From 779b21c61bd6dbc318d1f8dc0c8e5d8219dcf3fe Mon Sep 17 00:00:00 2001 From: Evan Klem Date: Fri, 26 Jun 2026 22:51:08 -0400 Subject: [PATCH] Lazy-load the file tree, decouple the workspace index The explorer rendered as "only dot folders / empty" when a project rooted a large early-sorted directory (e.g. a fresh .venv with ~11k files). The eager whole-tree walk in list_dir capped at 2500 files with a hard break, and because entries sort folders-first/lowercased, an oversized early subtree exhausted the budget and every sibling after it was dropped. .venv and the python cache dirs were also absent from the ignore list. Switch to VS Code-convention lazy resolution and split the two concerns the editor panel had conflated: - fs_list_dir(path): one level only, no cap; folders come back collapsed and resolve their children on expand. fs_list_files(): complete, gitignore- aware index via `rg --files` for quick-open and Monaco cross-file types, which previously rode on the same capped tree walk. - venv/__pycache__/.mypy_cache/.pytest_cache/.ruff_cache added to the built-in ignore list. - editor.listDir / editor.listFiles plumbed through the host RPC adapter, SDK client, and Tauri bridge; mapped under the editor.read permission. - Shared FileTree gains opt-in lazy onExpand (eager KB usage unchanged); the editor panel resolves children on expand, preserves expansion across create/delete/rename refreshes, and feeds quick-open/Monaco from the decoupled index. Default-open skips binary assets the complete index now surfaces. Co-Authored-By: Claude Opus 4.8 --- packages/host/src/plugin-loader.ts | 2 + packages/host/src/rpc-server.ts | 40 ++++++ packages/sdk/src/host.ts | 4 + plugins/editor/component.tsx | 131 ++++++++++++++---- plugins/shared/file-tree.tsx | 23 +++- plugins/shared/index.tsx | 2 +- src-tauri/src/fs_watch.rs | 206 ++++++++++++++++++++++++++++- src-tauri/src/main.rs | 2 + src/App.tsx | 10 ++ src/spine/hostRpcServer.test.ts | 46 +++++++ 10 files changed, 435 insertions(+), 31 deletions(-) diff --git a/packages/host/src/plugin-loader.ts b/packages/host/src/plugin-loader.ts index 6165108..3b926fe 100644 --- a/packages/host/src/plugin-loader.ts +++ b/packages/host/src/plugin-loader.ts @@ -235,6 +235,8 @@ function permissionForMethod(method: string): HostPermission | null { case 'knowledge.write': return 'knowledge.write'; case 'editor.tree': + case 'editor.listDir': + case 'editor.listFiles': case 'editor.open': case 'editor.read': return 'editor.read'; diff --git a/packages/host/src/rpc-server.ts b/packages/host/src/rpc-server.ts index 9e0bc4d..19590a3 100644 --- a/packages/host/src/rpc-server.ts +++ b/packages/host/src/rpc-server.ts @@ -129,6 +129,24 @@ export type FileTreeNode = | { kind: 'file'; name: string; path: string; subtitle?: string } | { kind: 'folder'; name: string; children: FileTreeNode[] }; +/* no-adapter fallbacks for the lazy editor.listDir / editor.listFiles handlers, + so headless hosts and tests still respond off the in-memory fileTree. */ +function sliceTreeLevel(tree: FileTreeNode[], path: string): FileTreeNode[] { + const segments = path.split('/').filter(Boolean); + let level = tree; + for (const segment of segments) { + const folder = level.find((node) => node.kind === 'folder' && node.name === segment); + if (!folder || folder.kind !== 'folder') return []; + level = folder.children; + } + // collapse folders at this level — children are resolved on demand + return level.map((node) => (node.kind === 'folder' ? { kind: 'folder', name: node.name, children: [] } : node)); +} + +function flattenTreeFiles(tree: FileTreeNode[]): string[] { + return tree.flatMap((node) => (node.kind === 'file' ? [node.path] : flattenTreeFiles(node.children))); +} + export type SkillRecord = { id: string; name: string; @@ -297,6 +315,8 @@ export type ExternalOpener = (url: string) => Promise | boolean; export type EditorSearchMatch = { file: string; line: number; text: string }; export type FileSystemAdapter = { listTree?: () => Promise; + listDir?: (path: string) => Promise; + listFiles?: () => Promise; readText?: (path: string) => Promise; writeText?: (path: string, content: string) => Promise; search?: (opts: { query: string; regex?: boolean; glob?: string; limit?: number }) => Promise; @@ -1565,6 +1585,24 @@ export class HostRpcServer { } return { tree: this.fileTree }; }); + /* lazy one-level listing — the explorer resolves a folder's children on + expand instead of walking the whole tree up front. without an adapter we + slice the in-memory fileTree so tests/headless hosts still respond. */ + this.registerHandler('editor.listDir', async (params) => { + const { path } = params as { path: string }; + if (this.fileSystemAdapter?.listDir) { + return { tree: await this.fileSystemAdapter.listDir(path) }; + } + return { tree: sliceTreeLevel(this.fileTree, path) }; + }); + /* complete workspace file index — feeds quick-open and cross-file + resolution, kept separate from the lazily-loaded explorer tree. */ + this.registerHandler('editor.listFiles', async () => { + if (this.fileSystemAdapter?.listFiles) { + return { files: await this.fileSystemAdapter.listFiles() }; + } + return { files: flattenTreeFiles(this.fileTree) }; + }); this.registerHandler('editor.open', (params) => { const { path } = params as { path: string }; this.publish('editor:opened', { path }); @@ -2640,6 +2678,8 @@ const RPC_PARAM_VALIDATORS: Record = { 'ui.openExternal': (params) => validateRef('https://polypore.dev/schemas/rpc/ui.schema.json#/definitions/ui.openExternal.params', params), 'state.get': objectShape({ key: { required: true, check: (value) => stringValue(value) && STATE_KEYS.has(value), message: 'key must be a known state key' } }), 'editor.tree': emptyParams, + 'editor.listDir': objectShape({ path: { required: true, check: stringValue, message: 'path must be a string' } }), + 'editor.listFiles': emptyParams, 'editor.open': objectShape({ path: { required: true, check: stringValue, message: 'path must be a string' }, opts: { check: looseObject, message: 'opts must be an object' }, diff --git a/packages/sdk/src/host.ts b/packages/sdk/src/host.ts index a7a9014..5eb24cf 100644 --- a/packages/sdk/src/host.ts +++ b/packages/sdk/src/host.ts @@ -174,6 +174,8 @@ export interface PolyporeHost { }; editor: { tree(): Promise<{ tree: FileTreeNode[] }>; + listDir(path: string): Promise<{ tree: FileTreeNode[] }>; + listFiles(): Promise<{ files: string[] }>; open(path: string, opts?: { line?: number; col?: number }): Promise<{ opened: boolean; path: string }>; onOpen(fn: (event: { path: string }) => void): Unsubscribe; read(path: string): Promise<{ path: string; content: string }>; @@ -683,6 +685,8 @@ export function createLoopbackHost( }, editor: { tree: () => call<{ tree: FileTreeNode[] }>('editor.tree', {}), + listDir: (path) => call<{ tree: FileTreeNode[] }>('editor.listDir', { path }), + listFiles: () => call<{ files: string[] }>('editor.listFiles', {}), open: (path, opts) => call<{ opened: boolean; path: string }>('editor.open', { path, opts }), onOpen: (fn) => subscribe?.('editor:opened', (payload) => fn(payload as { path: string })) ?? (() => {}), read: (path) => call<{ path: string; content: string }>('editor.read', { path }), diff --git a/plugins/editor/component.tsx b/plugins/editor/component.tsx index fccf2a1..2ffefbd 100644 --- a/plugins/editor/component.tsx +++ b/plugins/editor/component.tsx @@ -195,6 +195,7 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; info: FileTreeContextInfo } | null>(null); const [query, setQuery] = useState(''); const [tree, setTree] = useState([]); + const [indexFiles, setIndexFiles] = useState([]); const [fileText, setFileText] = useState(''); const [monacoReady, setMonacoReady] = useState(false); const [diagnostics, setDiagnostics] = useState([]); @@ -258,23 +259,15 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { the yield, this fires synchronously with all the other editor useEffects after commit and the panel can't paint until the chain drains. with it, the editor frame paints instantly and the - file tree fills in on the next task. */ + file tree fills in on the next task. only the root level is fetched; + a folder's children are resolved lazily on expand (loadChildren). */ let cancelled = false; const cancelSchedule = scheduleAfterPaint(() => { if (cancelled) return; perfPoint('editor:tree-effect-fires'); - host.editor.tree().then((result) => { + host.editor.listDir('').then((result) => { if (cancelled || !result.tree) return; - const nextTree = result.tree as FileNode[]; - setTree(nextTree); - const nextFiles = flattenFiles(nextTree); - setActiveFile((current) => { - if (current && nextFiles.includes(current)) return current; - if (nextFiles.length === 0) return ''; - const preferred = nextFiles.find((path) => path.toLowerCase().endsWith('readme.md')) ?? nextFiles[0]; - setOpenFiles([preferred]); - return preferred; - }); + setTree(result.tree as FileNode[]); }).catch(() => { setTree([]); }); @@ -285,6 +278,70 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { }; }, [host]); + /* workspace file index — complete and gitignore-aware, kept separate from the + lazily-loaded explorer tree so quick-open and Monaco cross-file resolution + always see every file. also drives the initial active-file pick. */ + useEffect(() => { + let cancelled = false; + host.editor.listFiles().then((result) => { + if (cancelled || !result.files) return; + setIndexFiles(result.files); + setActiveFile((current) => { + if (current && result.files.includes(current)) return current; + if (result.files.length === 0) return ''; + const preferred = pickDefaultFile(result.files); + setOpenFiles([preferred]); + return preferred; + }); + }).catch(() => { + if (!cancelled) setIndexFiles([]); + }); + return () => { cancelled = true; }; + }, [host]); + + /* mirror tree into a ref so async refreshes can read which folders were + expanded without taking `tree` as a dependency (would re-bind callbacks + on every keystroke-driven re-render). */ + const treeRef = useRef([]); + useEffect(() => { treeRef.current = tree; }, [tree]); + + /* resolve one folder's children on first expand and merge them in place. */ + const loadChildren = useCallback((folderPath: string) => { + host.editor.listDir(folderPath).then((result) => { + if (!result.tree) return; + setTree((current) => mergeChildren(current, folderPath, result.tree as FileNode[])); + }).catch(() => {}); + }, [host]); + + const refreshIndex = useCallback(() => { + host.editor.listFiles() + .then((result) => setIndexFiles(result.files ?? [])) + .catch(() => {}); + }, [host]); + + /* re-fetch exactly the levels that were previously loaded so a create/delete/ + rename refreshes the tree without collapsing the user's expanded folders. */ + const reloadTree = useCallback(async () => { + const rebuild = async (folderPath: string, prev: FileNode[]): Promise => { + const result = await host.editor.listDir(folderPath); + const fresh = (result.tree ?? []) as FileNode[]; + return Promise.all(fresh.map(async (node) => { + if (node.kind !== 'folder') return node; + const prevFolder = prev.find( + (p): p is Extract => p.kind === 'folder' && p.name === node.name, + ); + if (!prevFolder?.loaded) return node; + const childPath = folderPath ? `${folderPath}/${node.name}` : node.name; + return { ...node, children: await rebuild(childPath, prevFolder.children), loaded: true }; + })); + }; + try { + setTree(await rebuild('', treeRef.current)); + } catch { + /* leave the existing tree in place if a refresh fails */ + } + }, [host]); + useEffect(() => { let cancelled = false; host.editor.read(LANGUAGE_SERVERS_CONFIG_PATH).then((result) => { @@ -336,7 +393,7 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { window.clearTimeout(timeout); }; }, [activeFile, fileText, host, monacoReady]); - const allFiles = useMemo(() => flattenFiles(tree), [tree]); + const allFiles = indexFiles; const isDirty = activeFile !== '' && fileText !== savedText; const newEntryDir = newFilePath.includes('/') ? newFilePath.slice(0, newFilePath.lastIndexOf('/') + 1) : ''; const diagnosticsByFile = useMemo(() => { @@ -937,8 +994,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { setNewFileOpen(false); setNewFilePath(''); openFile(path, { preserveStatus: true }); - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); setSavedText(''); setFileText(''); setSaveStatus('created'); @@ -970,8 +1027,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { await host.fs.mkdir(path); setNewFileOpen(false); setNewFilePath(''); - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); setSaveStatus('folder created'); } catch (err) { setFileActionError(err instanceof Error ? err.message : 'could not create folder'); @@ -990,8 +1047,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { await host.fs.mkdir(path); setPlusPopupOpen(false); setPlusName(''); - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); setSaveStatus('folder created'); } catch (err) { setPlusError(err instanceof Error ? err.message : 'could not create folder'); @@ -1003,8 +1060,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { setPlusPopupOpen(false); setPlusName(''); openFile(path, { preserveStatus: true }); - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); setSavedText(''); setFileText(''); setSaveStatus('created'); @@ -1034,8 +1091,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { setOpenFiles(next); if (activeFile === path || activeFile.startsWith(prefix)) setActiveFile(next[0] ?? ''); } - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); } catch (err) { setSaveStatus(err instanceof Error ? err.message : 'delete failed'); } @@ -1058,8 +1115,8 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { })); if (activeFile === path) setActiveFile(newPath); else if (activeFile.startsWith(prefix)) setActiveFile(`${newPath}/${activeFile.slice(prefix.length)}`); - const result = await host.editor.tree(); - setTree(result.tree as FileNode[]); + await reloadTree(); + refreshIndex(); } catch (err) { setSaveStatus(err instanceof Error ? err.message : 'rename failed'); } @@ -1170,6 +1227,7 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { nodes={tree} activePath={activeFile} onSelect={openFile} + onExpand={loadChildren} metaFor={(path) => { const m = fileMeta(path); return m ? { status: m.status, diagnostics: m.diagnostics } : undefined; @@ -1487,8 +1545,27 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) { ); } -function flattenFiles(nodes: FileNode[]): string[] { - return nodes.flatMap((node) => node.kind === 'file' ? [node.path] : flattenFiles(node.children)); +/* binary/asset extensions to skip when auto-opening a default file — the index + (rg --files) is complete and includes them, but they'd render as garbage text. */ +const BINARY_ASSET_EXT = /\.(png|jpe?g|gif|webp|ico|bmp|svg|pdf|zip|gz|tar|woff2?|ttf|otf|eot|mp[34]|mov|webm|wasm|so|dylib|dll|exe|bin)$/i; + +/* prefer a readme, else the first editable (non-binary) file, else anything. */ +function pickDefaultFile(files: string[]): string { + const readme = files.find((path) => path.toLowerCase().endsWith('readme.md')); + if (readme) return readme; + return files.find((path) => !BINARY_ASSET_EXT.test(path)) ?? files[0] ?? ''; +} + +/* merge a lazily-resolved level into the tree at folderPath, marking the folder + loaded so it isn't re-fetched on a later expand. */ +function mergeChildren(nodes: FileNode[], folderPath: string, children: FileNode[]): FileNode[] { + const segments = folderPath.split('/').filter(Boolean); + const [head, ...rest] = segments; + return nodes.map((node) => { + if (node.kind !== 'folder' || node.name !== head) return node; + if (rest.length === 0) return { ...node, children, loaded: true }; + return { ...node, children: mergeChildren(node.children, rest.join('/'), children) }; + }); } function normalizeProjectLanguageConfig(value: unknown): ProjectLanguageConfig { diff --git a/plugins/shared/file-tree.tsx b/plugins/shared/file-tree.tsx index 6bffce3..e768996 100644 --- a/plugins/shared/file-tree.tsx +++ b/plugins/shared/file-tree.tsx @@ -21,6 +21,10 @@ export type FileTreeProps = { metaFor?: (path: string) => FileMeta | undefined; basePath?: string; onContextMenu?: (e: React.MouseEvent, info: FileTreeContextInfo) => void; + /* opt-in lazy loading: when set, a folder's children are requested via + onExpand(folderPath) on first expand instead of being walked up front. + Omit it for eager trees whose children are already fully resolved. */ + onExpand?: (folderPath: string) => void; }; export function FileTree({ @@ -34,6 +38,7 @@ export function FileTree({ metaFor, basePath = '', onContextMenu, + onExpand, }: FileTreeProps) { return (
@@ -51,6 +56,7 @@ export function FileTree({ metaFor={metaFor} basePath={basePath} onContextMenu={onContextMenu} + onExpand={onExpand} /> ) : ( ; depth: number; @@ -93,9 +100,20 @@ function FileTreeFolder({ metaFor?: (path: string) => FileMeta | undefined; basePath: string; onContextMenu?: (e: React.MouseEvent, info: FileTreeContextInfo) => void; + onExpand?: (folderPath: string) => void; }) { const [open, setOpen] = useState(defaultExpanded); const folderPath = basePath ? `${basePath}/${node.name}` : node.name; + // lazy mode reveals child count only once the level has been resolved + const showCount = !onExpand || node.loaded; + + const toggle = () => { + setOpen((v) => { + const next = !v; + if (next && onExpand && !node.loaded) onExpand(folderPath); + return next; + }); + }; return (
@@ -104,7 +122,7 @@ function FileTreeFolder({ className="file-tree__row file-tree__row--folder" style={{ paddingLeft: 6 + depth * 12 }} title={folderPath} - onClick={() => setOpen((v) => !v)} + onClick={toggle} onContextMenu={ onContextMenu ? (e) => { @@ -118,7 +136,7 @@ function FileTreeFolder({
diff --git a/plugins/shared/index.tsx b/plugins/shared/index.tsx index 8c588d1..e149c96 100644 --- a/plugins/shared/index.tsx +++ b/plugins/shared/index.tsx @@ -279,7 +279,7 @@ export function PanelHeader({ export type FileNode = | { kind: 'file'; name: string; path: string; subtitle?: string } - | { kind: 'folder'; name: string; children: FileNode[] }; + | { kind: 'folder'; name: string; children: FileNode[]; loaded?: boolean }; export { FileTree, type FileMeta, type FileTreeProps, type FileTreeContextInfo } from './file-tree'; export { diff --git a/src-tauri/src/fs_watch.rs b/src-tauri/src/fs_watch.rs index 9d76202..758a4d0 100644 --- a/src-tauri/src/fs_watch.rs +++ b/src-tauri/src/fs_watch.rs @@ -476,6 +476,45 @@ pub fn fs_list_tree() -> Result, String> { list_dir(&root, &root, 0, &mut count, &config) } +/// Lazy one-level listing for the file explorer. `path` is workspace-relative +/// (empty = root). Folders come back collapsed; the explorer requests their +/// children on expand via another call. +#[tauri::command] +pub fn fs_list_dir(path: String) -> Result, String> { + let root = resolve_workspace_root()?; + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone()); + let target = resolve_workspace_path(&path)?; + let config = load_file_tree_config(&root); + list_dir_shallow(&canonical_root, &target, &config) +} + +/// Complete, gitignore-aware list of workspace files for the quick-open index and +/// Monaco cross-file resolution. Decoupled from the explorer tree so it stays +/// complete even as the tree loads lazily. Backed by `rg --files`; falls back to an +/// empty list when ripgrep is unavailable (the host then scans open buffers). +fn list_workspace_files(root: &Path) -> Result, String> { + let output = match std::process::Command::new("rg") + .arg("--files") + .current_dir(root) + .output() + { + Ok(output) => output, + Err(_) => return Ok(Vec::new()), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .filter(|line| !line.is_empty()) + .map(|line| line.replace('\\', "/")) + .collect()) +} + +#[tauri::command] +pub fn fs_list_files() -> Result, String> { + let root = resolve_workspace_root()?; + list_workspace_files(&root) +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SearchMatch { @@ -799,6 +838,62 @@ pub fn knowledge_write( .map_err(|err| format!("failed to write knowledge doc {}: {err}", target.display())) } +/// One level of children for `dir`, relative to the workspace `root`. Folders are +/// returned with empty `children` (the frontend resolves them lazily on expand), so +/// there is no global file cap and no walk depth — an oversized subtree like `.venv` +/// is a single collapsed node that costs one `read_dir`, never starving its siblings. +fn list_dir_shallow( + root: &Path, + dir: &Path, + config: &FileTreeConfig, +) -> Result, String> { + let mut entries = std::fs::read_dir(dir) + .map_err(|err| format!("failed to list {}: {err}", dir.display()))? + .filter_map(|entry| entry.ok()) + .collect::>(); + + entries.sort_by_key(|entry| { + let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true); + (is_file, entry.file_name().to_string_lossy().to_lowercase()) + }); + + let mut nodes = Vec::new(); + for entry in entries { + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + if file_type.is_dir() { + if ignored_dir_path(root, &path, &name, config) { + continue; + } + nodes.push(FileTreeNode::Folder { + name, + children: Vec::new(), + }); + continue; + } + if !file_type.is_file() || !looks_textual_with_config(&path, config) { + continue; + } + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + nodes.push(FileTreeNode::File { + name, + path: relative, + }); + } + Ok(nodes) +} + fn list_dir( root: &Path, dir: &Path, @@ -873,7 +968,20 @@ fn always_ignored_dir_name(name: &str) -> bool { fn built_in_ignored_dir_name(name: &str) -> bool { matches!( name, - ".idea" | ".DS_Store" | "node_modules" | "target" | "dist" | "build" | ".vite" | ".tauri" + ".idea" + | ".DS_Store" + | "node_modules" + | "target" + | "dist" + | "build" + | ".vite" + | ".tauri" + | ".venv" + | "venv" + | "__pycache__" + | ".mypy_cache" + | ".pytest_cache" + | ".ruff_cache" ) } @@ -1577,4 +1685,100 @@ mod tests { &root.join("src-tauri/src/fs_watch.rs") )); } + + #[test] + fn list_workspace_files_enumerates_every_file_uncapped() { + if std::process::Command::new("rg") + .arg("--version") + .output() + .is_err() + { + return; // ripgrep is the index backend; nothing to assert without it + } + let root = temp_root("index-files"); + std::fs::create_dir_all(root.join("src/deep/nested")).expect("nested dirs"); + std::fs::write(root.join("README.md"), "x").expect("readme"); + std::fs::write(root.join("src/app.ts"), "x").expect("app"); + std::fs::write(root.join("src/deep/nested/leaf.rs"), "x").expect("leaf"); + + let files = list_workspace_files(&root).expect("index"); + + assert!(files.contains(&"README.md".to_string())); + assert!(files.contains(&"src/app.ts".to_string())); + // the deeply nested file proves there is no depth/count cap on the index + assert!( + files.contains(&"src/deep/nested/leaf.rs".to_string()), + "index dropped a deeply nested file: {files:?}", + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn list_dir_shallow_returns_one_level_folders_first_without_recursing() { + let root = temp_root("shallow-list"); + // a virtualenv with contents that must NOT appear at all + std::fs::create_dir_all(root.join(".venv/lib")).expect("venv"); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /x").expect("venv file"); + // real source folders, each with a child so we can prove non-recursion + std::fs::create_dir_all(root.join("alembic")).expect("alembic"); + std::fs::write(root.join("alembic/env.py"), "x").expect("alembic file"); + std::fs::create_dir_all(root.join("frontend")).expect("frontend"); + std::fs::write(root.join("frontend/app.tsx"), "x").expect("frontend file"); + // top-level files + std::fs::write(root.join("README.md"), "x").expect("readme"); + std::fs::write(root.join("zzz.txt"), "x").expect("zzz"); + + let config = FileTreeConfig::default(); + let nodes = list_dir_shallow(&root, &root, &config).expect("shallow list"); + + // folders first (alpha), then files (alpha); .venv excluded entirely + let summary: Vec = nodes + .iter() + .map(|node| match node { + FileTreeNode::Folder { name, children } => { + format!("dir:{name}:{}", children.len()) + } + FileTreeNode::File { name, .. } => format!("file:{name}"), + }) + .collect(); + assert_eq!( + summary, + vec![ + "dir:alembic:0".to_string(), + "dir:frontend:0".to_string(), + "file:README.md".to_string(), + "file:zzz.txt".to_string(), + ], + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn ignored_dir_path_excludes_python_virtualenv_and_caches() { + let root = Path::new("/workspace"); + let config = FileTreeConfig::default(); + + for dir in [ + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ] { + assert!( + ignored_dir_path(root, &root.join(dir), dir, &config), + "{dir} should be ignored by default", + ); + } + // a real source dir with a similar name must stay visible + assert!(!ignored_dir_path( + root, + &root.join("environments"), + "environments", + &config, + )); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5b364ef..e42bc2e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -69,6 +69,8 @@ fn main() { external::open_external_url, fs_watch::fs_watch_status, fs_watch::fs_list_tree, + fs_watch::fs_list_dir, + fs_watch::fs_list_files, fs_watch::fs_search, fs_watch::fs_read_text, fs_watch::fs_write_text, diff --git a/src/App.tsx b/src/App.tsx index 76ea27e..9076501 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -646,6 +646,16 @@ if (hasTauriInvoke()) { if (!tree) throw new Error('filesystem bridge unavailable'); return tree; }, + listDir: async (path) => { + const tree = tauriInvoke('fs_list_dir', { path }); + if (!tree) throw new Error('filesystem bridge unavailable'); + return tree; + }, + listFiles: async () => { + const files = tauriInvoke('fs_list_files'); + if (!files) throw new Error('filesystem bridge unavailable'); + return files; + }, readText: async (path) => { const text = tauriInvoke('fs_read_text', { path }); if (!text) throw new Error('filesystem bridge unavailable'); diff --git a/src/spine/hostRpcServer.test.ts b/src/spine/hostRpcServer.test.ts index 060c307..6255999 100644 --- a/src/spine/hostRpcServer.test.ts +++ b/src/spine/hostRpcServer.test.ts @@ -275,6 +275,52 @@ describe('HostRpcServer real-data adapters', () => { } }); + test('editor.listDir delegates one directory level to the filesystem adapter', async () => { + const server = new HostRpcServer({}); + server.setFileSystemAdapter({ + listDir: async (path) => [ + { kind: 'folder', name: 'nested', children: [] }, + { kind: 'file', name: 'app.ts', path: `${path}/app.ts` }, + ], + }); + + const response = await server.handle({ + kind: 'request', + id: 1, + method: 'editor.listDir', + params: { path: 'src' }, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.result).toEqual({ + tree: [ + { kind: 'folder', name: 'nested', children: [] }, + { kind: 'file', name: 'app.ts', path: 'src/app.ts' }, + ], + }); + } + }); + + test('editor.listFiles delegates the workspace index to the filesystem adapter', async () => { + const server = new HostRpcServer({}); + server.setFileSystemAdapter({ + listFiles: async () => ['README.md', 'src/deep/nested/leaf.rs'], + }); + + const response = await server.handle({ + kind: 'request', + id: 1, + method: 'editor.listFiles', + params: {}, + }); + + expect(response.ok).toBe(true); + if (response.ok) { + expect(response.result).toEqual({ files: ['README.md', 'src/deep/nested/leaf.rs'] }); + } + }); + test('ui.openExternal delegates to the configured external opener', async () => { const opened: string[] = []; const server = new HostRpcServer();