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:
parent
4c5f0c3cc2
commit
509f7e82ab
5 changed files with 140 additions and 72 deletions
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue