From 38e7213915a4765a086aeeb12de7502e609bb89c Mon Sep 17 00:00:00 2001 From: azg Date: Tue, 25 Aug 2026 01:34:37 +0200 Subject: [PATCH 1/2] feat: lazily load the file tree per directory instead of scanning it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listTree fetched the entire workspace recursively (host.list_paths, capped at TREE_LIMIT=10,000) on every mount, and again every 10s on the background poll. Expand/collapse only filtered that in-memory flat list — collapsing a folder never freed anything. On a large workspace (tens of thousands of files) this was slow, wasteful, and after a host-daemon bugfix landed, still routinely hit the plugin's 10k display cap on every load. Add a new listDirectory RPC (single-level, via bb.sdk.hosts.directory / host.browse_directory) alongside the existing listTree. useFilesWorkspace now keeps loaded children keyed by directory path: - Mount fetches only the root directory. - Expanding a folder fetches its children with one shallow call. - Collapsing a folder drops its subtree from state so it's re-fetched fresh next time, instead of just being hidden. - Opening a path (e.g. after creating a file) expands every ancestor folder to reveal it, fetching each lazily. - The 10s background refresh and post-mutation refresh now re-fetch only the root plus whatever's currently expanded, not the whole tree. listTree is unchanged and still does a full recursive host-side search when the search box has a query — full-depth search is still exactly what search should do. Updated app.test.tsx's initial-mount mocks from listTree to listDirectory (they were exercising the eager root load, now served by the new endpoint), and added a test covering expand fetching children and collapse dropping them from state. Co-Authored-By: Claude Sonnet 5 --- app.test.tsx | 84 +++++++++++---- src/components/FilesPanel.tsx | 2 + src/components/TreePane.tsx | 47 ++------ src/contracts.ts | 18 ++++ src/file-service.ts | 192 ++++++++++++++++++++++----------- src/hooks/useFilesWorkspace.ts | 128 +++++++++++++++++++--- 6 files changed, 338 insertions(+), 133 deletions(-) diff --git a/app.test.tsx b/app.test.tsx index 1f313b8..f941a0b 100644 --- a/app.test.tsx +++ b/app.test.tsx @@ -63,7 +63,8 @@ describe("Files plugin app", () => { it("uses BB Markdown for Preview and exposes Raw", async () => { setRpcHandlers({ - listTree: () => ({ + listDirectory: () => ({ + path: "", rootName: "repo", entries: [ { @@ -74,7 +75,7 @@ describe("Files plugin app", () => { positions: [], }, ], - truncated: false, + annotateAvailable: false, }), readFile: () => ({ state: "text", @@ -101,7 +102,8 @@ describe("Files plugin app", () => { const openFile = vi.fn(() => ({ delivered: 1 })); setRpcHandlers({ openFile, - listTree: () => ({ + listDirectory: () => ({ + path: "", rootName: "repo", entries: [ { @@ -112,7 +114,6 @@ describe("Files plugin app", () => { positions: [], }, ], - truncated: false, annotateAvailable: true, }), readFile: () => ({ @@ -146,7 +147,7 @@ describe("Files plugin app", () => { ); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => { const path = typeof input === "object" && @@ -195,7 +196,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }), }); setBbContext({ projectId: "project-a", threadId: "thread-1" }); @@ -213,20 +214,20 @@ describe("Files plugin app", () => { { kind: "workspace" as const, threadId: "thread-1", environmentId: "foreign-environment", projectId: null }, { kind: "workspace" as const, threadId: "thread-1", environmentId: null, projectId: "foreign-project" }, ])("does not authorize file-opener sources without a host context", async (source) => { - const listTree = vi.fn(); + const listDirectory = vi.fn(); const readFile = vi.fn(); setBbContext({ projectId: null, threadId: null }); - setRpcHandlers({ listTree, readFile }); + setRpcHandlers({ listDirectory, readFile }); render(); await new Promise((resolve) => window.setTimeout(resolve, 250)); - expect(listTree).not.toHaveBeenCalled(); + expect(listDirectory).not.toHaveBeenCalled(); expect(readFile).not.toHaveBeenCalled(); }); it("fails closed for unauthorized callback invocations", async () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); - const handlers = { openFile: vi.fn(), saveFile: vi.fn(), createFile: vi.fn(), createDirectory: vi.fn(), movePath: vi.fn(), removePath: vi.fn(), readFile: vi.fn(), listTree: vi.fn() }; + const handlers = { openFile: vi.fn(), saveFile: vi.fn(), createFile: vi.fn(), createDirectory: vi.fn(), movePath: vi.fn(), removePath: vi.fn(), readFile: vi.fn(), listDirectory: vi.fn() }; setRpcHandlers(handlers); setBbContext({ projectId: null, threadId: null }); const hook = renderHook(() => useFilesWorkspace()); @@ -258,7 +259,7 @@ describe("Files plugin app", () => { it("focuses an existing tab for the same source and path", async () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); - setRpcHandlers({ listTree: () => ({ rootName: "repo", entries: [], truncated: false }), readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }) }); + setRpcHandlers({ listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }) }); const hook = renderHook(() => useFilesWorkspace()); await act(async () => { await hook.result.current.openPath("README.md"); await hook.result.current.openPath("README.md"); }); expect(hook.result.current.tabs).toHaveLength(1); @@ -275,7 +276,7 @@ describe("Files plugin app", () => { it("resets panel state when the trusted host source changes", async () => { setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [{ kind: "file", path: "README.md", name: "README.md", score: 0, positions: [] }], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [{ kind: "file", path: "README.md", name: "README.md", score: 0, positions: [] }], annotateAvailable: false }), readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }), }); const view = render(); @@ -290,7 +291,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => { const path = (input as { path: string }).path; return { state: "text", path, sha256: path, sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: path }; @@ -312,7 +313,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }), }); const hook = renderHook(() => useFilesWorkspace()); @@ -331,7 +332,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }), saveFile: () => ({ outcome: "conflict", currentSha256: "new-sha" }), }); @@ -351,7 +352,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }), }); const hook = renderHook(() => useFilesWorkspace()); @@ -372,7 +373,7 @@ describe("Files plugin app", () => { const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); const { renderHook } = await import("@testing-library/react"); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: (input: unknown) => { const path = (input as { path: string }).path; return { state: "text", path, sha256: path, sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: path }; @@ -394,7 +395,7 @@ describe("Files plugin app", () => { "./src/hooks/useFilesWorkspace" ); setRpcHandlers({ - listTree: () => ({ rootName: "repo", entries: [], truncated: false }), + listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: () => ({ state: "text", path: "README.md", @@ -426,4 +427,51 @@ describe("Files plugin app", () => { expect(hook.result.current.tabs.find(t => t.path === "README.md")?.draftText).toBe("my draft"); expect(hook.result.current.activePath).toBe("README.md"); }); + + it("lazily loads a directory's children on expand and drops them on collapse", async () => { + const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace"); + const { renderHook } = await import("@testing-library/react"); + const listDirectory = vi.fn((input: unknown) => { + const path = (input as { path: string }).path; + if (path === "") { + return { + path: "", + rootName: "repo", + annotateAvailable: false, + entries: [{ kind: "directory", path: "src", name: "src", score: 0, positions: [] }], + }; + } + if (path === "src") { + return { + path: "src", + entries: [{ kind: "file", path: "src/a.ts", name: "a.ts", score: 0, positions: [] }], + }; + } + throw new Error(`unexpected listDirectory path: ${path}`); + }); + setRpcHandlers({ listDirectory }); + const hook = renderHook(() => useFilesWorkspace()); + + await waitFor(() => { + expect(hook.result.current.entries.map((entry) => entry.path)).toEqual(["src"]); + }); + expect(hook.result.current.expandedDirs.has("src")).toBe(false); + + await act(async () => { + hook.result.current.toggleDirectory("src"); + }); + await waitFor(() => { + expect(hook.result.current.entries.map((entry) => entry.path)).toEqual( + expect.arrayContaining(["src", "src/a.ts"]), + ); + }); + expect(hook.result.current.expandedDirs.has("src")).toBe(true); + expect(listDirectory).toHaveBeenCalledWith(expect.objectContaining({ path: "src" })); + + // Collapsing drops the fetched children from state instead of merely + // hiding them, so re-expanding fetches fresh data. + act(() => hook.result.current.toggleDirectory("src")); + expect(hook.result.current.expandedDirs.has("src")).toBe(false); + expect(hook.result.current.entries.map((entry) => entry.path)).toEqual(["src"]); + }); }); diff --git a/src/components/FilesPanel.tsx b/src/components/FilesPanel.tsx index aa2a28a..a970ef9 100644 --- a/src/components/FilesPanel.tsx +++ b/src/components/FilesPanel.tsx @@ -170,11 +170,13 @@ function FilesPanelContent({ initialPath }: { initialPath: string | null }) { requestCreate(kind)} onOpen={(path) => void workspace.openPath(path)} onRefresh={() => void workspace.refreshTree()} + onToggleDirectory={workspace.toggleDirectory} query={workspace.query} rootName={workspace.rootName} selectedPath={workspace.activePath} diff --git a/src/components/TreePane.tsx b/src/components/TreePane.tsx index 3341640..0b4e8f5 100644 --- a/src/components/TreePane.tsx +++ b/src/components/TreePane.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, - useState, type PointerEvent as ReactPointerEvent, } from "react"; import { Button } from "@/components/ui/button"; @@ -16,7 +15,6 @@ import type { FileTreeEntry } from "../hooks/useFilesWorkspace"; import { filterVisibleEntries, orderTreeEntries, - parentPath, searchSortEntries, } from "../tree-order"; @@ -158,11 +156,13 @@ function TreeRow({ export function TreePane({ entries, error, + expandedDirs, loading, onAction, onCreateRoot, onOpen, onRefresh, + onToggleDirectory, query, rootName, selectedPath, @@ -172,11 +172,13 @@ export function TreePane({ }: { entries: FileTreeEntry[]; error: string | null; + expandedDirs: ReadonlySet; loading: boolean; onAction(action: FileAction, entry: FileTreeEntry): void; onCreateRoot(kind: "file" | "directory"): void; onOpen(path: string): void; onRefresh(): void; + onToggleDirectory(path: string): void; query: string; rootName: string; selectedPath: string | null; @@ -184,36 +186,10 @@ export function TreePane({ showAnnotate: boolean; truncated: boolean; }) { - const [expanded, setExpanded] = useState>(new Set()); - - // Reveal a newly selected file by expanding every folder above it, so a - // file created or opened inside a collapsed folder appears in the tree - // instead of only in the editor. - useEffect(() => { - if (selectedPath === null) return; - const ancestors: string[] = []; - let parent = parentPath(selectedPath); - while (parent.length > 0) { - ancestors.push(parent); - parent = parentPath(parent); - } - if (ancestors.length === 0) return; - setExpanded((current) => { - let next = current; - for (const ancestor of ancestors) { - if (!next.has(ancestor)) { - if (next === current) next = new Set(current); - next.add(ancestor); - } - } - return next; - }); - }, [selectedPath]); - const visibleEntries = useMemo(() => { if (query.length > 0) return searchSortEntries(entries); - return filterVisibleEntries(orderTreeEntries(entries), expanded); - }, [entries, expanded, query]); + return filterVisibleEntries(orderTreeEntries(entries), expandedDirs); + }, [entries, expandedDirs, query]); return (