feat: optimize file and directory handling in editor services

- Enhanced FileSearchService to parallelize subdirectory traversal, improving performance when collecting file paths.
- Refactored ProjectFileService to classify directory entries without I/O, followed by parallel resolution of entries, optimizing file system access.
- Updated QuickOpenDialog to implement a search feature with a limit on rendered files, improving user experience when navigating large file sets.
- Introduced a new method for resolving symlinks in ProjectFileService, ensuring proper handling of symbolic links within the project structure.
This commit is contained in:
iliya 2026-03-01 21:13:11 +02:00
parent 4c5f0c3cc2
commit 509f7e82ab
5 changed files with 140 additions and 72 deletions

View file

@ -147,6 +147,7 @@ export class FileSearchService {
} }
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
const subdirs: string[] = [];
for (const entry of sorted) { for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break; if (signal?.aborted || files.length >= MAX_FILES) break;
@ -158,7 +159,7 @@ export class FileSearchService {
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFilePaths(projectRoot, fullPath, files, signal); subdirs.push(fullPath);
} else if (entry.isFile()) { } else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue; if (IGNORED_FILES.has(entry.name)) continue;
const relativePath = fullPath.startsWith(projectRoot) const relativePath = fullPath.startsWith(projectRoot)
@ -167,6 +168,14 @@ export class FileSearchService {
files.push({ path: fullPath, name: entry.name, relativePath }); files.push({ path: fullPath, name: entry.name, relativePath });
} }
} }
// 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;
const batch = subdirs.slice(i, i + DIR_CONCURRENCY);
await Promise.all(batch.map((dir) => this.collectFilePaths(projectRoot, dir, files, signal)));
}
} }
/** /**

View file

@ -90,51 +90,58 @@ export class ProjectFileService {
} }
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true }); const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
const entries: FileTreeEntry[] = [];
let truncated = false; // Phase 1: classify entries without I/O (instant)
const pendingEntries: {
dirent: { name: string };
entryPath: string;
type: 'file' | 'directory' | 'symlink';
}[] = [];
for (const dirent of dirents) { for (const dirent of dirents) {
// Ignore well-known noise
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue; if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue; if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
const entryPath = path.join(normalizedDir, dirent.name); const entryPath = path.join(normalizedDir, dirent.name);
if (!isPathWithinRoot(entryPath, projectRoot)) continue;
if (isGitInternalPath(entryPath)) continue;
// Symlink handling: resolve and re-check containment
if (dirent.isSymbolicLink()) { if (dirent.isSymbolicLink()) {
try { pendingEntries.push({ dirent, entryPath, type: 'symlink' });
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) {
continue; // Silently skip symlinks that escape project root (SEC-2)
}
const realStat = await fs.stat(realPath);
const entry = this.buildEntry(
dirent.name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
entries.push(entry);
} catch {
// Broken symlink — skip silently
continue;
}
} else if (dirent.isDirectory()) { } else if (dirent.isDirectory()) {
entries.push(this.buildEntry(dirent.name, entryPath, 'directory')); pendingEntries.push({ dirent, entryPath, type: 'directory' });
} else if (dirent.isFile()) { } else if (dirent.isFile()) {
try { pendingEntries.push({ dirent, entryPath, type: 'file' });
const fileStat = await fs.stat(entryPath);
entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size));
} catch {
// Can't stat — include without size
entries.push(this.buildEntry(dirent.name, entryPath, 'file'));
}
} }
// Skip other types (block devices, sockets, etc.)
if (entries.length >= maxEntries) { if (pendingEntries.length >= maxEntries) break;
truncated = true; }
break;
// Phase 2: resolve entries in parallel (I/O-bound)
const STAT_CONCURRENCY = 50;
const entries: FileTreeEntry[] = [];
for (let i = 0; i < pendingEntries.length; i += STAT_CONCURRENCY) {
const batch = pendingEntries.slice(i, i + STAT_CONCURRENCY);
const resolved = await Promise.all(
batch.map(async ({ dirent, entryPath, type }) => {
if (type === 'directory') {
return this.buildEntry(dirent.name, entryPath, 'directory');
}
if (type === 'symlink') {
return this.resolveSymlinkEntry(dirent.name, entryPath, projectRoot);
}
// file — stat for size
try {
const fileStat = await fs.stat(entryPath);
return this.buildEntry(dirent.name, entryPath, 'file', fileStat.size);
} catch {
return this.buildEntry(dirent.name, entryPath, 'file');
}
})
);
for (const entry of resolved) {
if (entry) entries.push(entry);
} }
} }
@ -144,7 +151,7 @@ export class ProjectFileService {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
return { entries, truncated }; return { entries, truncated: pendingEntries.length >= maxEntries };
} }
/** /**
@ -610,6 +617,26 @@ export class ProjectFileService {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private async resolveSymlinkEntry(
name: string,
entryPath: string,
projectRoot: string
): Promise<FileTreeEntry | null> {
try {
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) return null;
const realStat = await fs.stat(realPath);
return this.buildEntry(
name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
} catch {
return null; // broken symlink
}
}
private buildEntry( private buildEntry(
name: string, name: string,
entryPath: string, entryPath: string,

View file

@ -100,6 +100,15 @@ export const EditorFileTree = ({
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Defer DnD initialization — mount tree without drag/drop first, enable after idle
const [dndReady, setDndReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => setDndReady(true));
});
return () => cancelAnimationFrame(id);
}, []);
// Cleanup auto-expand timer on unmount // Cleanup auto-expand timer on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -149,12 +158,12 @@ export const EditorFileTree = ({
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 }; return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
}, [newItemState, flatItems]); }, [newItemState, flatItems]);
// Virtual scrolling — increase overscan during drag for more drop targets // Virtual scrolling — reduced overscan during initial mount, increase during drag
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: flatItems.length + (newItemInsert ? 1 : 0), count: flatItems.length + (newItemInsert ? 1 : 0),
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT, estimateSize: () => ITEM_HEIGHT,
overscan: draggedItem ? 20 : 10, overscan: !dndReady ? 3 : draggedItem ? 20 : 10,
}); });
// Git status lookup: absolute path → status type // Git status lookup: absolute path → status type
@ -451,17 +460,8 @@ export const EditorFileTree = ({
const item = flatItems[flatIdx]; const item = flatItems[flatIdx];
return ( return (
<DraggableTreeItem <div
key={item.node.fullPath} key={item.node.fullPath}
item={item}
activeNodePath={activeNodePath}
gitStatusMap={gitStatusMap}
dropTargetPath={dropTargetPath}
isDragActive={!!draggedItem}
onClick={handleNodeClick}
isRenaming={renamingPath === item.node.fullPath}
onRenameSubmit={handleRenameSubmit}
onRenameCancel={handleRenameCancel}
style={{ style={{
position: 'absolute', position: 'absolute',
top: `${virtualItem.start}px`, top: `${virtualItem.start}px`,
@ -469,7 +469,19 @@ export const EditorFileTree = ({
width: '100%', width: '100%',
height: `${virtualItem.size}px`, height: `${virtualItem.size}px`,
}} }}
/> >
<DraggableTreeItem
item={item}
activeNodePath={activeNodePath}
gitStatusMap={gitStatusMap}
dropTargetPath={dropTargetPath}
isDragActive={!!draggedItem}
onClick={handleNodeClick}
isRenaming={renamingPath === item.node.fullPath}
onRenameSubmit={handleRenameSubmit}
onRenameCancel={handleRenameCancel}
/>
</div>
); );
})} })}
</div> </div>
@ -556,7 +568,6 @@ interface DraggableTreeItemProps {
dropTargetPath: string | null; dropTargetPath: string | null;
isDragActive: boolean; isDragActive: boolean;
onClick: (node: TreeNode<FileTreeEntry>) => void; onClick: (node: TreeNode<FileTreeEntry>) => void;
style: React.CSSProperties;
isRenaming?: boolean; isRenaming?: boolean;
onRenameSubmit?: (newName: string) => void; onRenameSubmit?: (newName: string) => void;
onRenameCancel?: () => void; onRenameCancel?: () => void;
@ -571,7 +582,6 @@ const DraggableTreeItem = React.memo(
dropTargetPath, dropTargetPath,
isDragActive, isDragActive,
onClick, onClick,
style,
isRenaming, isRenaming,
onRenameSubmit, onRenameSubmit,
onRenameCancel, onRenameCancel,
@ -651,17 +661,12 @@ const DraggableTreeItem = React.memo(
role="treeitem" role="treeitem"
aria-selected={node.isFile ? isSelected : undefined} aria-selected={node.isFile ? isSelected : undefined}
aria-expanded={!node.isFile ? isExpanded : undefined} aria-expanded={!node.isFile ? isExpanded : undefined}
className={`flex cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${ className={`flex h-full cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary' isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
} ${isDragging ? 'opacity-30' : ''} ${ } ${isDragging ? 'opacity-30' : ''} ${
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : '' isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
} ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`} } ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`}
style={{ style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
...style,
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
display: 'flex',
alignItems: 'center',
}}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={0} tabIndex={0}

View file

@ -5,7 +5,7 @@
* Loads ALL project files via backend API on mount (not limited to expanded dirs). * Loads ALL project files via backend API on mount (not limited to expanded dirs).
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
@ -15,6 +15,8 @@ import { FileIcon } from './FileIcon';
import type { QuickOpenFile } from '@shared/types/editor'; import type { QuickOpenFile } from '@shared/types/editor';
const MAX_RENDERED = 100;
// ============================================================================= // =============================================================================
// Types // Types
// ============================================================================= // =============================================================================
@ -97,7 +99,24 @@ export const QuickOpenDialog = ({
[allFiles, onSelectFile, onClose] [allFiles, onSelectFile, onClose]
); );
const fileItems = allFiles; const [search, setSearch] = useState('');
const filteredFiles = useMemo(() => {
if (!search.trim()) return allFiles.slice(0, MAX_RENDERED);
const q = search.toLowerCase();
const matches: QuickOpenFile[] = [];
for (const file of allFiles) {
if (file.relativePath.toLowerCase().includes(q)) {
matches.push(file);
if (matches.length >= MAX_RENDERED) break;
}
}
return matches;
}, [allFiles, search]);
const hasMore = !search.trim()
? allFiles.length > MAX_RENDERED
: filteredFiles.length >= MAX_RENDERED;
return ( return (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]"> <div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
@ -119,8 +138,10 @@ export const QuickOpenDialog = ({
aria-label="Quick Open" aria-label="Quick Open"
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl" className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
> >
<Command label="Quick Open" shouldFilter={true}> <Command label="Quick Open" shouldFilter={false}>
<Command.Input <Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search files by name..." placeholder="Search files by name..."
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted" className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
autoFocus autoFocus
@ -132,12 +153,10 @@ export const QuickOpenDialog = ({
<span>Loading files...</span> <span>Loading files...</span>
</div> </div>
)} )}
{!loading && ( {!loading && filteredFiles.length === 0 && (
<Command.Empty className="p-6 text-center text-sm text-text-muted"> <div className="p-6 text-center text-sm text-text-muted">No files found</div>
No files found
</Command.Empty>
)} )}
{fileItems.map((file) => { {filteredFiles.map((file) => {
return ( return (
<Command.Item <Command.Item
key={file.path} key={file.path}
@ -153,6 +172,13 @@ export const QuickOpenDialog = ({
</Command.Item> </Command.Item>
); );
})} })}
{hasMore && (
<div className="p-2 text-center text-xs text-text-muted">
{search
? 'Refine search to see more...'
: `Type to search ${allFiles.length} files...`}
</div>
)}
</Command.List> </Command.List>
</Command> </Command>
</div> </div>

View file

@ -222,17 +222,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try { try {
await api.editor.open(projectPath); await api.editor.open(projectPath);
const result = await api.editor.readDir(projectPath);
// Parallelize: readDir + git status + watcher setup run concurrently
const [result] = await Promise.all([
api.editor.readDir(projectPath),
get().fetchGitStatus(),
get().toggleWatcher(true),
]);
set({ set({
editorFileTree: result.entries, editorFileTree: result.entries,
editorFileTreeLoading: false, editorFileTreeLoading: false,
}); });
// Fetch git status in background (non-blocking)
void get().fetchGitStatus();
// Auto-enable file watcher (standard editor behavior)
void get().toggleWatcher(true);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log.error('Failed to open editor:', message); log.error('Failed to open editor:', message);