Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type {
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { ChevronsDownUp, ChevronsUpDown, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";

import { toastManager } from "~/components/ui/toast";
import { useComposerHandleContext } from "~/composerHandleContext";
Expand All @@ -17,6 +17,13 @@ import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";

import { createFileTreeDragMentionController } from "./fileTreeDragMention";
import {
directoryTreePaths,
getFileTreeExpansionToggle,
getFileTreeExpansionSnapshot,
initiallyExpandedDirectoryPaths,
setAllDirectoriesExpanded,
} from "./fileTreeExpansion";
import { useProjectEntriesQuery } from "./projectFilesQueryState";

interface FileBrowserPanelProps {
Expand Down Expand Up @@ -58,6 +65,7 @@ export default function FileBrowserPanel({
);
const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const directoryPaths = useMemo(() => directoryTreePaths(entries), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// The tree renders rows in shadow DOM and its anchor rect is unreliable, so
Expand Down Expand Up @@ -161,7 +169,7 @@ export default function FileBrowserPanel({
density: "compact",
fileTreeSearchMode: "hide-non-matches",
flattenEmptyDirectories: true,
initialExpansion: 1,
initialExpansion: "closed",
icons: T3_PIERRE_ICONS,
onSelectionChange: (selectedPaths) => {
dragMention.handleSelectionChange(selectedPaths);
Expand All @@ -179,13 +187,30 @@ export default function FileBrowserPanel({
search: true,
unsafeCSS: TREE_UNSAFE_CSS,
});
const subscribeToTree = useCallback((listener: () => void) => model.subscribe(listener), [model]);
const getExpansionSnapshot = useCallback(
() => getFileTreeExpansionSnapshot(model, directoryPaths),
[directoryPaths, model],
);
const expansionSnapshot = useSyncExternalStore(
subscribeToTree,
getExpansionSnapshot,
getExpansionSnapshot,
);
const expansionControlsDisabled =
expansionSnapshot === "empty" || expansionSnapshot === "searching";
const expansionToggle = getFileTreeExpansionToggle(expansionSnapshot);

useEffect(() => {
if (previousTreePathsRef.current === treePaths) return;
entryKindsRef.current = entryKinds;
previousTreePathsRef.current = treePaths;
model.resetPaths(treePaths);
}, [entryKinds, model, treePaths]);
// A closed default lets bulk operations replace expansion in one reset;
// explicitly retain the explorer's existing depth-one initial state.
model.resetPaths(treePaths, {
initialExpandedPaths: initiallyExpandedDirectoryPaths(directoryPaths),
});
}, [directoryPaths, entryKinds, model, treePaths]);

const fileCount = useMemo(
() => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0),
Expand Down Expand Up @@ -233,6 +258,21 @@ export default function FileBrowserPanel({
{entriesQuery.data?.truncated ? " · partial" : ""}
</div>
</div>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
aria-label={expansionToggle.label}
disabled={expansionControlsDisabled}
onClick={() =>
setAllDirectoriesExpanded(model, treePaths, directoryPaths, expansionToggle.expanded)
}
>
{expansionToggle.expanded ? (
<ChevronsUpDown className="size-3.5" />
) : (
<ChevronsDownUp className="size-3.5" />
)}
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
Expand Down
220 changes: 220 additions & 0 deletions apps/web/src/components/files/fileTreeExpansion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { describe, expect, it, vi } from "@effect/vitest";
import { FileTree } from "@pierre/trees";
import type { ProjectEntry } from "@t3tools/contracts";

import {
COLLAPSE_ALL_FOLDERS_LABEL,
directoryTreePaths,
EXPAND_ALL_FOLDERS_LABEL,
getFileTreeExpansionToggle,
getFileTreeExpansionSnapshot,
initiallyExpandedDirectoryPaths,
setAllDirectoriesExpanded,
TOGGLE_ALL_FOLDERS_LABEL,
} from "./fileTreeExpansion.ts";

function makeModel(expansionByPath: Record<string, boolean>, searching = false) {
const state = new Map(Object.entries(expansionByPath));
const expand = vi.fn((path: string) => state.set(path, true));
const collapse = vi.fn((path: string) => state.set(path, false));
const resetPaths = vi.fn(
(_paths: readonly string[], options: { initialExpandedPaths: readonly string[] }) => {
const expandedPaths = new Set(options.initialExpandedPaths);
for (const path of state.keys()) state.set(path, expandedPaths.has(path));
},
);
return {
collapse,
expand,
model: {
getItem: (path: string) =>
state.has(path)
? {
collapse: () => collapse(path),
expand: () => expand(path),
isDirectory: () => true as const,
isExpanded: () => state.get(path) ?? false,
}
: null,
isSearchOpen: () => searching,
resetPaths,
},
resetPaths,
state,
};
}

describe("file tree expansion controls", () => {
it("collects unique nested directory paths in parent-first order for flattened trees", () => {
const entries: ProjectEntry[] = [
{ kind: "file", path: "src/ui/index.ts" },
{ kind: "directory", path: "src/ui" },
{ kind: "directory", path: "src" },
{ kind: "directory", path: "src/ui/" },
];

expect(directoryTreePaths(entries)).toEqual(["src/", "src/ui/"]);
expect(initiallyExpandedDirectoryPaths(["src/", "src/ui/", "docs/"])).toEqual([
"src/",
"docs/",
]);
});

it("expands and collapses every directory in a flattened tree model", () => {
const modelPaths = ["src/", "src/ui/", "src/ui/components/", "src/ui/components/Button.tsx"];
const model = new FileTree({
flattenEmptyDirectories: true,
initialExpansion: "closed",
paths: modelPaths,
});
const directoryPaths = ["src/", "src/ui/", "src/ui/components/"];

try {
setAllDirectoriesExpanded(model, modelPaths, directoryPaths, true);
expect(getFileTreeExpansionSnapshot(model, directoryPaths)).toBe("expanded");

setAllDirectoriesExpanded(model, modelPaths, directoryPaths, false);
expect(getFileTreeExpansionSnapshot(model, directoryPaths)).toBe("collapsed");
} finally {
model.cleanUp();
}
});

it("preserves the previous depth-one initial expansion", () => {
const modelPaths = ["docs/", "docs/guide/", "docs/guide/start.md", "src/", "src/index.ts"];
const directoryPaths = ["docs/", "src/", "docs/guide/"];
const previousModel = new FileTree({
flattenEmptyDirectories: true,
initialExpansion: 1,
paths: modelPaths,
});
const batchedModel = new FileTree({
flattenEmptyDirectories: true,
initialExpandedPaths: initiallyExpandedDirectoryPaths(directoryPaths),
initialExpansion: "closed",
paths: modelPaths,
});

try {
expect(
directoryPaths.map((path) => getFileTreeExpansionSnapshot(previousModel, [path])),
).toEqual(directoryPaths.map((path) => getFileTreeExpansionSnapshot(batchedModel, [path])));
} finally {
previousModel.cleanUp();
batchedModel.cleanUp();
}
});

it("reports an empty tree and gives the toggle an explicit accessible label", () => {
const { model } = makeModel({});

expect(getFileTreeExpansionSnapshot(model, [])).toBe("empty");
expect(getFileTreeExpansionToggle("empty")).toEqual({
expanded: false,
label: TOGGLE_ALL_FOLDERS_LABEL,
});
expect(getFileTreeExpansionToggle("searching")).toEqual({
expanded: false,
label: TOGGLE_ALL_FOLDERS_LABEL,
});
});

it("toggles toward the opposite uniform expansion state", () => {
expect(getFileTreeExpansionToggle("collapsed")).toEqual({
expanded: true,
label: EXPAND_ALL_FOLDERS_LABEL,
});
expect(getFileTreeExpansionToggle("expanded")).toEqual({
expanded: false,
label: COLLAPSE_ALL_FOLDERS_LABEL,
});
expect(getFileTreeExpansionToggle("mixed")).toEqual({
expanded: false,
label: COLLAPSE_ALL_FOLDERS_LABEL,
});
});

it("expands only collapsed directories", () => {
const { expand, model, resetPaths, state } = makeModel({
"docs/": true,
"src/": false,
"src/ui/": false,
});

expect(getFileTreeExpansionSnapshot(model, [...state.keys()])).toBe("mixed");
const paths = [...state.keys()];
setAllDirectoriesExpanded(model, paths, paths, true);

expect(resetPaths).toHaveBeenCalledOnce();
expect(resetPaths).toHaveBeenCalledWith(paths, { initialExpandedPaths: paths });
expect(expand).not.toHaveBeenCalled();
expect(getFileTreeExpansionSnapshot(model, [...state.keys()])).toBe("expanded");

setAllDirectoriesExpanded(model, paths, paths, true);
expect(resetPaths).toHaveBeenCalledOnce();
});

it("collapses only expanded directories", () => {
const { collapse, model, resetPaths, state } = makeModel({
"docs/": false,
"src/": true,
"src/ui/": true,
});

const paths = [...state.keys()];
setAllDirectoriesExpanded(model, paths, paths, false);

expect(resetPaths).toHaveBeenCalledOnce();
expect(resetPaths).toHaveBeenCalledWith(paths, { initialExpandedPaths: [] });
expect(collapse).not.toHaveBeenCalled();
expect(getFileTreeExpansionSnapshot(model, [...state.keys()])).toBe("collapsed");
});

it("emits and recomputes subscribed state only once for a large tree", () => {
const directoryPaths = Array.from({ length: 2_000 }, (_, index) => `folder-${index}/`);
const model = new FileTree({ initialExpansion: "closed", paths: directoryPaths });
const originalGetItem = model.getItem.bind(model);
let itemReads = 0;
const countedModel = {
getItem: (path: string) => {
itemReads += 1;
return originalGetItem(path);
},
isSearchOpen: () => model.isSearchOpen(),
resetPaths: (
paths: readonly string[],
options: { initialExpandedPaths: readonly string[] },
) => model.resetPaths(paths, options),
};
let subscriptionEmissions = 0;
const unsubscribe = model.subscribe(() => {
subscriptionEmissions += 1;
getFileTreeExpansionSnapshot(countedModel, directoryPaths);
});
const emissionsBeforeExpansion = subscriptionEmissions;
itemReads = 0;

try {
setAllDirectoriesExpanded(countedModel, directoryPaths, directoryPaths, true);

expect(subscriptionEmissions - emissionsBeforeExpansion).toBe(1);
expect(itemReads).toBeLessThanOrEqual(directoryPaths.length * 2);
expect(getFileTreeExpansionSnapshot(model, directoryPaths)).toBe("expanded");
} finally {
unsubscribe();
model.cleanUp();
}
});

it("does not change temporary expansion while search is open", () => {
const { collapse, expand, model, resetPaths } = makeModel({ "src/": true }, true);

expect(getFileTreeExpansionSnapshot(model, ["src/"])).toBe("searching");
setAllDirectoriesExpanded(model, ["src/"], ["src/"], false);
setAllDirectoriesExpanded(model, ["src/"], ["src/"], true);

expect(collapse).not.toHaveBeenCalled();
expect(expand).not.toHaveBeenCalled();
expect(resetPaths).not.toHaveBeenCalled();
});
});
Loading
Loading