Skip to content
74 changes: 71 additions & 3 deletions apps/web/src/components/files/FileBrowserPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -16,15 +17,15 @@ const entries: readonly ProjectEntry[] = [
const loadingDirectories: ReadonlySet<string> = new Set();
const directory: {
entries: readonly ProjectEntry[];
load: () => Promise<void>;
load: (path: string) => Promise<void>;
refresh: () => void;
ready: boolean;
error: null;
isPending: boolean;
loadingDirectories: ReadonlySet<string>;
} = {
entries,
load: () => Promise.resolve(),
load: (_path) => Promise.resolve(),
refresh: () => {},
ready: true,
error: null,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
<FileBrowserPanel
environmentId={EnvironmentId.make("env-files")}
cwd="/repo-expand-all-1"
projectName="repo"
selectedPath={null}
selectedPathRevealId={0}
onOpenFile={() => {}}
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 = () => (
<FileBrowserPanel
environmentId={EnvironmentId.make("env-files")}
cwd="/repo-expand-all-2"
projectName="repo"
selectedPath={null}
selectedPathRevealId={0}
onOpenFile={() => {}}
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(
<FileBrowserPanel
Expand Down
47 changes: 33 additions & 14 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type {
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import * as Cause from "effect/Cause";
import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
import { type Ref, useEffect, useEffectEvent, useRef, useState } from "react";
import { type Ref, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react";

import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
Expand All @@ -30,9 +30,9 @@ import { joinHostPath } from "./filePath";
import { createFileTreeDragMentionController } from "./fileTreeDragMention";
import {
areAllDirectoriesExpanded,
coalesceReentrantCalls,
readExpandedDirectories,
setAllDirectoriesExpanded,
subscribeOncePerTask,
writeExpandedDirectories,
} from "./fileTreeExpansion";
import { buildFileTreePathUpdates, shouldRebuildFileTree } from "./fileTreePathReconciliation";
Expand Down Expand Up @@ -314,8 +314,11 @@ export default function FileBrowserPanel({
const search = useFileTreeSearch(model);
// Pierre restores the unfiltered tree when a search has no matches.
const hasNoSearchMatches = search.value.trim().length > 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);
Expand Down Expand Up @@ -345,20 +348,26 @@ export default function FileBrowserPanel({
return subscribePreviewAction(onPanelAction);
}, [answersPanelFind]);
const expandedPathsRef = useRef(new Set<string>());
const expandedByExpandAllRef = useRef(new Set<string>());
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();
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
22 changes: 1 addition & 21 deletions apps/web/src/components/files/fileTreeExpansion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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;
Expand Down
Loading