From 2d31b1df7d62581625ba6522941a7ac7b40dafa7 Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:06:31 +0800 Subject: [PATCH 1/9] fix(web): opening a very large folder no longer freezes the Files panel Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 46 ++++++++++++++++++- .../src/components/files/FileBrowserPanel.tsx | 9 +++- .../files/fileTreePathReconciliation.ts | 3 ++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914be..bb0f479e23ba 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -12,7 +12,15 @@ const entries: readonly ProjectEntry[] = [ { path: "README.md", kind: "file" }, ]; const loadingDirectories: ReadonlySet = new Set(); -const directory = { +const directory: { + entries: readonly ProjectEntry[]; + load: () => Promise; + refresh: () => void; + ready: boolean; + error: null; + isPending: boolean; + loadingDirectories: ReadonlySet; +} = { entries, load: () => Promise.resolve(), refresh: () => {}, @@ -115,4 +123,40 @@ describe("FileBrowserPanel", () => { row.click(); await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); + + it("applies a very large folder listing quickly and keeps the expanded folder open", async () => { + const renderPanel = () => ( + {}} + workspaceMutationId={null} + /> + ); + await using screen = await render(renderPanel()); + await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); + findTreeRow("src/")!.click(); + await vi.waitFor(() => expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true")); + + directory.entries = [ + ...entries, + // Out of sort order, because inserting in order hides the cost of each insert. + ...Array.from({ length: 40_000 }, (_, index): ProjectEntry => { + const name = String((index * 7_919) % 40_000).padStart(5, "0"); + return { path: `src/generated-${name}`, kind: "directory" }; + }), + ]; + using _restoreEntries = { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; + await screen.rerender(renderPanel()); + + await vi.waitFor(() => expect(findTreeRow("src/generated-00000/")).not.toBeNull()); + expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true"); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index b02baf31b0ac..84ec1acec052 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -30,7 +30,10 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { joinHostPath } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; -import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; +import { + buildFileTreePathUpdates, + MAX_INCREMENTAL_FILE_TREE_UPDATES, +} from "./fileTreePathReconciliation"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; @@ -400,6 +403,10 @@ export default function FileBrowserPanel({ return; } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); + if (updates.length > MAX_INCREMENTAL_FILE_TREE_UPDATES) { + model.resetPaths(treePaths, { initialExpandedPaths: [...expandedPathsRef.current] }); + return; + } if (updates.length > 0) model.batch(updates); }, [ready, entryKinds, model, treePaths]); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts index 5bcccb2cd2a1..ad22c3ad75a5 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -1,5 +1,8 @@ import type { FileTreeBatchOperation } from "@pierre/trees"; +// Each batched insert reworks every sibling, so a big listing costs far more than a rebuild. +export const MAX_INCREMENTAL_FILE_TREE_UPDATES = 1_000; + function pathDepth(path: string): number { return path.split("/").filter(Boolean).length; } From 6a6f894faa894e458aae6b82e16ee65f4cfd180f Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:14:59 +0800 Subject: [PATCH 2/9] fix(web): an open file search stays accurate across a large listing Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 41 +++++++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 4 ++ 2 files changed, 45 insertions(+) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index bb0f479e23ba..187e1fb5feba 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -159,4 +159,45 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect(findTreeRow("src/generated-00000/")).not.toBeNull()); expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true"); }); + + it("keeps an open search accurate when a very large folder listing arrives", async () => { + const renderPanel = () => ( + {}} + workspaceMutationId={null} + /> + ); + directory.entries = [ + ...entries, + { path: "lib", kind: "directory" }, + { path: "lib/generated-existing.ts", kind: "file" }, + ]; + using _restoreEntries = { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; + await using screen = await render(renderPanel()); + const search = page.getByRole("searchbox", { name: "Search repo files" }); + await userEvent.fill(search, "generated-"); + await vi.waitFor(() => expect(findTreeRow("lib/generated-existing.ts")).not.toBeNull()); + + directory.entries = [ + ...directory.entries, + ...Array.from({ length: 2_000 }, (_, index): ProjectEntry => { + const name = String((index * 7_919) % 2_000).padStart(4, "0"); + return { path: `src/generated-${name}.ts`, kind: "file" }; + }), + ]; + await screen.rerender(renderPanel()); + + await vi.waitFor(() => expect(findTreeRow("src/generated-0000.ts")).not.toBeNull()); + expect(findTreeRow("lib/generated-existing.ts")).not.toBeNull(); + await expect.element(search).toHaveValue("generated-"); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 84ec1acec052..48fa8770e079 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -404,7 +404,11 @@ export default function FileBrowserPanel({ } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > MAX_INCREMENTAL_FILE_TREE_UPDATES) { + // A reset keeps old search matches, so search closes first and recomputes afterwards. + const activeSearch = model.isSearchOpen() ? model.getSearchValue() : null; + if (activeSearch !== null) model.closeSearch(); model.resetPaths(treePaths, { initialExpandedPaths: [...expandedPathsRef.current] }); + if (activeSearch !== null) model.setSearch(activeSearch); return; } if (updates.length > 0) model.batch(updates); From e66bc40984b7f757fa9b640da0133487d78e0791 Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:01:40 +0800 Subject: [PATCH 3/9] test(web): the selected file survives a large listing in Files Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 187e1fb5feba..34f6efb9e636 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -160,6 +160,45 @@ describe("FileBrowserPanel", () => { expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true"); }); + it("keeps the selected file through a very large listing without reopening it", async () => { + const onOpenFile = vi.fn(); + const renderPanel = () => ( + + ); + directory.entries = [...entries, { path: "src/aaa.ts", kind: "file" }]; + using _restoreEntries = { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; + await using screen = await render(renderPanel()); + await vi.waitFor(() => + expect(findTreeRow("src/aaa.ts")?.getAttribute("aria-selected")).toBe("true"), + ); + + directory.entries = [ + ...directory.entries, + ...Array.from({ length: 2_000 }, (_, index): ProjectEntry => { + const name = String((index * 7_919) % 2_000).padStart(4, "0"); + return { path: `src/generated-${name}.ts`, kind: "file" }; + }), + ]; + await screen.rerender(renderPanel()); + + await vi.waitFor(() => expect(findTreeRow("src/generated-0000.ts")).not.toBeNull()); + expect(findTreeRow("src/aaa.ts")?.getAttribute("aria-selected")).toBe("true"); + expect(findTreeRow("src/")?.getAttribute("aria-expanded")).toBe("true"); + expect(onOpenFile).not.toHaveBeenCalled(); + }); + it("keeps an open search accurate when a very large folder listing arrives", async () => { const renderPanel = () => ( Date: Tue, 3 Nov 2026 15:30:22 +0800 Subject: [PATCH 4/9] fix(web): removing a very large number of folders no longer stalls Files Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 61 +++++++++++++------ .../files/fileTreePathReconciliation.test.ts | 19 ++++++ .../files/fileTreePathReconciliation.ts | 13 +++- 3 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/files/fileTreePathReconciliation.test.ts diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 34f6efb9e636..07543f6d7976 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -52,6 +52,15 @@ function findTreeRow(path: string): HTMLElement | null { return null; } +function withDirectoryListing(listing: readonly ProjectEntry[]): Disposable { + directory.entries = listing; + return { + [Symbol.dispose]: () => { + directory.entries = entries; + }, + }; +} + describe("FileBrowserPanel", () => { it("copies with the pointer and returns to the row after keyboard dismissal", async () => { await using _screen = await render( @@ -125,6 +134,7 @@ describe("FileBrowserPanel", () => { }); it("applies a very large folder listing quickly and keeps the expanded folder open", async () => { + using _listing = withDirectoryListing(entries); const renderPanel = () => ( { return { path: `src/generated-${name}`, kind: "directory" }; }), ]; - using _restoreEntries = { - [Symbol.dispose]: () => { - directory.entries = entries; - }, - }; await screen.rerender(renderPanel()); await vi.waitFor(() => expect(findTreeRow("src/generated-00000/")).not.toBeNull()); expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true"); }); + it("applies the removal of a very large number of sibling folders quickly", async () => { + const renderPanel = () => ( + {}} + workspaceMutationId={null} + /> + ); + using _listing = withDirectoryListing([ + ...entries, + ...Array.from({ length: 100_000 }, (_, index): ProjectEntry => { + return { path: `src/generated-${String(index).padStart(6, "0")}`, kind: "directory" }; + }), + ]); + await using screen = await render(renderPanel()); + await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); + findTreeRow("src/")!.click(); + await vi.waitFor(() => expect(findTreeRow("src/generated-000000/")).not.toBeNull()); + + directory.entries = entries; + await screen.rerender(renderPanel()); + + await vi.waitFor(() => expect(findTreeRow("src/generated-000000/")).toBeNull()); + expect(findTreeRow("README.md")).not.toBeNull(); + }); + it("keeps the selected file through a very large listing without reopening it", async () => { const onOpenFile = vi.fn(); const renderPanel = () => ( @@ -173,12 +208,7 @@ describe("FileBrowserPanel", () => { workspaceMutationId={null} /> ); - directory.entries = [...entries, { path: "src/aaa.ts", kind: "file" }]; - using _restoreEntries = { - [Symbol.dispose]: () => { - directory.entries = entries; - }, - }; + using _listing = withDirectoryListing([...entries, { path: "src/aaa.ts", kind: "file" }]); await using screen = await render(renderPanel()); await vi.waitFor(() => expect(findTreeRow("src/aaa.ts")?.getAttribute("aria-selected")).toBe("true"), @@ -211,16 +241,11 @@ describe("FileBrowserPanel", () => { workspaceMutationId={null} /> ); - directory.entries = [ + using _listing = withDirectoryListing([ ...entries, { path: "lib", kind: "directory" }, { path: "lib/generated-existing.ts", kind: "file" }, - ]; - using _restoreEntries = { - [Symbol.dispose]: () => { - directory.entries = entries; - }, - }; + ]); await using screen = await render(renderPanel()); const search = page.getByRole("searchbox", { name: "Search repo files" }); await userEvent.fill(search, "generated-"); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.test.ts b/apps/web/src/components/files/fileTreePathReconciliation.test.ts new file mode 100644 index 000000000000..d622a7e8ec3b --- /dev/null +++ b/apps/web/src/components/files/fileTreePathReconciliation.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; + +describe("buildFileTreePathUpdates", () => { + it("removes a folder once and skips the paths inside it", () => { + expect( + buildFileTreePathUpdates(["src/", "src/a/", "src/a/b/", "src/a/c.ts"], ["src/"]), + ).toEqual([{ type: "remove", path: "src/a/", recursive: true }]); + }); + + it("does not treat a sibling that shares a name prefix as inside a removed folder", () => { + expect(buildFileTreePathUpdates(["src/a/", "src/ab/", "src/a.ts"], [])).toEqual([ + { type: "remove", path: "src/a/", recursive: true }, + { type: "remove", path: "src/ab/", recursive: true }, + { type: "remove", path: "src/a.ts" }, + ]); + }); +}); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts index ad22c3ad75a5..7aabb6cdcb34 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -7,23 +7,30 @@ function pathDepth(path: string): number { return path.split("/").filter(Boolean).length; } +function isInsideRemovedDirectory(path: string, removedRoots: ReadonlySet): boolean { + for (let slash = path.indexOf("/"); slash !== -1; slash = path.indexOf("/", slash + 1)) { + if (removedRoots.has(path.slice(0, slash + 1))) return true; + } + return false; +} + export function buildFileTreePathUpdates( previousPaths: readonly string[], nextPaths: readonly string[], ): FileTreeBatchOperation[] { const previous = new Set(previousPaths); const next = new Set(nextPaths); - const removedDirectoryRoots: string[] = []; + const removedDirectoryRoots = new Set(); const updates: FileTreeBatchOperation[] = []; const removedPaths = previousPaths .filter((path) => !next.has(path)) .toSorted((left, right) => pathDepth(left) - pathDepth(right)); for (const path of removedPaths) { - if (removedDirectoryRoots.some((directory) => path.startsWith(directory))) continue; + if (isInsideRemovedDirectory(path, removedDirectoryRoots)) continue; const recursive = path.endsWith("/"); updates.push({ type: "remove", path, ...(recursive ? { recursive: true } : {}) }); - if (recursive) removedDirectoryRoots.push(path); + if (recursive) removedDirectoryRoots.add(path); } const addedPaths = nextPaths From c9c3edbfb1f8a816055c0b3185886c2ac83e3f2e Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:41:53 +0800 Subject: [PATCH 5/9] fix(web): rebuild the Files tree once a rebuild is cheaper than a batch Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBrowserPanel.browser.test.tsx | 2 +- apps/web/src/components/files/fileTreePathReconciliation.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 07543f6d7976..f7a4eed9ca1b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -179,7 +179,7 @@ describe("FileBrowserPanel", () => { ); using _listing = withDirectoryListing([ ...entries, - ...Array.from({ length: 100_000 }, (_, index): ProjectEntry => { + ...Array.from({ length: 30_000 }, (_, index): ProjectEntry => { return { path: `src/generated-${String(index).padStart(6, "0")}`, kind: "directory" }; }), ]); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts index 7aabb6cdcb34..f636c02d968a 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -1,7 +1,8 @@ import type { FileTreeBatchOperation } from "@pierre/trees"; -// Each batched insert reworks every sibling, so a big listing costs far more than a rebuild. -export const MAX_INCREMENTAL_FILE_TREE_UPDATES = 1_000; +// Each batched operation reworks every sibling. Both costs grow with the listing, and a rebuild +// wins at about 300 operations whether the folder holds 50,000 entries or 332,000. +export const MAX_INCREMENTAL_FILE_TREE_UPDATES = 300; function pathDepth(path: string): number { return path.split("/").filter(Boolean).length; From 25f509dcf65e05da8ed8fe5d8806489b27cf35ab Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:53:04 +0800 Subject: [PATCH 6/9] fix(web): rebuild the Files tree only when a batch would cost more Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBrowserPanel.tsx | 7 +--- .../files/fileTreePathReconciliation.test.ts | 40 ++++++++++++++++++- .../files/fileTreePathReconciliation.ts | 39 ++++++++++++++++-- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 48fa8770e079..3382fd37a72e 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -30,10 +30,7 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { joinHostPath } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; -import { - buildFileTreePathUpdates, - MAX_INCREMENTAL_FILE_TREE_UPDATES, -} from "./fileTreePathReconciliation"; +import { buildFileTreePathUpdates, shouldRebuildFileTree } from "./fileTreePathReconciliation"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; @@ -403,7 +400,7 @@ export default function FileBrowserPanel({ return; } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); - if (updates.length > MAX_INCREMENTAL_FILE_TREE_UPDATES) { + if (shouldRebuildFileTree(previousTreePaths, treePaths, updates)) { // A reset keeps old search matches, so search closes first and recomputes afterwards. const activeSearch = model.isSearchOpen() ? model.getSearchValue() : null; if (activeSearch !== null) model.closeSearch(); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.test.ts b/apps/web/src/components/files/fileTreePathReconciliation.test.ts index d622a7e8ec3b..10c389bfe3c6 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.test.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; +import { buildFileTreePathUpdates, shouldRebuildFileTree } from "./fileTreePathReconciliation"; + +const folder = (name: string, count: number) => + Array.from({ length: count }, (_, index) => `${name}/entry-${index}`); + +const decide = (previous: readonly string[], next: readonly string[]) => + shouldRebuildFileTree(previous, next, buildFileTreePathUpdates(previous, next)); describe("buildFileTreePathUpdates", () => { it("removes a folder once and skips the paths inside it", () => { @@ -17,3 +23,35 @@ describe("buildFileTreePathUpdates", () => { ]); }); }); + +describe("shouldRebuildFileTree", () => { + it("rebuilds when many paths land in a folder that dominates the tree", () => { + const existing = folder("big", 50_000); + expect(decide(existing, [...existing, ...addTo("big", 301)])).toBe(true); + }); + + it("rebuilds when many paths leave a folder that dominates the tree", () => { + const existing = folder("big", 50_000); + expect(decide(existing, existing.slice(301))).toBe(true); + }); + + it("batches a few hundred paths into a small folder beside a large unrelated one", () => { + const existing = folder("big", 50_000); + expect(decide(existing, [...existing, ...folder("small", 301)])).toBe(false); + }); + + it("batches at 300 operations even in a folder that dominates the tree", () => { + const existing = folder("big", 50_000); + expect(decide(existing, [...existing, ...addTo("big", 300)])).toBe(false); + }); + + it("batches many operations spread thinly across many small folders", () => { + const existing = folder("big", 50_000); + const added = Array.from({ length: 1_000 }, (_, index) => `small-${index}/file.ts`); + expect(decide(existing, [...existing, ...added])).toBe(false); + }); +}); + +function addTo(name: string, count: number) { + return Array.from({ length: count }, (_, index) => `${name}/added-${index}`); +} diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts index f636c02d968a..23531d212c72 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -1,8 +1,41 @@ import type { FileTreeBatchOperation } from "@pierre/trees"; -// Each batched operation reworks every sibling. Both costs grow with the listing, and a rebuild -// wins at about 300 operations whether the folder holds 50,000 entries or 332,000. -export const MAX_INCREMENTAL_FILE_TREE_UPDATES = 300; +// A batched operation reworks its folder's siblings, while a rebuild reworks every path. On flat +// folders of 50,000 and 332,000 entries a rebuild won somewhere between 200 and 500 operations. +const REBUILD_COST_IN_FOLDER_OPERATIONS = 300; + +function parentOf(path: string): string { + const end = path.endsWith("/") ? path.length - 1 : path.length; + return path.slice(0, Math.max(0, path.lastIndexOf("/", end - 1))); +} + +function countByParent(paths: readonly string[]): Map { + const counts = new Map(); + for (const path of paths) { + const parent = parentOf(path); + counts.set(parent, (counts.get(parent) ?? 0) + 1); + } + return counts; +} + +export function shouldRebuildFileTree( + previousPaths: readonly string[], + nextPaths: readonly string[], + updates: readonly FileTreeBatchOperation[], +): boolean { + // Up to this many operations can't outweigh a rebuild, whatever the folder sizes. + if (updates.length <= REBUILD_COST_IN_FOLDER_OPERATIONS) return false; + const previousCounts = countByParent(previousPaths); + const nextCounts = countByParent(nextPaths); + let batchWork = 0; + for (const update of updates) { + if (update.type !== "add" && update.type !== "remove") continue; + const parent = parentOf(update.path); + batchWork += Math.max(previousCounts.get(parent) ?? 0, nextCounts.get(parent) ?? 0); + } + const rebuildWork = Math.max(previousPaths.length, nextPaths.length); + return batchWork > REBUILD_COST_IN_FOLDER_OPERATIONS * rebuildWork; +} function pathDepth(path: string): number { return path.split("/").filter(Boolean).length; From 7f8ef6136af602b28cfd44659587adfc22692ba9 Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:04:17 +0800 Subject: [PATCH 7/9] docs(web): say the rebuild shortcut rests on an estimate Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/fileTreePathReconciliation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/files/fileTreePathReconciliation.ts b/apps/web/src/components/files/fileTreePathReconciliation.ts index 23531d212c72..b57dfd013b8d 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.ts @@ -23,7 +23,7 @@ export function shouldRebuildFileTree( nextPaths: readonly string[], updates: readonly FileTreeBatchOperation[], ): boolean { - // Up to this many operations can't outweigh a rebuild, whatever the folder sizes. + // Up to this many operations, the estimate below can't exceed the rebuild, so skip counting. if (updates.length <= REBUILD_COST_IN_FOLDER_OPERATIONS) return false; const previousCounts = countByParent(previousPaths); const nextCounts = countByParent(nextPaths); From 88e3a29793986c90eb8d3cc14e6734291e0192d8 Mon Sep 17 00:00:00 2001 From: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:17:08 +0800 Subject: [PATCH 8/9] test(web): pin the rebuild decision for folder paths Co-Authored-By: Claude Opus 5.5 --- .../components/files/fileTreePathReconciliation.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/files/fileTreePathReconciliation.test.ts b/apps/web/src/components/files/fileTreePathReconciliation.test.ts index 10c389bfe3c6..c9ec975f4594 100644 --- a/apps/web/src/components/files/fileTreePathReconciliation.test.ts +++ b/apps/web/src/components/files/fileTreePathReconciliation.test.ts @@ -30,6 +30,13 @@ describe("shouldRebuildFileTree", () => { expect(decide(existing, [...existing, ...addTo("big", 301)])).toBe(true); }); + it("counts a folder's subfolders as siblings, whose paths end in a slash", () => { + const existing = folder("big", 50_000).map((path) => `${path}/`); + const added = addTo("big", 301).map((path) => `${path}/`); + expect(decide(existing, [...existing, ...added])).toBe(true); + expect(decide(existing, existing.slice(301))).toBe(true); + }); + it("rebuilds when many paths leave a folder that dominates the tree", () => { const existing = folder("big", 50_000); expect(decide(existing, existing.slice(301))).toBe(true); From 1ef3a5cea983d873b7cc67ee5be01c42b396582d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 15:57:35 +0000 Subject: [PATCH 9/9] test(web): cut the large-folder Files tests to the ones that guard the rebuild Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 42 +++---------------- .../files/fileTreePathReconciliation.test.ts | 10 ----- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index ce8cb4ccba50..c7456f141c3b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -135,7 +135,7 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); - it("applies a very large folder listing quickly and keeps the expanded folder open", async () => { + it("keeps an expanded folder open when a very large listing rebuilds the tree", async () => { using _listing = withDirectoryListing(entries); const renderPanel = () => ( { directory.entries = [ ...entries, // Out of sort order, because inserting in order hides the cost of each insert. - ...Array.from({ length: 40_000 }, (_, index): ProjectEntry => { - const name = String((index * 7_919) % 40_000).padStart(5, "0"); + ...Array.from({ length: 2_000 }, (_, index): ProjectEntry => { + const name = String((index * 7_919) % 2_000).padStart(4, "0"); return { path: `src/generated-${name}`, kind: "directory" }; }), ]; await screen.rerender(renderPanel()); - await vi.waitFor(() => expect(findTreeRow("src/generated-00000/")).not.toBeNull()); + await vi.waitFor(() => expect(findTreeRow("src/generated-0000/")).not.toBeNull()); expect(findTreeRow("src/")!.getAttribute("aria-expanded")).toBe("true"); }); - it("applies the removal of a very large number of sibling folders quickly", async () => { - const renderPanel = () => ( - {}} - workspaceMutationId={null} - /> - ); - using _listing = withDirectoryListing([ - ...entries, - ...Array.from({ length: 30_000 }, (_, index): ProjectEntry => { - return { path: `src/generated-${String(index).padStart(6, "0")}`, kind: "directory" }; - }), - ]); - await using screen = await render(renderPanel()); - await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); - findTreeRow("src/")!.click(); - await vi.waitFor(() => expect(findTreeRow("src/generated-000000/")).not.toBeNull()); - - directory.entries = entries; - await screen.rerender(renderPanel()); - - await vi.waitFor(() => expect(findTreeRow("src/generated-000000/")).toBeNull()); - expect(findTreeRow("README.md")).not.toBeNull(); - }); - it("keeps the selected file through a very large listing without reopening it", async () => { const onOpenFile = vi.fn(); const renderPanel = () => ( { const renderPanel = () => ( { expect(decide(existing, existing.slice(301))).toBe(true); }); - it("rebuilds when many paths leave a folder that dominates the tree", () => { - const existing = folder("big", 50_000); - expect(decide(existing, existing.slice(301))).toBe(true); - }); - it("batches a few hundred paths into a small folder beside a large unrelated one", () => { const existing = folder("big", 50_000); expect(decide(existing, [...existing, ...folder("small", 301)])).toBe(false); }); - it("batches at 300 operations even in a folder that dominates the tree", () => { - const existing = folder("big", 50_000); - expect(decide(existing, [...existing, ...addTo("big", 300)])).toBe(false); - }); - it("batches many operations spread thinly across many small folders", () => { const existing = folder("big", 50_000); const added = Array.from({ length: 1_000 }, (_, index) => `small-${index}/file.ts`);