| 1 | import { type RunId } from "@t3tools/contracts"; |
| 2 | import { type MouseEvent, memo, useCallback, useMemo, useState } from "react"; |
| 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | import { |
| 5 | buildTurnDiffTree, |
| 6 | summarizeTurnDiffStats, |
| 7 | type TurnDiffTreeNode, |
| 8 | } from "../../lib/turnDiffTree"; |
| 9 | import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; |
| 10 | import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; |
| 11 | import { cn } from "~/lib/utils"; |
| 12 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 13 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 14 | import { Button } from "../ui/button"; |
| 15 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 16 | import { MiddleTruncate } from "../ui/middle-truncate"; |
| 17 | import { MorphIcon } from "~/components/MorphIcon"; |
| 18 | |
| 19 | const 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). */ |
| 22 | export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void; |
| 23 | |
| 24 | export 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 | |
| 126 | export 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 | |
| 252 | function 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 | } |