Unified · Split
| @@ -1,14 +1,85 @@ | ||
| 1 | 1 | import { type TurnId } from "@t3tools/contracts"; |
| 2 | 2 | import { memo, useCallback, useMemo, useState } from "react"; |
| 3 | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | −import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; | |
| 4 | +import { | |
| 5 | + buildTurnDiffTree, | |
| 6 | + summarizeTurnDiffStats, | |
| 7 | + type TurnDiffTreeNode, | |
| 8 | +} from "../../lib/turnDiffTree"; | |
| 5 | 9 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; |
| 6 | 10 | import { cn } from "~/lib/utils"; |
| 7 | 11 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 8 | 12 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; |
| 13 | +import { Button } from "../ui/button"; | |
| 9 | 14 | |
| 10 | 15 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 11 | 16 | |
| 17 | +export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | |
| 18 | + turnId: TurnId; | |
| 19 | + files: ReadonlyArray<TurnDiffFileChange>; | |
| 20 | + allDirectoriesExpanded: boolean; | |
| 21 | + resolvedTheme: "light" | "dark"; | |
| 22 | + onToggleAllDirectories: () => void; | |
| 23 | + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 24 | +}) { | |
| 25 | + const { | |
| 26 | + turnId, | |
| 27 | + files, | |
| 28 | + allDirectoriesExpanded, | |
| 29 | + resolvedTheme, | |
| 30 | + onToggleAllDirectories, | |
| 31 | + onOpenTurnDiff, | |
| 32 | + } = props; | |
| 33 | + const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <div className="relative mt-4 rounded-2xl bg-card/40 shadow-xs/5 not-dark:bg-clip-padding after:pointer-events-none after:absolute after:inset-0 after:z-20 after:rounded-2xl after:border after:border-input"> | |
| 37 | + <div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 rounded-t-2xl bg-card/72 p-3 backdrop-blur-md"> | |
| 38 | + <p className="flex items-center gap-1 font-medium text-foreground text-xs leading-4"> | |
| 39 | + <span>{files.length} changed files</span> | |
| 40 | + {hasNonZeroStat(summaryStat) && ( | |
| 41 | + <DiffStatLabel | |
| 42 | + additions={summaryStat.additions} | |
| 43 | + className="text-xs leading-4" | |
| 44 | + deletions={summaryStat.deletions} | |
| 45 | + layout="inline" | |
| 46 | + /> | |
| 47 | + )} | |
| 48 | + </p> | |
| 49 | + <div className="flex items-center gap-1.5"> | |
| 50 | + <Button | |
| 51 | + type="button" | |
| 52 | + size="xs" | |
| 53 | + variant="outline" | |
| 54 | + data-scroll-anchor-ignore | |
| 55 | + onClick={onToggleAllDirectories} | |
| 56 | + > | |
| 57 | + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 58 | + </Button> | |
| 59 | + <Button | |
| 60 | + type="button" | |
| 61 | + size="xs" | |
| 62 | + variant="outline" | |
| 63 | + onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} | |
| 64 | + > | |
| 65 | + View diff | |
| 66 | + </Button> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + <div className="px-2 pb-2"> | |
| 70 | + <ChangedFilesTree | |
| 71 | + key={`changed-files-tree:${turnId}`} | |
| 72 | + turnId={turnId} | |
| 73 | + files={files} | |
| 74 | + allDirectoriesExpanded={allDirectoriesExpanded} | |
| 75 | + resolvedTheme={resolvedTheme} | |
| 76 | + onOpenTurnDiff={onOpenTurnDiff} | |
| 77 | + /> | |
| 78 | + </div> | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +}); | |
| 82 | + | |
| 12 | 83 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 13 | 84 | turnId: TurnId; |
| 14 | 85 | files: ReadonlyArray<TurnDiffFileChange>; |
| @@ -22,6 +93,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 7 unmodified lines | ||
| 22 | 93 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| 23 | 94 | [treeNodes], |
| 24 | 95 | ); |
| 96 | + const hasDirectoryNodes = directoryPathsKey.length > 0; | |
| 25 | 97 | const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; |
| 26 | 98 | const [directoryExpansionState, setDirectoryExpansionState] = useState<{ |
| 27 | 99 | key: string; |
| @@ -60,7 +132,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 32 unmodified lines | ||
| 60 | 132 | <button |
| 61 | 133 | type="button" |
| 62 | 134 | data-scroll-anchor-ignore |
| 63 | − className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 135 | + className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | |
| 64 | 136 | style={{ paddingLeft: `${leftPadding}px` }} |
| 65 | 137 | onClick={() => toggleDirectory(node.path)} |
| 66 | 138 | > |
| @@ -98,11 +170,13 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 31 unmodified lines | ||
| 98 | 170 | <button |
| 99 | 171 | key={`file:${node.path}`} |
| 100 | 172 | type="button" |
| 101 | − className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 173 | + className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | |
| 102 | 174 | style={{ paddingLeft: `${leftPadding}px` }} |
| 103 | 175 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| 104 | 176 | > |
| 105 | − <span aria-hidden="true" className="size-3.5 shrink-0" /> | |
| 177 | + {hasDirectoryNodes || depth > 0 ? ( | |
| 178 | + <span aria-hidden="true" className="size-3.5 shrink-0" /> | |
| 179 | + ) : null} | |
| 106 | 180 | <VscodeEntryIcon |
| 107 | 181 | pathValue={node.path} |
| 108 | 182 | kind="file" |
| @@ -1,21 +1,46 @@ | ||
| 1 | 1 | import { memo } from "react"; |
| 2 | +import { cn } from "~/lib/utils"; | |
| 2 | 3 | |
| 3 | 4 | export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { |
| 4 | 5 | return stat.additions > 0 || stat.deletions > 0; |
| 5 | 6 | } |
| 6 | 7 | |
| 8 | +function formatCompactDiffCount(value: number): string { | |
| 9 | + if (value < 1000) return String(value); | |
| 10 | + if (value < 1_000_000) { | |
| 11 | + const k = value / 1000; | |
| 12 | + return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`; | |
| 13 | + } | |
| 14 | + if (value < 1_000_000_000) { | |
| 15 | + const m = value / 1_000_000; | |
| 16 | + return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`; | |
| 17 | + } | |
| 18 | + const b = value / 1_000_000_000; | |
| 19 | + return `${b < 10 ? b.toFixed(1).replace(/\.0$/, "") : Math.round(b)}b`; | |
| 20 | +} | |
| 21 | + | |
| 7 | 22 | export const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 8 | 23 | additions: number; |
| 9 | 24 | deletions: number; |
| 25 | + className?: string; | |
| 10 | 26 | showParentheses?: boolean; |
| 27 | + layout?: "aligned" | "inline"; | |
| 11 | 28 | }) { |
| 12 | − const { additions, deletions, showParentheses = false } = props; | |
| 29 | + const { additions, deletions, className, showParentheses = false, layout = "aligned" } = props; | |
| 13 | 30 | return ( |
| 14 | 31 | <> |
| 15 | 32 | {showParentheses && <span className="text-muted-foreground/70">(</span>} |
| 16 | − <span className="text-success">+{additions}</span> | |
| 17 | − <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 18 | − <span className="text-destructive">-{deletions}</span> | |
| 33 | + <span | |
| 34 | + className={cn( | |
| 35 | + layout === "inline" | |
| 36 | + ? "inline-flex items-center gap-1 tabular-nums align-middle" | |
| 37 | + : "inline-grid grid-cols-[4ch_4ch] gap-2 text-right tabular-nums align-middle", | |
| 38 | + className, | |
| 39 | + )} | |
| 40 | + > | |
| 41 | + <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span> | |
| 42 | + <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span> | |
| 43 | + </span> | |
| 19 | 44 | {showParentheses && <span className="text-muted-foreground/70">)</span>} |
| 20 | 45 | </> |
| 21 | 46 | ); |
| @@ -20,7 +20,6 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; · 19 unmodified lines | ||
| 20 | 20 | import { FileDiff } from "@pierre/diffs/react"; |
| 21 | 21 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; |
| 22 | 22 | import { type TurnDiffSummary } from "../../types"; |
| 23 | −import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | |
| 24 | 23 | import { |
| 25 | 24 | getRenderablePatch, |
| 26 | 25 | resolveDiffThemeName, |
| @@ -44,8 +43,7 @@ import { · 17 unmodified lines | ||
| 44 | 43 | import { Button } from "../ui/button"; |
| 45 | 44 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 46 | 45 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 47 | −import { ChangedFilesTree } from "./ChangedFilesTree"; | |
| 48 | −import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | |
| 46 | +import { ChangedFilesCard } from "./ChangedFilesTree"; | |
| 49 | 47 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 50 | 48 | import { |
| 51 | 49 | computeStableMessagesTimelineRows, |
| @@ -708,50 +706,18 @@ function AssistantChangedFilesSectionInner({ · 656 unmodified lines | ||
| 708 | 706 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, |
| 709 | 707 | ); |
| 710 | 708 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 711 | − const summaryStat = summarizeTurnDiffStats(checkpointFiles); | |
| 712 | − const changedFileCountLabel = String(checkpointFiles.length); | |
| 713 | 709 | |
| 714 | 710 | return ( |
| 715 | − <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 716 | − <div className="sticky top-2 z-10 mb-1.5 flex items-center justify-between gap-2 bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:absolute before:inset-x-0 before:-top-2 before:h-2 before:bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:content-['']"> | |
| 717 | − <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 718 | − <span>Changed files ({changedFileCountLabel})</span> | |
| 719 | − {hasNonZeroStat(summaryStat) && ( | |
| 720 | − <> | |
| 721 | − <span className="mx-1">•</span> | |
| 722 | − <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} /> | |
| 723 | − </> | |
| 724 | − )} | |
| 725 | − </p> | |
| 726 | − <div className="flex items-center gap-1.5"> | |
| 727 | − <Button | |
| 728 | − type="button" | |
| 729 | − size="xs" | |
| 730 | − variant="outline" | |
| 731 | − data-scroll-anchor-ignore | |
| 732 | − onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)} | |
| 733 | − > | |
| 734 | − {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 735 | − </Button> | |
| 736 | − <Button | |
| 737 | − type="button" | |
| 738 | − size="xs" | |
| 739 | − variant="outline" | |
| 740 | − onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} | |
| 741 | − > | |
| 742 | − View diff | |
| 743 | − </Button> | |
| 744 | − </div> | |
| 745 | − </div> | |
| 746 | − <ChangedFilesTree | |
| 747 | − key={`changed-files-tree:${turnSummary.turnId}`} | |
| 748 | − turnId={turnSummary.turnId} | |
| 749 | − files={checkpointFiles} | |
| 750 | − allDirectoriesExpanded={allDirectoriesExpanded} | |
| 751 | − resolvedTheme={resolvedTheme} | |
| 752 | − onOpenTurnDiff={onOpenTurnDiff} | |
| 753 | − /> | |
| 754 | − </div> | |
| 711 | + <ChangedFilesCard | |
| 712 | + turnId={turnSummary.turnId} | |
| 713 | + files={checkpointFiles} | |
| 714 | + allDirectoriesExpanded={allDirectoriesExpanded} | |
| 715 | + resolvedTheme={resolvedTheme} | |
| 716 | + onToggleAllDirectories={() => | |
| 717 | + setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) | |
| 718 | + } | |
| 719 | + onOpenTurnDiff={onOpenTurnDiff} | |
| 720 | + /> | |
| 755 | 721 | ); |
| 756 | 722 | } |
| 757 | 723 | |