diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx new file mode 100644 index 0000000000..5b72fd237d --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -0,0 +1,116 @@ +import { TurnId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ChangedFilesTree } from "./ChangedFilesTree"; + +describe("ChangedFilesTree", () => { + it.each([ + { + name: "a compacted single-chain directory", + files: [ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + ], + visibleLabels: ["apps/web/src"], + hiddenLabels: ["index.ts", "main.ts"], + }, + { + name: "a branch point after a compacted prefix", + files: [ + { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, + { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + ], + visibleLabels: ["apps/server/src"], + hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], + }, + { + name: "mixed root files and nested compacted directories", + files: [ + { path: "README.md", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, + { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + ], + visibleLabels: ["README.md", "packages"], + hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], + }, + ])( + "renders $name collapsed on the first render when collapse-all is active", + ({ files, visibleLabels, hiddenLabels }) => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + for (const label of visibleLabels) { + expect(markup).toContain(label); + } + for (const label of hiddenLabels) { + expect(markup).not.toContain(label); + } + }, + ); + + it.each([ + { + name: "a compacted single-chain directory", + files: [ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + ], + visibleLabels: ["apps/web/src", "index.ts", "main.ts"], + }, + { + name: "a branch point after a compacted prefix", + files: [ + { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, + { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + ], + visibleLabels: [ + "apps/server/src", + "git/Layers", + "provider/Layers", + "GitCore.ts", + "CodexAdapter.ts", + ], + }, + { + name: "mixed root files and nested compacted directories", + files: [ + { path: "README.md", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, + { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + ], + visibleLabels: [ + "README.md", + "packages", + "shared/src", + "contracts/src", + "git.ts", + "orchestration.ts", + ], + }, + ])( + "renders $name expanded on the first render when expand-all is active", + ({ files, visibleLabels }) => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + for (const label of visibleLabels) { + expect(markup).toContain(label); + } + }, + ); +}); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a13ddad30d..29bd96ea05 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,5 +1,5 @@ import { type TurnId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; @@ -7,6 +7,8 @@ import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; +const EMPTY_DIRECTORY_OVERRIDES: Record = {}; + export const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; @@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { () => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes], ); - const allDirectoryExpansionState = useMemo( - () => - buildDirectoryExpansionState( - directoryPathsKey ? directoryPathsKey.split("\u0000") : [], - allDirectoriesExpanded, - ), - [allDirectoriesExpanded, directoryPathsKey], - ); - const [expandedDirectories, setExpandedDirectories] = useState>(() => - buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), - ); - useEffect(() => { - setExpandedDirectories(allDirectoryExpansionState); - }, [allDirectoryExpansionState]); + const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; + const [directoryExpansionState, setDirectoryExpansionState] = useState<{ + key: string; + overrides: Record; + }>(() => ({ + key: expansionStateKey, + overrides: {}, + })); + const expandedDirectories = + directoryExpansionState.key === expansionStateKey + ? directoryExpansionState.overrides + : EMPTY_DIRECTORY_OVERRIDES; - const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { - setExpandedDirectories((current) => ({ - ...current, - [pathValue]: !(current[pathValue] ?? fallbackExpanded), - })); - }, []); + const toggleDirectory = useCallback( + (pathValue: string) => { + setDirectoryExpansionState((current) => { + const nextOverrides = current.key === expansionStateKey ? current.overrides : {}; + return { + key: expansionStateKey, + overrides: { + ...nextOverrides, + [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded), + }, + }; + }); + }, + [allDirectoriesExpanded, expansionStateKey], + ); const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { const leftPadding = 8 + depth * 14; if (node.kind === "directory") { - const isExpanded = expandedDirectories[node.path] ?? depth === 0; + const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded; return (