1import type { GitStatusEntry } from "@pierre/trees";
2import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react";
3import { ChevronsDownUp, ChevronsUpDown } from "lucide";
4import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
5
6import { useTheme } from "~/hooks/useTheme";
7import { cn } from "~/lib/utils";
8import { T3_PIERRE_ICONS } from "~/pierre-icons";
9import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
10
11import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion";
12import { Button } from "../ui/button";
13import { MorphIcon } from "~/components/MorphIcon";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
15import {
16 buildDiffFileTreeUpdates,
17 compareDiffFileTreeEntries,
18 collectDirectoryPaths,
19 diffFileTreePositions,
20 type DiffFileTreeEntry,
21} from "./diffFileTree.logic";
22
23export type { DiffFileTreeEntry } from "./diffFileTree.logic";
24
25interface DiffFileTreeProps {
26 readonly entries: ReadonlyArray<DiffFileTreeEntry>;
27 /** Called with the file's path when the reader picks a file row. */
28 readonly onSelectFile: (path: string) => void;
29 /**
30 * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to
31 * scroll the tree to the same path again.
32 */
33 readonly selectedPath?: string | null;
34 readonly revealRequestId?: number;
35 readonly ariaLabel: string;
36 /** Right-aligned content in the header row, after the file count. */
37 readonly headerAccessory?: ReactNode;
38 /** Rendered under the tree, for a host that still has files to fetch. */
39 readonly footer?: ReactNode;
40 readonly className?: string;
41}
42
43/**
44 * A directory tree of the files in a diff. Every directory starts open: a diff is a short list
45 * compared to a workspace, and the reader came for the files, not the folders.
46 */
47export function DiffFileTree({
48 entries,
49 onSelectFile,
50 selectedPath = null,
51 revealRequestId = 0,
52 ariaLabel,
53 headerAccessory,
54 footer,
55 className,
56}: DiffFileTreeProps) {
57 const { resolvedTheme } = useTheme();
58 const paths = useMemo(() => entries.map((entry) => entry.path), [entries]);
59 const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]);
60 const positions = useMemo(() => diffFileTreePositions(paths), [paths]);
61 const [ordering] = useState(() => {
62 let currentPositions: ReadonlyMap<string, number> = new Map();
63 return {
64 sort: compareDiffFileTreeEntries(() => currentPositions),
65 update: (nextPositions: ReadonlyMap<string, number>) => {
66 currentPositions = nextPositions;
67 },
68 };
69 });
70 const gitStatus = useMemo<ReadonlyArray<GitStatusEntry>>(
71 () => entries.map((entry) => ({ path: entry.path, status: entry.status })),
72 [entries],
73 );
74 const filePathsRef = useRef<ReadonlySet<string>>(new Set(paths));
75 const onSelectFileRef = useRef(onSelectFile);
76 // Selection driven by `selectedPath` below is an echo of a file already on screen, not a
77 // request to scroll to it again.
78 const syncingSelectionRef = useRef(false);
79 const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null);
80 const mountedPathsRef = useRef<ReadonlyArray<string> | null>(null);
81
82 useEffect(() => {
83 filePathsRef.current = new Set(paths);
84 onSelectFileRef.current = onSelectFile;
85 }, [onSelectFile, paths]);
86
87 const { model } = useFileTree({
88 density: "compact",
89 flattenEmptyDirectories: true,
90 initialExpansion: "open",
91 icons: T3_PIERRE_ICONS,
92 onSelectionChange: (selectedPaths) => {
93 if (syncingSelectionRef.current) return;
94 const path = selectedPaths.at(-1)?.replace(/\/$/, "");
95 if (path && filePathsRef.current.has(path)) onSelectFileRef.current(path);
96 },
97 paths: [],
98 search: false,
99 sort: ordering.sort,
100 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
101 });
102 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
103 areAllDirectoriesExpanded(currentModel, directoryPaths),
104 );
105
106 useEffect(() => {
107 ordering.update(positions);
108 const mountedPaths = mountedPathsRef.current;
109 if (mountedPaths === paths) return;
110 mountedPathsRef.current = paths;
111 if (mountedPaths === null) {
112 model.resetPaths(paths);
113 } else if (mountedPaths.every((path, index) => paths[index] === path)) {
114 // PR slices only append files, so keep the existing tree and its open folders.
115 const updates = buildDiffFileTreeUpdates(mountedPaths, paths);
116 if (updates.length > 0) model.batch(updates);
117 } else {
118 // A refreshed diff can change the rank of existing siblings. Mutations do not reorder
119 // those rows, so rebuild while carrying the reader's folder expansion forward.
120 const collapsedDirectories = directoryPaths.filter((path) => {
121 const directory = model.getItem(path);
122 return directory !== null && "isExpanded" in directory && !directory.isExpanded();
123 });
124 model.resetPaths(paths);
125 for (const path of collapsedDirectories) {
126 const directory = model.getItem(path);
127 if (directory !== null && "collapse" in directory) directory.collapse();
128 }
129 }
130 model.setGitStatus(gitStatus);
131 }, [directoryPaths, gitStatus, model, ordering, paths, positions]);
132
133 useEffect(() => {
134 if (selectedPath === null) {
135 handledRevealRef.current = null;
136 return;
137 }
138 // A path list that changes under an already-revealed file (a refresh, a later slice) must
139 // not pull the tree back to it over whatever the reader has picked since.
140 const item = model.getItem(selectedPath);
141 if (item === null || item.isDirectory()) {
142 // A file that left the diff has to be revealed again when it comes back.
143 handledRevealRef.current = null;
144 return;
145 }
146 const handled = handledRevealRef.current;
147 if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return;
148 handledRevealRef.current = { path: selectedPath, revealRequestId };
149 syncingSelectionRef.current = true;
150 for (const path of model.getSelectedPaths()) {
151 if (path !== selectedPath) model.getItem(path)?.deselect();
152 }
153 let ancestor = "";
154 for (const segment of selectedPath.split("/").slice(0, -1)) {
155 ancestor += `${segment}/`;
156 const directory = model.getItem(ancestor);
157 if (directory !== null && "expand" in directory) directory.expand();
158 }
159 item.select();
160 model.scrollToPath(selectedPath, { offset: "nearest" });
161 queueMicrotask(() => {
162 syncingSelectionRef.current = false;
163 });
164 // `paths` is a dependency so a file that arrives after it was asked for is still revealed.
165 }, [model, paths, revealRequestId, selectedPath]);
166
167 return (
168 <div className={cn("flex min-h-0 flex-1 flex-col bg-background", className)}>
169 <div
170 className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 text-xs text-muted-foreground in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"
171 data-surface-subheader
172 >
173 <span className="px-1 font-medium text-foreground">Files</span>
174 <span className="ml-auto tabular-nums">{entries.length}</span>
175 {headerAccessory}
176 {directoryPaths.length > 0 ? (
177 <Tooltip>
178 <TooltipTrigger
179 render={
180 <Button
181 type="button"
182 size="icon-xs"
183 variant="ghost"
184 aria-label={
185 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
186 }
187 onClick={() =>
188 setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded)
189 }
190 />
191 }
192 >
193 <MorphIcon
194 className="size-3.5"
195 icon={allDirectoriesExpanded ? ChevronsDownUp : ChevronsUpDown}
196 />
197 </TooltipTrigger>
198 <TooltipPopup>
199 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
200 </TooltipPopup>
201 </Tooltip>
202 ) : null}
203 </div>
204 <FileTree
205 model={model}
206 aria-label={ariaLabel}
207 onClickCapture={(event) => {
208 if (
209 event.defaultPrevented ||
210 event.button !== 0 ||
211 event.ctrlKey ||
212 event.metaKey ||
213 event.shiftKey ||
214 event.altKey
215 ) {
216 return;
217 }
218 // Pierre does not emit a selection change for its sole selected row.
219 // Read selection before the row handles the click so new selections reveal only once.
220 const selected = model.getSelectedPaths();
221 const path = selected.length === 1 ? selected[0] : undefined;
222 if (!path || !filePathsRef.current.has(path)) return;
223 const clickedSelectedRow = event.nativeEvent
224 .composedPath()
225 .some(
226 (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path,
227 );
228 if (clickedSelectedRow) onSelectFileRef.current(path);
229 }}
230 className="min-h-0 flex-1 overflow-hidden"
231 style={pierreTreeStyle(resolvedTheme)}
232 />
233 {footer}
234 </div>
235 );
236}