1import { type RunId } from "@t3tools/contracts";
2import { type MouseEvent, memo, useCallback, useMemo, useState } from "react";
3import { type TurnDiffFileChange } from "../../types";
4import {
5 buildTurnDiffTree,
6 summarizeTurnDiffStats,
7 type TurnDiffTreeNode,
8} from "../../lib/turnDiffTree";
9import { ChevronRightIcon, FileDiffIcon } from "lucide-react";
10import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide";
11import { cn } from "~/lib/utils";
12import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
13import { PierreEntryIcon } from "./PierreEntryIcon";
14import { Button } from "../ui/button";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
16import { MiddleTruncate } from "../ui/middle-truncate";
17import { MorphIcon } from "~/components/MorphIcon";
18
19const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
20
21/** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */
22export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void;
23
24export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
25 runId: RunId;
26 files: ReadonlyArray<TurnDiffFileChange>;
27 allDirectoriesExpanded: boolean;
28 resolvedTheme: "light" | "dark";
29 onToggleAllDirectories: () => void;
30 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
31 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
32}) {
33 const {
34 runId,
35 files,
36 allDirectoriesExpanded,
37 resolvedTheme,
38 onToggleAllDirectories,
39 onOpenTurnDiff,
40 onFileContextMenu,
41 } = props;
42 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
43 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
44
45 return (
46 <div
47 className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20"
48 data-changed-files-state="tree"
49 >
50 <div
51 data-changed-files-header=""
52 className="sticky top-2 z-10 flex items-center justify-between gap-2 rounded-t-lg bg-secondary px-3 py-2 dark:bg-background dark:bg-linear-to-b dark:from-input/20 dark:to-input/20"
53 >
54 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">
55 <span>
56 {files.length} changed file{files.length === 1 ? "" : "s"}
57 </span>
58 {hasNonZeroStat(summaryStat) && (
59 <DiffStatLabel
60 additions={summaryStat.additions}
61 deletions={summaryStat.deletions}
62 layout="inline"
63 className="text-xs leading-4"
64 />
65 )}
66 </div>
67 <div className="flex shrink-0 items-center gap-1">
68 {hasDirectories && (
69 <Tooltip>
70 <TooltipTrigger
71 render={
72 <Button
73 type="button"
74 size="icon-xs"
75 variant="ghost-muted"
76 aria-label={
77 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
78 }
79 data-scroll-anchor-ignore
80 onClick={onToggleAllDirectories}
81 />
82 }
83 >
84 <MorphIcon
85 className="size-3"
86 icon={allDirectoriesExpanded ? ChevronsDownUp : ChevronsUpDown}
87 />
88 </TooltipTrigger>
89 <TooltipPopup side="top">
90 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
91 </TooltipPopup>
92 </Tooltip>
93 )}
94 <Tooltip>
95 <TooltipTrigger
96 render={
97 <Button
98 type="button"
99 size="xs"
100 variant="ghost-muted"
101 aria-label="Open diff"
102 onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
103 />
104 }
105 >
106 <FileDiffIcon className="size-3" />
107 <span className="hidden @[24rem]/changed-files:inline">Open diff</span>
108 </TooltipTrigger>
109 <TooltipPopup side="top">Open the full diff</TooltipPopup>
110 </Tooltip>
111 </div>
112 </div>
113 <ChangedFilesTree
114 key={`${runId}:${allDirectoriesExpanded}`}
115 runId={runId}
116 files={files}
117 allDirectoriesExpanded={allDirectoriesExpanded}
118 resolvedTheme={resolvedTheme}
119 onOpenTurnDiff={onOpenTurnDiff}
120 onFileContextMenu={onFileContextMenu}
121 />
122 </div>
123 );
124});
125
126export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
127 runId: RunId;
128 files: ReadonlyArray<TurnDiffFileChange>;
129 allDirectoriesExpanded: boolean;
130 resolvedTheme: "light" | "dark";
131 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
132 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
133}) {
134 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, runId, onFileContextMenu } =
135 props;
136 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
137 const directoryPathsKey = useMemo(
138 () => collectDirectoryPaths(treeNodes).join("\u0000"),
139 [treeNodes],
140 );
141 const hasDirectoryNodes = directoryPathsKey.length > 0;
142 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;
143 const [directoryExpansionState, setDirectoryExpansionState] = useState<{
144 key: string;
145 overrides: Record<string, boolean>;
146 }>(() => ({
147 key: expansionStateKey,
148 overrides: {},
149 }));
150 const expandedDirectories =
151 directoryExpansionState.key === expansionStateKey
152 ? directoryExpansionState.overrides
153 : EMPTY_DIRECTORY_OVERRIDES;
154
155 const toggleDirectory = useCallback(
156 (pathValue: string) => {
157 setDirectoryExpansionState((current) => {
158 const nextOverrides = current.key === expansionStateKey ? current.overrides : {};
159 return {
160 key: expansionStateKey,
161 overrides: {
162 ...nextOverrides,
163 [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded),
164 },
165 };
166 });
167 },
168 [allDirectoriesExpanded, expansionStateKey],
169 );
170
171 const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => {
172 const leftPadding = 8 + depth * 14;
173 if (node.kind === "directory") {
174 const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded;
175 return (
176 <div key={`dir:${node.path}`}>
177 <button
178 type="button"
179 data-scroll-anchor-ignore
180 aria-expanded={isExpanded}
181 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
182 style={{ paddingLeft: `${leftPadding}px` }}
183 onClick={() => toggleDirectory(node.path)}
184 >
185 <ChevronRightIcon
186 aria-hidden="true"
187 className={cn(
188 "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80",
189 isExpanded && "rotate-90",
190 )}
191 />
192 <MorphIcon
193 className="size-3.5 shrink-0 text-muted-foreground/75"
194 icon={isExpanded ? Folder : FolderClosed}
195 />
196 <span className="truncate font-mono text-2xs text-muted-foreground/90 group-hover:text-foreground/90">
197 {node.name}
198 </span>
199 {hasNonZeroStat(node.stat) && (
200 <span className="ml-auto shrink-0 font-mono text-3xs tabular-nums">
201 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
202 </span>
203 )}
204 </button>
205 {isExpanded && (
206 <div>{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}</div>
207 )}
208 </div>
209 );
210 }
211
212 return (
213 <button
214 key={`file:${node.path}`}
215 type="button"
216 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
217 style={{ paddingLeft: `${leftPadding}px` }}
218 onClick={() => onOpenTurnDiff(runId, node.path)}
219 onContextMenu={
220 onFileContextMenu
221 ? (event) => {
222 event.preventDefault();
223 onFileContextMenu(node.path, event);
224 }
225 : undefined
226 }
227 >
228 {hasDirectoryNodes || depth > 0 ? (
229 <span aria-hidden="true" className="size-3.5 shrink-0" />
230 ) : null}
231 <PierreEntryIcon
232 pathValue={node.path}
233 kind="file"
234 theme={resolvedTheme}
235 className="size-3.5 text-muted-foreground/70"
236 />
237 <span className="flex min-w-0 font-mono text-xs text-foreground/85 group-hover:text-foreground">
238 <MiddleTruncate value={node.name} />
239 </span>
240 {node.stat && (
241 <span className="ml-auto shrink-0 font-mono text-3xs tabular-nums">
242 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
243 </span>
244 )}
245 </button>
246 );
247 };
248
249 return <div className="p-2">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
250});
251
252function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
253 const paths: string[] = [];
254 for (const node of nodes) {
255 if (node.kind !== "directory") continue;
256 paths.push(node.path);
257 paths.push(...collectDirectoryPaths(node.children));
258 }
259 return paths;
260}