authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-01 05:20:02+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 16:50:02-07:00
log17f00f60248374aafa2efb9b54ff08ce52e60a0a
tree1ceb7fee43a7261b5c339cf9db8fb28822350c8c
parentc50b0b4ef8d61ffe3de1cfa6249f906709f7da1f
signature Signed by PGP key B5690EEEBB952194

feat(web): add expand/collapse all control to the files surface (#8889)


3 files changed, 154 insertions(+), 2 deletions(-)

apps/web/src/components/files/FileBrowserPanel.tsx+39-2
...@@ -3,9 +3,9 @@ import type {...@@ -3,9 +3,9 @@ import type {
3 ContextMenuOpenContext as TreeContextMenuOpenContext,3 ContextMenuOpenContext as TreeContextMenuOpenContext,
4} from "@pierre/trees";4} from "@pierre/trees";
5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";6import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { RotateCw } from "lucide-react";8import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
9import { useEffect, useMemo, useRef } from "react";9import { useEffect, useMemo, useRef } from "react";
1010
11import { Button } from "~/components/ui/button";11import { Button } from "~/components/ui/button";
...@@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi";...@@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi";
21import { T3_PIERRE_ICONS } from "~/pierre-icons";21import { T3_PIERRE_ICONS } from "~/pierre-icons";
2222
23import { createFileTreeDragMentionController } from "./fileTreeDragMention";23import { createFileTreeDragMentionController } from "./fileTreeDragMention";
24import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
24import { useProjectEntriesQuery } from "./projectFilesQueryState";25import { useProjectEntriesQuery } from "./projectFilesQueryState";
2526
26interface FileBrowserPanelProps {27interface FileBrowserPanelProps {
...@@ -121,6 +122,10 @@ export default function FileBrowserPanel({...@@ -121,6 +122,10 @@ export default function FileBrowserPanel({
121 );122 );
122 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);123 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
123 const treePaths = useMemo(() => entries.map(treePath), [entries]);124 const treePaths = useMemo(() => entries.map(treePath), [entries]);
125 const directoryPaths = useMemo(
126 () => entries.filter((entry) => entry.kind === "directory").map(treePath),
127 [entries],
128 );
124 const previousTreePathsRef = useRef<readonly string[]>([]);129 const previousTreePathsRef = useRef<readonly string[]>([]);
125 const syncingSelectionRef = useRef(false);130 const syncingSelectionRef = useRef(false);
126 const treeSelectionPathRef = useRef<string | null>(null);131 const treeSelectionPathRef = useRef<string | null>(null);
...@@ -252,6 +257,12 @@ export default function FileBrowserPanel({...@@ -252,6 +257,12 @@ export default function FileBrowserPanel({
252 unsafeCSS: TREE_UNSAFE_CSS,257 unsafeCSS: TREE_UNSAFE_CSS,
253 });258 });
254 const search = useFileTreeSearch(model);259 const search = useFileTreeSearch(model);
260 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
261 areAllDirectoriesExpanded(currentModel, directoryPaths),
262 );
263 const toggleAllDirectories = () => {
264 setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);
265 };
255 const handleSearchValueChange = (value: string) => {266 const handleSearchValueChange = (value: string) => {
256 if (value.trim().length === 0) {267 if (value.trim().length === 0) {
257 search.close();268 search.close();
...@@ -376,6 +387,32 @@ export default function FileBrowserPanel({...@@ -376,6 +387,32 @@ export default function FileBrowserPanel({
376 onValueChange={handleSearchValueChange}387 onValueChange={handleSearchValueChange}
377 onClose={search.close}388 onClose={search.close}
378 />389 />
390 {directoryPaths.length > 0 ? (
391 <Tooltip>
392 <TooltipTrigger
393 render={
394 <Button
395 type="button"
396 size="icon-xs"
397 variant="ghost"
398 aria-label={
399 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
400 }
401 onClick={toggleAllDirectories}
402 />
403 }
404 >
405 {allDirectoriesExpanded ? (
406 <ChevronsDownUpIcon className="size-3.5" />
407 ) : (
408 <ChevronsUpDownIcon className="size-3.5" />
409 )}
410 </TooltipTrigger>
411 <TooltipPopup>
412 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
413 </TooltipPopup>
414 </Tooltip>
415 ) : null}
379 </div>416 </div>
380 {entriesQuery.error && entriesQuery.data === null ? (417 {entriesQuery.error && entriesQuery.data === null ? (
381 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>418 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
apps/web/src/components/files/fileTreeExpansion.test.ts created+60
...@@ -0,0 +1,60 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2
3import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
4
5type FakeDirectoryItem = {
6 isDirectory: () => true;
7 isExpanded: () => boolean;
8 expand: () => void;
9 collapse: () => void;
10};
11
12function makeModel(expanded: Record<string, boolean>) {
13 const items = new Map<string, FakeDirectoryItem>();
14 return {
15 getItem: (path: string) => {
16 const existing = items.get(path);
17 if (existing !== undefined) return existing;
18 const item: FakeDirectoryItem = {
19 isDirectory: () => true,
20 isExpanded: () => expanded[path] ?? false,
21 expand: () => {
22 expanded[path] = true;
23 },
24 collapse: () => {
25 expanded[path] = false;
26 },
27 };
28 items.set(path, item);
29 return item;
30 },
31 };
32}
33
34describe("file tree expansion", () => {
35 it("requires at least one directory and detects whether all are expanded", () => {
36 const model = makeModel({ "src/": true, "test/": true });
37 expect(areAllDirectoriesExpanded(model, [])).toBe(false);
38 expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true);
39 expect(
40 areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]),
41 ).toBe(false);
42 });
43
44 it("expands and collapses every directory", () => {
45 const expanded = { "src/": true, "test/": false };
46 const model = makeModel(expanded);
47 setAllDirectoriesExpanded(model, ["src/", "test/"], true);
48 expect(expanded).toEqual({ "src/": true, "test/": true });
49 setAllDirectoriesExpanded(model, ["src/", "test/"], false);
50 expect(expanded).toEqual({ "src/": false, "test/": false });
51 });
52
53 it("skips directories already at the requested state", () => {
54 const model = makeModel({ "src/": true });
55 const item = model.getItem("src/");
56 const collapse = vi.spyOn(item, "collapse");
57 setAllDirectoriesExpanded(model, ["src/"], true);
58 expect(collapse).not.toHaveBeenCalled();
59 });
60});
apps/web/src/components/files/fileTreeExpansion.ts created+55
...@@ -0,0 +1,55 @@
1export interface FileTreeExpansionModel {
2 getItem(path: string): unknown;
3}
4
5type DirectoryHandle = {
6 isDirectory(): boolean;
7 isExpanded(): boolean;
8 expand(): void;
9 collapse(): void;
10};
11
12function asDirectoryHandle(item: unknown): DirectoryHandle | null {
13 if (
14 typeof item !== "object" ||
15 item === null ||
16 !("isDirectory" in item) ||
17 typeof item.isDirectory !== "function" ||
18 !item.isDirectory() ||
19 !("isExpanded" in item) ||
20 typeof item.isExpanded !== "function" ||
21 !("expand" in item) ||
22 typeof item.expand !== "function" ||
23 !("collapse" in item) ||
24 typeof item.collapse !== "function"
25 ) {
26 return null;
27 }
28 return item as DirectoryHandle;
29}
30
31export function areAllDirectoriesExpanded(
32 model: FileTreeExpansionModel,
33 directoryPaths: readonly string[],
34): boolean {
35 return (
36 directoryPaths.length > 0 &&
37 directoryPaths.every((path) => {
38 const item = asDirectoryHandle(model.getItem(path));
39 return item !== null && item.isExpanded();
40 })
41 );
42}
43
44export function setAllDirectoriesExpanded(
45 model: FileTreeExpansionModel,
46 directoryPaths: readonly string[],
47 expanded: boolean,
48): void {
49 for (const path of directoryPaths) {
50 const item = asDirectoryHandle(model.getItem(path));
51 if (item === null || item.isExpanded() === expanded) continue;
52 if (expanded) item.expand();
53 else item.collapse();
54 }
55}