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

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -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 (
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
{config.letter}
</span>
);
};
});

View file

@ -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<void> => {
try {
const files = await window.electronAPI.editor.listFiles();
if (!cancelled) {
if (projectPath) setQuickOpenCache(projectPath, files);
setAllFiles(files);
setLoading(false);
}

View file

@ -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 <span className="truncate text-[11px] text-text-secondary">{text}</span>;
}
@ -356,4 +356,4 @@ const HighlightedLine = ({
}
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.
*/
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<EditorKeyHandlerDeps>(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

View file

@ -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<AppState, [], [], EditorSlice> = (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);

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