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 subdirs: string[] = [];
for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break;
@ -158,7 +159,7 @@ export class FileSearchService {
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFilePaths(projectRoot, fullPath, files, signal);
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
const relativePath = fullPath.startsWith(projectRoot)
@ -167,6 +168,14 @@ export class FileSearchService {
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 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) {
// Ignore well-known noise
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
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()) {
try {
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;
}
pendingEntries.push({ dirent, entryPath, type: 'symlink' });
} else if (dirent.isDirectory()) {
entries.push(this.buildEntry(dirent.name, entryPath, 'directory'));
pendingEntries.push({ dirent, entryPath, type: 'directory' });
} else if (dirent.isFile()) {
try {
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'));
}
pendingEntries.push({ dirent, entryPath, type: 'file' });
}
// Skip other types (block devices, sockets, etc.)
if (entries.length >= maxEntries) {
truncated = true;
break;
if (pendingEntries.length >= maxEntries) 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 { entries, truncated };
return { entries, truncated: pendingEntries.length >= maxEntries };
}
/**
@ -610,6 +617,26 @@ export class ProjectFileService {
// 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(
name: string,
entryPath: string,

View file

@ -100,6 +100,15 @@ export const EditorFileTree = ({
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(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
useEffect(() => {
return () => {
@ -149,12 +158,12 @@ export const EditorFileTree = ({
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
}, [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({
count: flatItems.length + (newItemInsert ? 1 : 0),
getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: draggedItem ? 20 : 10,
overscan: !dndReady ? 3 : draggedItem ? 20 : 10,
});
// Git status lookup: absolute path → status type
@ -451,17 +460,8 @@ export const EditorFileTree = ({
const item = flatItems[flatIdx];
return (
<DraggableTreeItem
<div
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={{
position: 'absolute',
top: `${virtualItem.start}px`,
@ -469,7 +469,19 @@ export const EditorFileTree = ({
width: '100%',
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>
@ -556,7 +568,6 @@ interface DraggableTreeItemProps {
dropTargetPath: string | null;
isDragActive: boolean;
onClick: (node: TreeNode<FileTreeEntry>) => void;
style: React.CSSProperties;
isRenaming?: boolean;
onRenameSubmit?: (newName: string) => void;
onRenameCancel?: () => void;
@ -571,7 +582,6 @@ const DraggableTreeItem = React.memo(
dropTargetPath,
isDragActive,
onClick,
style,
isRenaming,
onRenameSubmit,
onRenameCancel,
@ -651,17 +661,12 @@ const DraggableTreeItem = React.memo(
role="treeitem"
aria-selected={node.isFile ? isSelected : 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'
} ${isDragging ? 'opacity-30' : ''} ${
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' : ''}`}
style={{
...style,
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
display: 'flex',
alignItems: 'center',
}}
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}

View file

@ -5,7 +5,7 @@
* 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 { Command } from 'cmdk';
@ -15,6 +15,8 @@ import { FileIcon } from './FileIcon';
import type { QuickOpenFile } from '@shared/types/editor';
const MAX_RENDERED = 100;
// =============================================================================
// Types
// =============================================================================
@ -97,7 +99,24 @@ export const QuickOpenDialog = ({
[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 (
<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"
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
value={search}
onValueChange={setSearch}
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"
autoFocus
@ -132,12 +153,10 @@ export const QuickOpenDialog = ({
<span>Loading files...</span>
</div>
)}
{!loading && (
<Command.Empty className="p-6 text-center text-sm text-text-muted">
No files found
</Command.Empty>
{!loading && filteredFiles.length === 0 && (
<div className="p-6 text-center text-sm text-text-muted">No files found</div>
)}
{fileItems.map((file) => {
{filteredFiles.map((file) => {
return (
<Command.Item
key={file.path}
@ -153,6 +172,13 @@ export const QuickOpenDialog = ({
</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>
</div>

View file

@ -222,17 +222,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try {
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({
editorFileTree: result.entries,
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) {
const message = error instanceof Error ? error.message : String(error);
log.error('Failed to open editor:', message);