| ... | ... | @@ -63,11 +63,17 @@ import { |
| 63 | 63 | MAX_THREAD_TERMINAL_COUNT, |
| 64 | 64 | type ChatMessage, |
| 65 | 65 | type Thread, |
| 66 | type TurnDiffFileChange, |
| 66 | 67 | type TurnDiffSummary, |
| 67 | 68 | } from "../types"; |
| 68 | 69 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 69 | 70 | import { useTheme } from "../hooks/useTheme"; |
| 70 | 71 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 72 | import { |
| 73 | buildTurnDiffTree, |
| 74 | summarizeTurnDiffStats, |
| 75 | type TurnDiffTreeNode, |
| 76 | } from "../lib/turnDiffTree"; |
| 71 | 77 | import BranchToolbar from "./BranchToolbar"; |
| 72 | 78 | import GitActionsControl from "./GitActionsControl"; |
| 73 | 79 | import { |
| ... | ... | @@ -3127,6 +3133,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri |
| 3127 | 3133 | ); |
| 3128 | 3134 | }); |
| 3129 | 3135 | |
| 3136 | function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { |
| 3137 | return stat.additions > 0 || stat.deletions > 0; |
| 3138 | } |
| 3139 | |
| 3140 | const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 3141 | additions: number; |
| 3142 | deletions: number; |
| 3143 | showParentheses?: boolean; |
| 3144 | }) { |
| 3145 | const { additions, deletions, showParentheses = false } = props; |
| 3146 | return ( |
| 3147 | <> |
| 3148 | {showParentheses && <span className="text-muted-foreground/70">(</span>} |
| 3149 | <span className="text-success">+{additions}</span> |
| 3150 | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| 3151 | <span className="text-destructive">-{deletions}</span> |
| 3152 | {showParentheses && <span className="text-muted-foreground/70">)</span>} |
| 3153 | </> |
| 3154 | ); |
| 3155 | }); |
| 3156 | |
| 3157 | function buildInitiallyExpandedDirectoryState( |
| 3158 | nodes: ReadonlyArray<TurnDiffTreeNode>, |
| 3159 | ): Record<string, boolean> { |
| 3160 | const expandedState: Record<string, boolean> = {}; |
| 3161 | for (const node of nodes) { |
| 3162 | if (node.kind === "directory") { |
| 3163 | expandedState[node.path] = true; |
| 3164 | } |
| 3165 | } |
| 3166 | return expandedState; |
| 3167 | } |
| 3168 | |
| 3169 | const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 3170 | turnId: TurnId; |
| 3171 | files: ReadonlyArray<TurnDiffFileChange>; |
| 3172 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 3173 | }) { |
| 3174 | const { files, onOpenTurnDiff, turnId } = props; |
| 3175 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); |
| 3176 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>( |
| 3177 | () => buildInitiallyExpandedDirectoryState(treeNodes), |
| 3178 | ); |
| 3179 | |
| 3180 | const toggleDirectory = useCallback((pathValue: string) => { |
| 3181 | setExpandedDirectories((current) => ({ |
| 3182 | ...current, |
| 3183 | [pathValue]: !current[pathValue], |
| 3184 | })); |
| 3185 | }, []); |
| 3186 | |
| 3187 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { |
| 3188 | const leftPadding = 8 + depth * 14; |
| 3189 | if (node.kind === "directory") { |
| 3190 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; |
| 3191 | return ( |
| 3192 | <div key={`dir:${node.path}`}> |
| 3193 | <button |
| 3194 | type="button" |
| 3195 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 3196 | style={{ paddingLeft: `${leftPadding}px` }} |
| 3197 | onClick={() => toggleDirectory(node.path)} |
| 3198 | > |
| 3199 | <ChevronRightIcon |
| 3200 | aria-hidden="true" |
| 3201 | className={cn( |
| 3202 | "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80", |
| 3203 | isExpanded && "rotate-90", |
| 3204 | )} |
| 3205 | /> |
| 3206 | {isExpanded ? ( |
| 3207 | <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| 3208 | ) : ( |
| 3209 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> |
| 3210 | )} |
| 3211 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> |
| 3212 | {node.name} |
| 3213 | </span> |
| 3214 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 3215 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 3216 | </span> |
| 3217 | </button> |
| 3218 | {isExpanded && ( |
| 3219 | <div className="space-y-0.5"> |
| 3220 | {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} |
| 3221 | </div> |
| 3222 | )} |
| 3223 | </div> |
| 3224 | ); |
| 3225 | } |
| 3226 | |
| 3227 | return ( |
| 3228 | <button |
| 3229 | key={`file:${node.path}`} |
| 3230 | type="button" |
| 3231 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 3232 | style={{ paddingLeft: `${leftPadding}px` }} |
| 3233 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| 3234 | > |
| 3235 | <span aria-hidden="true" className="size-3.5 shrink-0" /> |
| 3236 | <FileIcon className="size-3.5 shrink-0 text-muted-foreground/70" /> |
| 3237 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> |
| 3238 | {node.name} |
| 3239 | </span> |
| 3240 | {node.stat && ( |
| 3241 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> |
| 3242 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> |
| 3243 | </span> |
| 3244 | )} |
| 3245 | </button> |
| 3246 | ); |
| 3247 | }; |
| 3248 | |
| 3249 | return ( |
| 3250 | <div className="space-y-0.5"> |
| 3251 | {treeNodes.map((node) => renderTreeNode(node, 0))} |
| 3252 | </div> |
| 3253 | ); |
| 3254 | }); |
| 3255 | |
| 3130 | 3256 | interface MessagesTimelineProps { |
| 3131 | 3257 | hasMessages: boolean; |
| 3132 | 3258 | isWorking: boolean; |
| ... | ... | @@ -3531,33 +3657,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3531 | 3657 | if (!turnSummary) return null; |
| 3532 | 3658 | const checkpointFiles = turnSummary.files; |
| 3533 | 3659 | if (checkpointFiles.length === 0) return null; |
| 3534 | | const summaryStat = checkpointFiles.reduce( |
| 3535 | | (acc, file) => { |
| 3536 | | if ( |
| 3537 | | typeof file.additions !== "number" || |
| 3538 | | typeof file.deletions !== "number" |
| 3539 | | ) { |
| 3540 | | return acc; |
| 3541 | | } |
| 3542 | | return { |
| 3543 | | additions: acc.additions + file.additions, |
| 3544 | | deletions: acc.deletions + file.deletions, |
| 3545 | | }; |
| 3546 | | }, |
| 3547 | | { additions: 0, deletions: 0 }, |
| 3548 | | ); |
| 3660 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); |
| 3549 | 3661 | const changedFileCountLabel = String(checkpointFiles.length); |
| 3550 | 3662 | return ( |
| 3551 | 3663 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 3552 | 3664 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| 3553 | 3665 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 3554 | 3666 | <span>Changed files ({changedFileCountLabel})</span> |
| 3555 | | {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( |
| 3667 | {hasNonZeroStat(summaryStat) && ( |
| 3556 | 3668 | <> |
| 3557 | 3669 | <span className="mx-1">•</span> |
| 3558 | | <span className="text-success">+{summaryStat.additions}</span> |
| 3559 | | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| 3560 | | <span className="text-destructive">-{summaryStat.deletions}</span> |
| 3670 | <DiffStatLabel |
| 3671 | additions={summaryStat.additions} |
| 3672 | deletions={summaryStat.deletions} |
| 3673 | /> |
| 3561 | 3674 | </> |
| 3562 | 3675 | )} |
| 3563 | 3676 | </p> |
| ... | ... | @@ -3572,40 +3685,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3572 | 3685 | View diff |
| 3573 | 3686 | </Button> |
| 3574 | 3687 | </div> |
| 3575 | | <div className="flex flex-wrap gap-1.5"> |
| 3576 | | {checkpointFiles.map((file) => ( |
| 3577 | | <button |
| 3578 | | key={`${turnSummary.turnId}:${file.path}`} |
| 3579 | | type="button" |
| 3580 | | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" |
| 3581 | | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} |
| 3582 | | > |
| 3583 | | {(() => { |
| 3584 | | const stat = |
| 3585 | | typeof file.additions === "number" && |
| 3586 | | typeof file.deletions === "number" |
| 3587 | | ? { |
| 3588 | | additions: file.additions, |
| 3589 | | deletions: file.deletions, |
| 3590 | | } |
| 3591 | | : null; |
| 3592 | | if (!stat) { |
| 3593 | | return file.path; |
| 3594 | | } |
| 3595 | | return ( |
| 3596 | | <> |
| 3597 | | <span>{file.path}</span> |
| 3598 | | <span className="ml-1 text-muted-foreground/70">(</span> |
| 3599 | | <span className="text-success">+{stat.additions}</span> |
| 3600 | | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| 3601 | | <span className="text-destructive">-{stat.deletions}</span> |
| 3602 | | <span className="text-muted-foreground/70">)</span> |
| 3603 | | </> |
| 3604 | | ); |
| 3605 | | })()} |
| 3606 | | </button> |
| 3607 | | ))} |
| 3608 | | </div> |
| 3688 | <ChangedFilesTree |
| 3689 | key={`changed-files-tree:${turnSummary.turnId}`} |
| 3690 | turnId={turnSummary.turnId} |
| 3691 | files={checkpointFiles} |
| 3692 | onOpenTurnDiff={onOpenTurnDiff} |
| 3693 | /> |
| 3609 | 3694 | </div> |
| 3610 | 3695 | ); |
| 3611 | 3696 | })()} |