From 91412dccc1e89214c2bf5c669933967d9feb92cc Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 1 Mar 2026 22:54:10 +0200 Subject: [PATCH] feat: optimize file handling and component performance in editor - Enhanced FileSearchService to implement parallel subdirectory traversal, improving efficiency in file collection. - Refactored ProjectFileService to reuse directory stats, reducing unnecessary file system calls. - Updated multiple components (EditorStatusBar, EditorTabBar, EditorToolbar, GitStatusBadge, QuickOpenDialog, SearchInFilesPanel) to utilize Zustand's shallow comparison for improved state management and performance. - Introduced a module-level cache for QuickOpenDialog to minimize redundant API calls and enhance user experience. - Added cache invalidation logic in editorSlice to ensure fresh data on file system changes. --- src/main/services/editor/FileSearchService.ts | 8 ++- .../services/editor/ProjectFileService.ts | 4 +- .../team/editor/EditorStatusBar.tsx | 19 ++++-- .../components/team/editor/EditorTabBar.tsx | 11 +++- .../components/team/editor/EditorToolbar.tsx | 13 ++-- .../components/team/editor/GitStatusBadge.tsx | 8 ++- .../team/editor/QuickOpenDialog.tsx | 12 +++- .../team/editor/SearchInFilesPanel.tsx | 8 +-- .../hooks/useEditorKeyboardShortcuts.ts | 64 +++++++++---------- src/renderer/store/slices/editorSlice.ts | 2 + src/renderer/utils/quickOpenCache.ts | 31 +++++++++ 11 files changed, 120 insertions(+), 60 deletions(-) create mode 100644 src/renderer/utils/quickOpenCache.ts diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index 4e347279..63aba750 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -248,10 +248,12 @@ export class FileSearchService { } } - // Recurse into subdirectories - for (const subdir of subdirs) { + // Parallel subdirectory traversal (batched) + const DIR_CONCURRENCY = 10; + for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) { if (signal?.aborted || files.length >= MAX_FILES) break; - await this.collectFiles(projectRoot, subdir, files, signal); + const batch = subdirs.slice(i, i + DIR_CONCURRENCY); + await Promise.all(batch.map((dir) => this.collectFiles(projectRoot, dir, files, signal))); } } diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index 455e9e7f..53587ff9 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -536,8 +536,8 @@ export class ProjectFileService { await fs.rename(normalizedSrc, newPath); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'EXDEV') { - const stat = await fs.lstat(normalizedSrc); - if (stat.isDirectory()) { + // Reuse srcStat from step 5 — no need for another fs.lstat + if (isDirectory) { await fs.cp(normalizedSrc, newPath, { recursive: true }); } else { await fs.copyFile(normalizedSrc, newPath); diff --git a/src/renderer/components/team/editor/EditorStatusBar.tsx b/src/renderer/components/team/editor/EditorStatusBar.tsx index e55136f4..47751199 100644 --- a/src/renderer/components/team/editor/EditorStatusBar.tsx +++ b/src/renderer/components/team/editor/EditorStatusBar.tsx @@ -2,9 +2,12 @@ * Status bar: cursor position, language, encoding, indent style, git branch. */ +import React from 'react'; + import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { GitBranch } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; interface EditorStatusBarProps { line: number; @@ -12,14 +15,18 @@ interface EditorStatusBarProps { language: string; } -export const EditorStatusBar = ({ +export const EditorStatusBar = React.memo(function EditorStatusBar({ line, col, language, -}: EditorStatusBarProps): React.ReactElement => { - const gitBranch = useStore((s) => s.editorGitBranch); - const isGitRepo = useStore((s) => s.editorIsGitRepo); - const watcherEnabled = useStore((s) => s.editorWatcherEnabled); +}: EditorStatusBarProps): React.ReactElement { + const { gitBranch, isGitRepo, watcherEnabled } = useStore( + useShallow((s) => ({ + gitBranch: s.editorGitBranch, + isGitRepo: s.editorIsGitRepo, + watcherEnabled: s.editorWatcherEnabled, + })) + ); return (
@@ -49,4 +56,4 @@ export const EditorStatusBar = ({
); -}; +}); diff --git a/src/renderer/components/team/editor/EditorTabBar.tsx b/src/renderer/components/team/editor/EditorTabBar.tsx index c97bfd9a..b624e02f 100644 --- a/src/renderer/components/team/editor/EditorTabBar.tsx +++ b/src/renderer/components/team/editor/EditorTabBar.tsx @@ -20,6 +20,7 @@ import { CSS } from '@dnd-kit/utilities'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { X } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { EditorTabContextMenu } from './EditorTabContextMenu'; import { FileIcon } from './FileIcon'; @@ -43,9 +44,13 @@ interface EditorTabBarProps { export const EditorTabBar = ({ onRequestCloseTab, }: EditorTabBarProps): React.ReactElement | null => { - const tabs = useStore((s) => s.editorOpenTabs); - const activeTabId = useStore((s) => s.editorActiveTabId); - const modifiedFiles = useStore((s) => s.editorModifiedFiles); + const { tabs, activeTabId, modifiedFiles } = useStore( + useShallow((s) => ({ + tabs: s.editorOpenTabs, + activeTabId: s.editorActiveTabId, + modifiedFiles: s.editorModifiedFiles, + })) + ); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); const reorderEditorTabs = useStore((s) => s.reorderEditorTabs); const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs); diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx index 0744fd20..85ee40f5 100644 --- a/src/renderer/components/team/editor/EditorToolbar.tsx +++ b/src/renderer/components/team/editor/EditorToolbar.tsx @@ -9,17 +9,22 @@ import { useStore } from '@renderer/store'; import { editorBridge } from '@renderer/utils/editorBridge'; import { shortcutLabel } from '@renderer/utils/platformKeys'; import { Redo2, Save, Undo2, WrapText } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; // ============================================================================= // Component // ============================================================================= export const EditorToolbar = (): React.ReactElement | null => { - const activeTabId = useStore((s) => s.editorActiveTabId); - const modifiedFiles = useStore((s) => s.editorModifiedFiles); - const saving = useStore((s) => s.editorSaving); + const { activeTabId, modifiedFiles, saving, lineWrap } = useStore( + useShallow((s) => ({ + activeTabId: s.editorActiveTabId, + modifiedFiles: s.editorModifiedFiles, + saving: s.editorSaving, + lineWrap: s.editorLineWrap, + })) + ); const saveFile = useStore((s) => s.saveFile); - const lineWrap = useStore((s) => s.editorLineWrap); const toggleLineWrap = useStore((s) => s.toggleLineWrap); if (!activeTabId) return null; diff --git a/src/renderer/components/team/editor/GitStatusBadge.tsx b/src/renderer/components/team/editor/GitStatusBadge.tsx index 61695503..e2a5caba 100644 --- a/src/renderer/components/team/editor/GitStatusBadge.tsx +++ b/src/renderer/components/team/editor/GitStatusBadge.tsx @@ -10,6 +10,8 @@ * - R (renamed) — cyan */ +import React from 'react'; + import type { GitFileStatusType } from '@shared/types/editor'; // ============================================================================= @@ -33,11 +35,13 @@ interface GitStatusBadgeProps { status: GitFileStatusType; } -export const GitStatusBadge = ({ status }: GitStatusBadgeProps): React.ReactElement => { +export const GitStatusBadge = React.memo(function GitStatusBadge({ + status, +}: GitStatusBadgeProps): React.ReactElement { const config = STATUS_CONFIG[status]; return ( {config.letter} ); -}; +}); diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index b736a130..4ad9a6c3 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; +import { getQuickOpenCache, setQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { Command } from 'cmdk'; import { Loader2 } from 'lucide-react'; @@ -47,14 +48,23 @@ export const QuickOpenDialog = ({ setAllFiles([]); } - // Load all project files via backend API + // Load all project files via backend API (with module-level cache) useEffect(() => { + // Use cache if fresh and for the same project + const cached = projectPath ? getQuickOpenCache(projectPath) : null; + if (cached) { + setAllFiles(cached.files); + setLoading(false); + return; + } + let cancelled = false; const fetchFiles = async (): Promise => { try { const files = await window.electronAPI.editor.listFiles(); if (!cancelled) { + if (projectPath) setQuickOpenCache(projectPath, files); setAllFiles(files); setLoading(false); } diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx index b9ee5428..46de8da7 100644 --- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx +++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx @@ -5,7 +5,7 @@ * Results are clickable to open the file at the matched line. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -315,11 +315,11 @@ interface HighlightedLineProps { caseSensitive: boolean; } -const HighlightedLine = ({ +const HighlightedLine = React.memo(function HighlightedLine({ text, query, caseSensitive, -}: HighlightedLineProps): React.ReactElement => { +}: HighlightedLineProps): React.ReactElement { if (!query) { return {text}; } @@ -356,4 +356,4 @@ const HighlightedLine = ({ } return {parts}; -}; +}); diff --git a/src/renderer/hooks/useEditorKeyboardShortcuts.ts b/src/renderer/hooks/useEditorKeyboardShortcuts.ts index 74a5f0f7..db690f94 100644 --- a/src/renderer/hooks/useEditorKeyboardShortcuts.ts +++ b/src/renderer/hooks/useEditorKeyboardShortcuts.ts @@ -5,12 +5,13 @@ * CM6-internal shortcuts (Cmd+Z, Cmd+Shift+Z, Cmd+A, Cmd+D) are handled by CodeMirror directly. */ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { openSearchPanel } from '@codemirror/search'; import { useStore } from '@renderer/store'; import { editorBridge } from '@renderer/utils/editorBridge'; import { physicalKey } from '@renderer/utils/keyboardUtils'; +import { useShallow } from 'zustand/react/shallow'; import type { EditorFileTab } from '@shared/types/editor'; @@ -190,46 +191,39 @@ export function useEditorKeyboardShortcuts({ onToggleSidebar, onClose: _onClose, }: UseEditorKeyboardShortcutsOptions): void { - const openTabs = useStore((s) => s.editorOpenTabs); - const activeTabId = useStore((s) => s.editorActiveTabId); + const { openTabs, activeTabId } = useStore( + useShallow((s) => ({ + openTabs: s.editorOpenTabs, + activeTabId: s.editorActiveTabId, + })) + ); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); const saveFile = useStore((s) => s.saveFile); const saveAllFiles = useStore((s) => s.saveAllFiles); const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges); const toggleLineWrap = useStore((s) => s.toggleLineWrap); - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const handler = createEditorKeyHandler({ - activeTabId, - openTabs, - setActiveEditorTab, - saveFile, - saveAllFiles, - hasUnsavedChanges, - onToggleQuickOpen, - onToggleSearchPanel, - onToggleGoToLine, - onToggleSidebar, - onToggleLineWrap: toggleLineWrap, - getEditorView: () => editorBridge.getView(), - }); - handler(e); - }, - [ - activeTabId, - openTabs, - setActiveEditorTab, - saveFile, - saveAllFiles, - hasUnsavedChanges, - onToggleQuickOpen, - onToggleSearchPanel, - onToggleGoToLine, - onToggleSidebar, - toggleLineWrap, - ] - ); + // Store all deps in a ref so the keydown handler has a stable identity + const depsRef = useRef(null!); + depsRef.current = { + activeTabId, + openTabs, + setActiveEditorTab, + saveFile, + saveAllFiles, + hasUnsavedChanges, + onToggleQuickOpen, + onToggleSearchPanel, + onToggleGoToLine, + onToggleSidebar, + onToggleLineWrap: toggleLineWrap, + getEditorView: () => editorBridge.getView(), + }; + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + const handler = createEditorKeyHandler(depsRef.current); + handler(e); + }, []); useEffect(() => { window.addEventListener('keydown', handleKeyDown, true); // capture phase diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index c8089825..394ee14d 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -10,6 +10,7 @@ import { api } from '@renderer/api'; import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages'; import { editorBridge } from '@renderer/utils/editorBridge'; +import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; import { createLogger } from '@shared/utils/logger'; @@ -920,6 +921,7 @@ export const createEditorSlice: StateCreator = (s // Refresh parent directory in tree for create/delete if (event.type === 'create' || event.type === 'delete') { + invalidateQuickOpenCache(); const parentDir = event.path.substring(0, event.path.lastIndexOf('/')); if (parentDir && editorProjectPath) { void refreshDirectory(get, set, parentDir); diff --git a/src/renderer/utils/quickOpenCache.ts b/src/renderer/utils/quickOpenCache.ts new file mode 100644 index 00000000..6853939f --- /dev/null +++ b/src/renderer/utils/quickOpenCache.ts @@ -0,0 +1,31 @@ +/** + * Module-level cache for Quick Open file list. + * Separated from QuickOpenDialog to avoid circular dependency with editorSlice. + */ + +import type { QuickOpenFile } from '@shared/types/editor'; + +const FILE_LIST_CACHE_TTL = 10_000; // 10 seconds + +let fileListCache: { files: QuickOpenFile[]; projectPath: string; timestamp: number } | null = null; + +/** Invalidate file list cache (call on file watcher create/delete events) */ +export function invalidateQuickOpenCache(): void { + fileListCache = null; +} + +/** Get cached file list if fresh and for the same project */ +export function getQuickOpenCache(projectPath: string): { files: QuickOpenFile[] } | null { + if ( + fileListCache?.projectPath === projectPath && + Date.now() - fileListCache.timestamp < FILE_LIST_CACHE_TTL + ) { + return { files: fileListCache.files }; + } + return null; +} + +/** Store file list in cache */ +export function setQuickOpenCache(projectPath: string, files: QuickOpenFile[]): void { + fileListCache = { files, projectPath, timestamp: Date.now() }; +}