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.
This commit is contained in:
iliya 2026-03-01 22:54:10 +02:00
parent 533f9b9e06
commit 91412dccc1
11 changed files with 120 additions and 60 deletions

View file

@ -248,10 +248,12 @@ export class FileSearchService {
} }
} }
// Recurse into subdirectories // Parallel subdirectory traversal (batched)
for (const subdir of subdirs) { const DIR_CONCURRENCY = 10;
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) {
if (signal?.aborted || files.length >= MAX_FILES) break; 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)));
} }
} }

View file

@ -536,8 +536,8 @@ export class ProjectFileService {
await fs.rename(normalizedSrc, newPath); await fs.rename(normalizedSrc, newPath);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') { if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
const stat = await fs.lstat(normalizedSrc); // Reuse srcStat from step 5 — no need for another fs.lstat
if (stat.isDirectory()) { if (isDirectory) {
await fs.cp(normalizedSrc, newPath, { recursive: true }); await fs.cp(normalizedSrc, newPath, { recursive: true });
} else { } else {
await fs.copyFile(normalizedSrc, newPath); await fs.copyFile(normalizedSrc, newPath);

View file

@ -2,9 +2,12 @@
* Status bar: cursor position, language, encoding, indent style, git branch. * Status bar: cursor position, language, encoding, indent style, git branch.
*/ */
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { GitBranch } from 'lucide-react'; import { GitBranch } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
interface EditorStatusBarProps { interface EditorStatusBarProps {
line: number; line: number;
@ -12,14 +15,18 @@ interface EditorStatusBarProps {
language: string; language: string;
} }
export const EditorStatusBar = ({ export const EditorStatusBar = React.memo(function EditorStatusBar({
line, line,
col, col,
language, language,
}: EditorStatusBarProps): React.ReactElement => { }: EditorStatusBarProps): React.ReactElement {
const gitBranch = useStore((s) => s.editorGitBranch); const { gitBranch, isGitRepo, watcherEnabled } = useStore(
const isGitRepo = useStore((s) => s.editorIsGitRepo); useShallow((s) => ({
const watcherEnabled = useStore((s) => s.editorWatcherEnabled); gitBranch: s.editorGitBranch,
isGitRepo: s.editorIsGitRepo,
watcherEnabled: s.editorWatcherEnabled,
}))
);
return ( return (
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted"> <div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
@ -49,4 +56,4 @@ export const EditorStatusBar = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -20,6 +20,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { EditorTabContextMenu } from './EditorTabContextMenu'; import { EditorTabContextMenu } from './EditorTabContextMenu';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
@ -43,9 +44,13 @@ interface EditorTabBarProps {
export const EditorTabBar = ({ export const EditorTabBar = ({
onRequestCloseTab, onRequestCloseTab,
}: EditorTabBarProps): React.ReactElement | null => { }: EditorTabBarProps): React.ReactElement | null => {
const tabs = useStore((s) => s.editorOpenTabs); const { tabs, activeTabId, modifiedFiles } = useStore(
const activeTabId = useStore((s) => s.editorActiveTabId); useShallow((s) => ({
const modifiedFiles = useStore((s) => s.editorModifiedFiles); tabs: s.editorOpenTabs,
activeTabId: s.editorActiveTabId,
modifiedFiles: s.editorModifiedFiles,
}))
);
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
const reorderEditorTabs = useStore((s) => s.reorderEditorTabs); const reorderEditorTabs = useStore((s) => s.reorderEditorTabs);
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs); const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);

View file

@ -9,17 +9,22 @@ import { useStore } from '@renderer/store';
import { editorBridge } from '@renderer/utils/editorBridge'; import { editorBridge } from '@renderer/utils/editorBridge';
import { shortcutLabel } from '@renderer/utils/platformKeys'; import { shortcutLabel } from '@renderer/utils/platformKeys';
import { Redo2, Save, Undo2, WrapText } from 'lucide-react'; import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
// ============================================================================= // =============================================================================
// Component // Component
// ============================================================================= // =============================================================================
export const EditorToolbar = (): React.ReactElement | null => { export const EditorToolbar = (): React.ReactElement | null => {
const activeTabId = useStore((s) => s.editorActiveTabId); const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
const modifiedFiles = useStore((s) => s.editorModifiedFiles); useShallow((s) => ({
const saving = useStore((s) => s.editorSaving); activeTabId: s.editorActiveTabId,
modifiedFiles: s.editorModifiedFiles,
saving: s.editorSaving,
lineWrap: s.editorLineWrap,
}))
);
const saveFile = useStore((s) => s.saveFile); const saveFile = useStore((s) => s.saveFile);
const lineWrap = useStore((s) => s.editorLineWrap);
const toggleLineWrap = useStore((s) => s.toggleLineWrap); const toggleLineWrap = useStore((s) => s.toggleLineWrap);
if (!activeTabId) return null; if (!activeTabId) return null;

View file

@ -10,6 +10,8 @@
* - R (renamed) cyan * - R (renamed) cyan
*/ */
import React from 'react';
import type { GitFileStatusType } from '@shared/types/editor'; import type { GitFileStatusType } from '@shared/types/editor';
// ============================================================================= // =============================================================================
@ -33,11 +35,13 @@ interface GitStatusBadgeProps {
status: GitFileStatusType; 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]; const config = STATUS_CONFIG[status];
return ( return (
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}> <span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
{config.letter} {config.letter}
</span> </span>
); );
}; });

View file

@ -8,6 +8,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getQuickOpenCache, setQuickOpenCache } from '@renderer/utils/quickOpenCache';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@ -47,14 +48,23 @@ export const QuickOpenDialog = ({
setAllFiles([]); setAllFiles([]);
} }
// Load all project files via backend API // Load all project files via backend API (with module-level cache)
useEffect(() => { 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; let cancelled = false;
const fetchFiles = async (): Promise<void> => { const fetchFiles = async (): Promise<void> => {
try { try {
const files = await window.electronAPI.editor.listFiles(); const files = await window.electronAPI.editor.listFiles();
if (!cancelled) { if (!cancelled) {
if (projectPath) setQuickOpenCache(projectPath, files);
setAllFiles(files); setAllFiles(files);
setLoading(false); setLoading(false);
} }

View file

@ -5,7 +5,7 @@
* Results are clickable to open the file at the matched line. * 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 { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -315,11 +315,11 @@ interface HighlightedLineProps {
caseSensitive: boolean; caseSensitive: boolean;
} }
const HighlightedLine = ({ const HighlightedLine = React.memo(function HighlightedLine({
text, text,
query, query,
caseSensitive, caseSensitive,
}: HighlightedLineProps): React.ReactElement => { }: HighlightedLineProps): React.ReactElement {
if (!query) { if (!query) {
return <span className="truncate text-[11px] text-text-secondary">{text}</span>; return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
} }
@ -356,4 +356,4 @@ const HighlightedLine = ({
} }
return <span className="truncate text-[11px]">{parts}</span>; return <span className="truncate text-[11px]">{parts}</span>;
}; });

View file

@ -5,12 +5,13 @@
* CM6-internal shortcuts (Cmd+Z, Cmd+Shift+Z, Cmd+A, Cmd+D) are handled by CodeMirror directly. * 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 { openSearchPanel } from '@codemirror/search';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { editorBridge } from '@renderer/utils/editorBridge'; import { editorBridge } from '@renderer/utils/editorBridge';
import { physicalKey } from '@renderer/utils/keyboardUtils'; import { physicalKey } from '@renderer/utils/keyboardUtils';
import { useShallow } from 'zustand/react/shallow';
import type { EditorFileTab } from '@shared/types/editor'; import type { EditorFileTab } from '@shared/types/editor';
@ -190,46 +191,39 @@ export function useEditorKeyboardShortcuts({
onToggleSidebar, onToggleSidebar,
onClose: _onClose, onClose: _onClose,
}: UseEditorKeyboardShortcutsOptions): void { }: UseEditorKeyboardShortcutsOptions): void {
const openTabs = useStore((s) => s.editorOpenTabs); const { openTabs, activeTabId } = useStore(
const activeTabId = useStore((s) => s.editorActiveTabId); useShallow((s) => ({
openTabs: s.editorOpenTabs,
activeTabId: s.editorActiveTabId,
}))
);
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
const saveFile = useStore((s) => s.saveFile); const saveFile = useStore((s) => s.saveFile);
const saveAllFiles = useStore((s) => s.saveAllFiles); const saveAllFiles = useStore((s) => s.saveAllFiles);
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges); const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
const toggleLineWrap = useStore((s) => s.toggleLineWrap); const toggleLineWrap = useStore((s) => s.toggleLineWrap);
const handleKeyDown = useCallback( // Store all deps in a ref so the keydown handler has a stable identity
(e: KeyboardEvent) => { const depsRef = useRef<EditorKeyHandlerDeps>(null!);
const handler = createEditorKeyHandler({ depsRef.current = {
activeTabId, activeTabId,
openTabs, openTabs,
setActiveEditorTab, setActiveEditorTab,
saveFile, saveFile,
saveAllFiles, saveAllFiles,
hasUnsavedChanges, hasUnsavedChanges,
onToggleQuickOpen, onToggleQuickOpen,
onToggleSearchPanel, onToggleSearchPanel,
onToggleGoToLine, onToggleGoToLine,
onToggleSidebar, onToggleSidebar,
onToggleLineWrap: toggleLineWrap, onToggleLineWrap: toggleLineWrap,
getEditorView: () => editorBridge.getView(), getEditorView: () => editorBridge.getView(),
}); };
handler(e);
}, const handleKeyDown = useCallback((e: KeyboardEvent) => {
[ const handler = createEditorKeyHandler(depsRef.current);
activeTabId, handler(e);
openTabs, }, []);
setActiveEditorTab,
saveFile,
saveAllFiles,
hasUnsavedChanges,
onToggleQuickOpen,
onToggleSearchPanel,
onToggleGoToLine,
onToggleSidebar,
toggleLineWrap,
]
);
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown, true); // capture phase window.addEventListener('keydown', handleKeyDown, true); // capture phase

View file

@ -10,6 +10,7 @@
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages'; import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages';
import { editorBridge } from '@renderer/utils/editorBridge'; import { editorBridge } from '@renderer/utils/editorBridge';
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -920,6 +921,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Refresh parent directory in tree for create/delete // Refresh parent directory in tree for create/delete
if (event.type === 'create' || event.type === 'delete') { if (event.type === 'create' || event.type === 'delete') {
invalidateQuickOpenCache();
const parentDir = event.path.substring(0, event.path.lastIndexOf('/')); const parentDir = event.path.substring(0, event.path.lastIndexOf('/'));
if (parentDir && editorProjectPath) { if (parentDir && editorProjectPath) {
void refreshDirectory(get, set, parentDir); void refreshDirectory(get, set, parentDir);

View file

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