diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 446da2559270..bc6cdf243cad 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -31,6 +31,7 @@ import { joinHostPath } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; +import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; @@ -366,12 +367,7 @@ export default function FileBrowserPanel({ useEffect(() => { if (!selectedPath) return; const controller = new AbortController(); - void (async () => { - const segments = selectedPath.split("/"); - for (let index = 0; index < segments.length && !controller.signal.aborted; index++) { - await load(segments.slice(0, index).join("/")); - } - })(); + void loadFileAncestors(selectedPath, load, { signal: controller.signal }); return () => { controller.abort(); }; diff --git a/apps/web/src/components/files/loadFileAncestors.test.ts b/apps/web/src/components/files/loadFileAncestors.test.ts new file mode 100644 index 000000000000..1fd165a790ed --- /dev/null +++ b/apps/web/src/components/files/loadFileAncestors.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "vite-plus/test"; +import { loadFileAncestors } from "./loadFileAncestors"; + +function queuedLoader() { + const paths: string[] = []; + const resolve: Array<() => void> = []; + const load = (path: string) => { + paths.push(path); + return new Promise((done) => resolve.push(done)); + }; + return { paths, resolve, load }; +} + +it("bounds loads to four and finishes children even when parents finish last", async () => { + const queue = queuedLoader(); + const loading = loadFileAncestors("a/b/c/d/e/f/file.ts", queue.load, new AbortController()); + for (let index = 3; index < 6; index++) { + queue.resolve[index]?.(); + await Promise.resolve(); + expect(queue.paths).toHaveLength(index + 2); + } + expect(queue.paths).toEqual(["", "a", "a/b", "a/b/c", "a/b/c/d", "a/b/c/d/e", "a/b/c/d/e/f"]); + for (const resolve of queue.resolve) resolve(); + await loading; +}); + +it("stops aborted reveals while a new selection loads independently", async () => { + const old = queuedLoader(); + const controller = new AbortController(); + const oldReveal = loadFileAncestors("old/a/b/c/d/file.ts", old.load, controller); + controller.abort(); + const fresh = queuedLoader(); + const newReveal = loadFileAncestors("new/file.ts", fresh.load, new AbortController()); + for (const resolve of [...old.resolve, ...fresh.resolve]) resolve(); + await Promise.all([oldReveal, newReveal]); + await loadFileAncestors("aborted/path/file.ts", old.load, controller); + expect(old.paths).toEqual(["", "old", "old/a", "old/a/b"]); + expect(fresh.paths).toEqual(["", "new"]); +}); diff --git a/apps/web/src/components/files/loadFileAncestors.ts b/apps/web/src/components/files/loadFileAncestors.ts new file mode 100644 index 000000000000..126bf35b3770 --- /dev/null +++ b/apps/web/src/components/files/loadFileAncestors.ts @@ -0,0 +1,15 @@ +/** The loader owns deduplication and the shared request cap, including other tree loads. */ +export async function loadFileAncestors( + selectedPath: string, + load: (directoryPath: string) => Promise, + options: { readonly signal: AbortSignal }, +) { + const segments = selectedPath.split("/"); + let next = 0; + const worker = async () => { + while (next < segments.length && !options.signal.aborted) { + await load(segments.slice(0, next++).join("/")); + } + }; + await Promise.all(Array.from({ length: Math.min(4, segments.length) }, worker)); +}