Skip to content
Source
Sign inSign up
Open History to see the last commit

ChangedFilesTree.tsx

426 lines15.7 KB

1import { useSyncLayout } from "@legendapp/list/react";
2import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts";
3import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react";
4import { type TurnDiffFileChange } from "../../types";
5import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
6import {
7 ChevronRightIcon,
8 FileDiffIcon,
9 ChevronsDownUpIcon,
10 ChevronsUpDownIcon,
11 FolderClosedIcon,
12 FolderIcon,
13} from "lucide-react";
14import { cn } from "~/lib/utils";
15import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
16import { PierreEntryIcon } from "./PierreEntryIcon";
17import { Button } from "../ui/button";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19import { MiddleTruncate } from "../ui/middle-truncate";
20import { observeResize } from "~/lib/observeResize";
21​
22const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
23const VIRTUALIZE_AFTER_ROWS = 250;
24const ROW_OVERSCAN = 8;
25​
26interface ChangedFileRow {
27 node: TurnDiffTreeNode;
28 depth: number;
30​
31type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void;
32​
33export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
34 runId: RunId;
35 /** May be a prefix of the changed files; `totals` covers all of them. */
36 files: ReadonlyArray<TurnDiffFileChange>;
37 totals: OrchestrationV2CheckpointFileTotals;
38 allDirectoriesExpanded: boolean;
39 resolvedTheme: "light" | "dark";
40 onToggleAllDirectories: () => void;
41 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
42 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
43}) {
44 const {
45 runId,
46 files,
47 totals,
48 allDirectoriesExpanded,
49 resolvedTheme,
50 onToggleAllDirectories,
51 onOpenTurnDiff,
52 onFileContextMenu,
53 } = props;
54 const syncTimelineLayout = useSyncLayout();
55 const hiddenFileCount = totals.count - files.length;
56 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
57​
58 return (
59 <div
60 className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20"
61 data-changed-files-state="tree"
62 >
63 <div
64 data-changed-files-header=""
65 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"
66 >
67 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">
68 <span>
69 {totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"}
70 </span>
71 {hasNonZeroStat(totals) && (
72 <DiffStatLabel
73 additions={totals.additions}
74 deletions={totals.deletions}
75 layout="inline"
76 className="text-xs leading-4"
77 />
78 )}
79 </div>
80 <div className="flex shrink-0 items-center gap-1">
81 {hasDirectories && (
82 <Tooltip>
83 <TooltipTrigger
84 render={
85 <Button
86 type="button"
87 size="icon-xs"
88 variant="ghost-muted"
89 aria-label={
90 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
91 }
92 data-scroll-anchor-ignore
93 onClick={() => {
94 onToggleAllDirectories();
95 syncTimelineLayout();
96 }}
97 />
98 }
99 >
100 {allDirectoriesExpanded ? (
101 <ChevronsDownUpIcon className="size-3" />
102 ) : (
103 <ChevronsUpDownIcon className="size-3" />
104 )}
105 </TooltipTrigger>
106 <TooltipPopup side="top">
107 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
108 </TooltipPopup>
109 </Tooltip>
110 )}
111 <Tooltip>
112 <TooltipTrigger
113 render={
114 <Button
115 type="button"
116 size="xs"
117 variant="ghost-muted"
118 aria-label="Open diff"
119 onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
120 />
121 }
122 >
123 <FileDiffIcon className="size-3" />
124 <span className="hidden @[24rem]/changed-files:inline">Open diff</span>
125 </TooltipTrigger>
126 <TooltipPopup side="top">Open the full diff</TooltipPopup>
127 </Tooltip>
128 </div>
129 </div>
130 <ChangedFilesTree
131 key={`${runId}:${allDirectoriesExpanded}`}
132 runId={runId}
133 files={files}
134 allDirectoriesExpanded={allDirectoriesExpanded}
135 resolvedTheme={resolvedTheme}
136 onOpenTurnDiff={onOpenTurnDiff}
137 onFileContextMenu={onFileContextMenu}
138 />
139 {hiddenFileCount > 0 && (
140 <div className="px-3 pb-2">
141 <Button
142 type="button"
143 size="xs"
144 variant="ghost-muted"
145 onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
146 >
147 {hiddenFileCount.toLocaleString()} more file{hiddenFileCount === 1 ? "" : "s"} in the
148 full diff
149 </Button>
150 </div>
151 )}
152 </div>
153 );
154});
155​
156const ChangedFilesTree = memo(function ChangedFilesTree(props: {
157 runId: RunId;
158 files: ReadonlyArray<TurnDiffFileChange>;
159 allDirectoriesExpanded: boolean;
160 resolvedTheme: "light" | "dark";
161 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
162 onFileContextMenu?: ChangedFileContextMenuHandler | undefined;
163}) {
164 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, runId, onFileContextMenu } =
165 props;
166 // Place state before tree derivation to preserve its compiler cache.
167 const [directoryExpansionState, setDirectoryExpansionState] = useState<{
168 key: string | null;
169 overrides: Record<string, boolean>;
170 }>({ key: null, overrides: {} });
171 const syncTimelineLayout = useSyncLayout();
172 const treeNodes = buildTurnDiffTree(files);
173 const directoryPathsKey = collectDirectoryPaths(treeNodes).join("\u0000");
174 const hasDirectoryNodes = directoryPathsKey.length > 0;
175 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;
176 const expandedDirectories =
177 directoryExpansionState.key === expansionStateKey
178 ? directoryExpansionState.overrides
179 : EMPTY_DIRECTORY_OVERRIDES;
180​
181 const toggleDirectory = (pathValue: string) => {
182 setDirectoryExpansionState((current) => {
183 const nextOverrides = current.key === expansionStateKey ? current.overrides : {};
184 return {
185 key: expansionStateKey,
186 overrides: {
187 ...nextOverrides,
188 [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded),
189 },
190 };
191 });
192 // Measure before paint instead of waiting for LegendList's deferred shrink measurement.
193 syncTimelineLayout();
194 };
195​
196 const rows = collectExpandedRows(treeNodes, expandedDirectories, allDirectoriesExpanded);
197 const virtualized = rows.length > VIRTUALIZE_AFTER_ROWS;
198​
199 const renderRow = ({ node, depth }: ChangedFileRow) => {
200 const leftPadding = 8 + depth * 14;
201 if (node.kind === "directory") {
202 const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded;
203 return (
204 <button
205 key={`dir:${node.path}`}
206 type="button"
207 data-scroll-anchor-ignore
208 aria-expanded={isExpanded}
209 className="group flex h-7 w-full cursor-pointer 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"
210 style={{ paddingLeft: `${leftPadding}px` }}
211 onClick={() => toggleDirectory(node.path)}
212 >
213 <ChevronRightIcon
214 aria-hidden="true"
215 className={cn(
216 "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80",
217 isExpanded && "rotate-90",
218 )}
219 />
220 {isExpanded ? (
221 <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
222 ) : (
223 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
224 )}
225 <span className="truncate font-mono text-2xs text-muted-foreground/90 group-hover:text-foreground/90">
226 {node.name}
227 </span>
228 {hasNonZeroStat(node.stat) && (
229 <span className="ml-auto shrink-0 font-mono text-3xs tabular-nums">
230 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
231 </span>
232 )}
233 </button>
234 );
235 }
236​
237 return (
238 <button
239 key={`file:${node.path}`}
240 type="button"
241 className="group flex h-7 w-full cursor-pointer 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"
242 style={{ paddingLeft: `${leftPadding}px` }}
243 onClick={() => onOpenTurnDiff(runId, node.path)}
244 onContextMenu={
245 onFileContextMenu
246 ? (event) => {
247 event.preventDefault();
248 onFileContextMenu(node.path, event);
249 }
250 : undefined
251 }
252 >
253 {hasDirectoryNodes || depth > 0 ? (
254 <span aria-hidden="true" className="size-3.5 shrink-0" />
255 ) : null}
256 <PierreEntryIcon
257 pathValue={node.path}
258 kind="file"
259 theme={resolvedTheme}
260 className="size-3.5 text-muted-foreground/70"
261 />
262 <span className="flex min-w-0 font-mono text-xs text-foreground/85 group-hover:text-foreground">
263 <MiddleTruncate value={node.name} />
264 </span>
265 {node.stat && (
266 <span className="ml-auto shrink-0 font-mono text-3xs tabular-nums">
267 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
268 </span>
269 )}
270 </button>
271 );
272 };
273​
274 return <ChangedFilesRows rows={rows} renderRow={renderRow} virtualized={virtualized} />;
275});
276​
277function ChangedFilesRows({
278 rows,
279 renderRow,
280 virtualized,
281}: {
282 rows: ReadonlyArray<ChangedFileRow>;
283 renderRow: (row: ChangedFileRow) => ReactNode;
284 virtualized: boolean;
285}) {
286 const containerRef = useRef<HTMLDivElement>(null);
287 const rowSizeRef = useRef<HTMLDivElement>(null);
288 const [range, setRange] = useState({ start: 0, end: 40, rowHeight: 28 });
289 const [focusedPath, setFocusedPath] = useState<string | null>(null);
290​
291 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes scroll, window resize and ancestor resizes to compute the virtualized row range
292 useLayoutEffect(() => {
293 const container = containerRef.current;
294 const rowSize = rowSizeRef.current;
295 if (!virtualized || !container || !rowSize) return;
296​
297 // The card is inside a virtualized timeline item, so its own scrollTop is always zero.
298 // Observe its ancestors too: streaming text and timeline layout can move the card.
299 const ancestors: HTMLElement[] = [container, rowSize];
300 let scrollElement: HTMLElement | null = null;
301 for (let parent = container.parentElement; parent; parent = parent.parentElement) {
302 ancestors.push(parent);
303 if (/auto|scroll/.test(getComputedStyle(parent).overflowY)) {
304 scrollElement = parent;
305 break;
306 }
307 }
308​
309 const measure = () => {
310 const rowHeight = rowSize.getBoundingClientRect().height;
311 if (rowHeight <= 0) return;
312 const listTop = container.getBoundingClientRect().top;
313 const viewportTop = scrollElement
314 ? scrollElement.getBoundingClientRect().top + scrollElement.clientTop
315 : 0;
316 const viewportHeight = scrollElement?.clientHeight ?? document.documentElement.clientHeight;
317 const start = Math.max(
318 0,
319 Math.min(rows.length, Math.floor((viewportTop - listTop) / rowHeight) - ROW_OVERSCAN),
320 );
321 const end = Math.max(
322 start,
323 Math.min(
324 rows.length,
325 Math.ceil((viewportTop + viewportHeight - listTop) / rowHeight) + ROW_OVERSCAN,
326 ),
327 );
328 setRange((current) =>
329 current.start === start && current.end === end && current.rowHeight === rowHeight
330 ? current
331 : { start, end, rowHeight },
332 );
333 };
334 measure();
335 const scrollTarget = scrollElement ?? globalThis.window;
336 let frame = 0;
337 const onScroll = () => {
338 if (frame) return;
339 frame = requestAnimationFrame(() => {
340 frame = 0;
341 measure();
342 });
343 };
344 scrollTarget.addEventListener("scroll", onScroll, { passive: true });
345 globalThis.window.addEventListener("resize", onScroll);
346 const stopObserving = observeResize(ancestors, measure);
347 return () => {
348 scrollTarget.removeEventListener("scroll", onScroll);
349 globalThis.window.removeEventListener("resize", onScroll);
350 if (frame) cancelAnimationFrame(frame);
351 stopObserving();
352 };
353 }, [virtualized, rows.length]);
354​
355 const focusedIndex =
356 focusedPath === null ? -1 : rows.findIndex((row) => row.node.path === focusedPath);
357 const start = virtualized ? range.start : 0;
358 const end = virtualized ? Math.min(rows.length, range.end) : rows.length;
359 const indices = Array.from({ length: Math.max(0, end - start) }, (_, index) => start + index);
360 // Keep keyboard focus on its row when mouse scrolling moves it outside the render window.
361 if (focusedIndex >= 0 && focusedIndex < start) indices.unshift(focusedIndex);
362 if (focusedIndex >= end) indices.push(focusedIndex);
363 return (
364 <div className="p-2">
365 <div
366 ref={containerRef}
367 className="relative"
368 style={virtualized ? { height: rows.length * range.rowHeight } : undefined}
369 onFocusCapture={(event) => {
370 const row = event.target.closest<HTMLElement>("[data-changed-file-row]");
371 setFocusedPath(row?.dataset.changedFileRow ?? null);
372 }}
373 onBlurCapture={(event) => {
374 if (!event.currentTarget.contains(event.relatedTarget)) setFocusedPath(null);
375 }}
376 >
377 {virtualized && (
378 <div
379 ref={rowSizeRef}
380 aria-hidden="true"
381 className="pointer-events-none invisible absolute h-7"
382 />
383 )}
384 {indices.map((index) => {
385 const row = rows[index]!;
386 return (
387 <div
388 key={`${row.node.kind}:${row.node.path}`}
389 data-changed-file-row={row.node.path}
390 className={virtualized ? "absolute inset-x-0" : undefined}
391 style={virtualized ? { top: index * range.rowHeight } : undefined}
392 >
393 {renderRow(row)}
394 </div>
395 );
396 })}
397 </div>
398 </div>
399 );
401​
402function collectExpandedRows(
403 nodes: ReadonlyArray<TurnDiffTreeNode>,
404 overrides: Readonly<Record<string, boolean>>,
405 allDirectoriesExpanded: boolean,
406 depth = 0,
407 rows: ChangedFileRow[] = [],
408): ChangedFileRow[] {
409 for (const node of nodes) {
410 rows.push({ node, depth });
411 if (node.kind === "directory" && (overrides[node.path] ?? allDirectoriesExpanded)) {
412 collectExpandedRows(node.children, overrides, allDirectoriesExpanded, depth + 1, rows);
413 }
414 }
415 return rows;
417​
418function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
419 const paths: string[] = [];
420 for (const node of nodes) {
421 if (node.kind !== "directory") continue;
422 paths.push(node.path);
423 paths.push(...collectDirectoryPaths(node.children));
424 }
425 return paths;