| 1 | import type { GitStatusEntry } from "@pierre/trees"; |
| 2 | import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; |
| 3 | import { ChevronsDownUp, ChevronsUpDown } from "lucide"; |
| 4 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 5 | |
| 6 | import { useTheme } from "~/hooks/useTheme"; |
| 7 | import { cn } from "~/lib/utils"; |
| 8 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 9 | import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; |
| 10 | |
| 11 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; |
| 12 | import { Button } from "../ui/button"; |
| 13 | import { MorphIcon } from "~/components/MorphIcon"; |
| 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 15 | import { |
| 16 | buildDiffFileTreeUpdates, |
| 17 | compareDiffFileTreeEntries, |
| 18 | collectDirectoryPaths, |
| 19 | diffFileTreePositions, |
| 20 | type DiffFileTreeEntry, |
| 21 | } from "./diffFileTree.logic"; |
| 22 | |
| 23 | export type { DiffFileTreeEntry } from "./diffFileTree.logic"; |
| 24 | |
| 25 | interface 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 | */ |
| 47 | export 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 | } |