diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index 14357444..2ee01e4c 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -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))); + } } /** diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index 13eae614..455e9e7f 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -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 { + 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, diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index bfa63f19..513930c7 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -100,6 +100,15 @@ export const EditorFileTree = ({ const autoExpandTimerRef = useRef | null>(null); const scrollRef = useRef(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 ( - + > + + ); })} @@ -556,7 +568,6 @@ interface DraggableTreeItemProps { dropTargetPath: string | null; isDragActive: boolean; onClick: (node: TreeNode) => 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} diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index e2399f9c..b736a130 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -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 (
@@ -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" > - + Loading files...
)} - {!loading && ( - - No files found - + {!loading && filteredFiles.length === 0 && ( +
No files found
)} - {fileItems.map((file) => { + {filteredFiles.map((file) => { return ( ); })} + {hasMore && ( +
+ {search + ? 'Refine search to see more...' + : `Type to search ${allFiles.length} files...`} +
+ )} diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index dcdc5445..c8089825 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -222,17 +222,18 @@ export const createEditorSlice: StateCreator = (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);