diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index c7456f141c3b4..095a80a1726f6 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -1,4 +1,5 @@ import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; +import { FileTree } from "@pierre/trees"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -16,7 +17,7 @@ const entries: readonly ProjectEntry[] = [ const loadingDirectories: ReadonlySet = new Set(); const directory: { entries: readonly ProjectEntry[]; - load: () => Promise; + load: (path: string) => Promise; refresh: () => void; ready: boolean; error: null; @@ -24,7 +25,7 @@ const directory: { loadingDirectories: ReadonlySet; } = { entries, - load: () => Promise.resolve(), + load: (_path) => Promise.resolve(), refresh: () => {}, ready: true, error: null, @@ -54,15 +55,24 @@ function findTreeRow(path: string): HTMLElement | null { return null; } -function withDirectoryListing(listing: readonly ProjectEntry[]): Disposable { +function withDirectoryListing( + listing: readonly ProjectEntry[], + load: typeof directory.load = directory.load, +): Disposable { + const previousLoad = directory.load; directory.entries = listing; + directory.load = load; return { [Symbol.dispose]: () => { directory.entries = entries; + directory.load = previousLoad; }, }; } +const folders = (count: number): ProjectEntry[] => + Array.from({ length: count }, (_, index) => ({ path: `folder-${index}`, kind: "directory" })); + describe("FileBrowserPanel", () => { it("copies with the pointer and returns to the row after keyboard dismissal", async () => { await using _screen = await render( @@ -237,6 +247,64 @@ describe("FileBrowserPanel", () => { await expect.element(search).toHaveValue("generated-"); }); + it("expands every folder without asking the tree about all folders for each one", async () => { + const load = vi.fn((_path: string) => Promise.resolve()); + using _listing = withDirectoryListing(folders(300), load); + await using _screen = await render( + {}} + workspaceMutationId={null} + />, + ); + using getItem = vi.spyOn(FileTree.prototype, "getItem"); + const expandAll = page.getByRole("button", { name: "Expand all folders" }); + await expect.element(expandAll).toBeVisible(); + getItem.mockClear(); + await userEvent.click(expandAll); + await expect.element(page.getByRole("button", { name: "Collapse all folders" })).toBeVisible(); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(300)); + // Each of the 300 expansions used to look up all 300 folders again, about 137,000 lookups. + expect(getItem.mock.calls.length).toBeLessThan(300 * 20); + }); + + it("expands folders that arrive after Expand all without visiting the earlier ones again", async () => { + const batches = 5; + const perBatch = 60; + const load = vi.fn((_path: string) => Promise.resolve()); + using _listing = withDirectoryListing(folders(perBatch), load); + const renderPanel = () => ( + {}} + workspaceMutationId={null} + /> + ); + await using screen = await render(renderPanel()); + using getItem = vi.spyOn(FileTree.prototype, "getItem"); + await userEvent.click(page.getByRole("button", { name: "Expand all folders" })); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(perBatch)); + getItem.mockClear(); + for (let batch = 2; batch <= batches; batch++) { + directory.entries = folders(batch * perBatch); + await screen.rerender(renderPanel()); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(batch * perBatch)); + } + await expect.element(page.getByRole("button", { name: "Collapse all folders" })).toBeVisible(); + expect(new Set(load.mock.calls.map(([path]) => path)).size).toBe(batches * perBatch); + // About 1,100 lookups. Rescanning every folder for the button state adds about 5,000, and a + // second scan of every folder per listing about 800. + expect(getItem.mock.calls.length).toBeLessThan(1_400); + }); + it("mod+f from a tree row, or the panel find command, focuses the file search", async () => { await using _screen = await render( 0 && search.matchingPaths.length === 0; - const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => - areAllDirectoriesExpanded(currentModel, expandableDirectoryPaths), + const allDirectoriesExpanded = useSyncExternalStore( + (listener) => subscribeOncePerTask(model, listener), + // The expand-all effect keeps every folder open, and collapsing one turns Expand all off. + () => + (expandAll && !query.trim()) || areAllDirectoriesExpanded(model, expandableDirectoryPaths), ); const toggleAllDirectories = () => { const expanded = !(expandAll || allDirectoriesExpanded); @@ -345,20 +348,26 @@ export default function FileBrowserPanel({ return subscribePreviewAction(onPanelAction); }, [answersPanelFind]); const expandedPathsRef = useRef(new Set()); + const expandedByExpandAllRef = useRef(new Set()); const expansionRoot = `${environmentId}:${cwd}`; // Folders expanded when the panel last unmounted; each reopens once lazy loading lists it. const [pendingExpandedPaths] = useState(() => new Set(readExpandedDirectories(expansionRoot))); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded + const directoryPathsRef = useRef(directoryPaths); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- forgets expanded folders that left the listing before the Pierre tree model notifies about them useEffect(() => { + directoryPathsRef.current = directoryPaths; const currentPaths = new Set(directoryPaths); for (const path of expandedPathsRef.current) { if (!currentPaths.has(path)) expandedPathsRef.current.delete(path); } - // Restoring calls item.expand(), which notifies subscribers synchronously. - const loadExpanded = coalesceReentrantCalls(() => { + }, [directoryPaths]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded + useEffect(() => { + // Not rerun per listing: adding its paths to the model notifies this listener once. + const loadExpanded = () => { if (model.isSearchOpen()) return; let changed = false; - for (const path of directoryPaths) { + for (const path of directoryPathsRef.current) { const item = model.getItem(path); if (item?.isDirectory() && pendingExpandedPaths.delete(path) && "expand" in item) { item.expand(); @@ -380,10 +389,10 @@ export default function FileBrowserPanel({ ...pendingExpandedPaths, ]); } - }); + }; loadExpanded(); - return model.subscribe(loadExpanded); - }, [directoryPaths, expansionRoot, load, model, pendingExpandedPaths]); + return subscribeOncePerTask(model, loadExpanded); + }, [expansionRoot, load, model, pendingExpandedPaths]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model useEffect(() => { model.setGitStatus( @@ -451,8 +460,18 @@ export default function FileBrowserPanel({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => { - if (expandAll && !query.trim()) - setAllDirectoriesExpanded(model, expandableDirectoryPaths, true); + if (!expandAll || query.trim()) { + expandedByExpandAllRef.current.clear(); + return; + } + // Only folders listed since the last pass; asking the tree about every folder each time is quadratic. + const previous = expandedByExpandAllRef.current; + setAllDirectoriesExpanded( + model, + expandableDirectoryPaths.filter((path) => !previous.has(path)), + true, + ); + expandedByExpandAllRef.current = new Set(expandableDirectoryPaths); }, [expandableDirectoryPaths, expandAll, model, query]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals an externally selected path in the Pierre tree model by expanding, selecting and scrolling diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts index 35e6a548603ee..2e0ca5b0631e0 100644 --- a/apps/web/src/components/files/fileTreeExpansion.ts +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -35,6 +35,7 @@ function asDirectoryHandle(item: unknown): DirectoryHandle | null { /** * Calls `listener` once per task, however many events the model emitted in it. Expanding every * folder emits one event per folder, and a listener that rescans the folders is quadratic otherwise. + * Deferring also keeps a listener that expands items from rerunning inside itself. */ export function subscribeOncePerTask( model: { subscribe(listener: () => void): () => void }, @@ -82,27 +83,6 @@ export function setAllDirectoriesExpanded( } } -/** Calls made while `run` is running rerun it once it returns, instead of nesting. */ -export function coalesceReentrantCalls(run: () => void): () => void { - let running = false; - let calledAgain = false; - return () => { - if (running) { - calledAgain = true; - return; - } - running = true; - try { - do { - calledAgain = false; - run(); - } while (calledAgain); - } finally { - running = false; - } - }; -} - const EXPANDED_DIRECTORIES_STORAGE_KEY = "t3code.fileTreeExpandedDirectories"; // Roots are kept most recent first and capped so worktrees that come and go do not grow storage. const MAX_STORED_ROOTS = 20;