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:
parent
533f9b9e06
commit
91412dccc1
11 changed files with 120 additions and 60 deletions
|
|
@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
31
src/renderer/utils/quickOpenCache.ts
Normal file
31
src/renderer/utils/quickOpenCache.ts
Normal 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() };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue