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() };
+}