@spunto/design-system 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +225 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
|
4
|
+
import {
|
|
5
|
+
ChevronRightIcon,
|
|
6
|
+
CopyIcon,
|
|
7
|
+
FileDiffIcon,
|
|
8
|
+
PowerIcon,
|
|
9
|
+
RefreshCwIcon,
|
|
10
|
+
SearchIcon,
|
|
11
|
+
} from "lucide-react"
|
|
12
|
+
|
|
13
|
+
import { cn } from "../../utils"
|
|
14
|
+
import type { TaskDiff, TaskDiffFile, TaskFilePatch, TaskPatchHunk } from "./types"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a task changed — the review panel of the cockpit, beside the session.
|
|
18
|
+
*
|
|
19
|
+
* The session answers "what did it do?". This answers the question that decides whether the work
|
|
20
|
+
* is accepted: **what is different now.** Spunto reads the task's own machine with git, so there
|
|
21
|
+
* is no forge in the loop and no pull request required to review a branch. The component itself
|
|
22
|
+
* reads nothing: the summary comes in as `diff`, and each file's patch through `loadPatch`.
|
|
23
|
+
*
|
|
24
|
+
* Three decisions shape the layout, and they are all about a reviewer's eye rather than about
|
|
25
|
+
* diffs in general:
|
|
26
|
+
*
|
|
27
|
+
* - **The list opens, the files load.** A 60-file change must appear instantly; the bytes of a
|
|
28
|
+
* file arrive when it is opened. So the summary is one request and every patch is its own.
|
|
29
|
+
* - **The line is the unit.** Two gutters of numbers, a coloured rail, and — the part that makes
|
|
30
|
+
* a dense diff readable — the *changed span within a line* is highlighted, so a renamed
|
|
31
|
+
* variable in a 200-character line does not have to be hunted for.
|
|
32
|
+
* - **Nothing is claimed that was not read.** A stopped machine, a missing base branch and an
|
|
33
|
+
* empty diff are three different sentences: "this task changed nothing" is the one answer that
|
|
34
|
+
* must never be a side effect of not having looked.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
type DiffFile = TaskDiffFile
|
|
38
|
+
type Hunk = TaskPatchHunk
|
|
39
|
+
type FilePatch = TaskFilePatch
|
|
40
|
+
|
|
41
|
+
const STATUS_STYLE: Record<string, { letter: string; label: string; className: string }> = {
|
|
42
|
+
added: { letter: "A", label: "added", className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" },
|
|
43
|
+
modified: { letter: "M", label: "modified", className: "border-primary/30 bg-primary/10 text-primary" },
|
|
44
|
+
deleted: { letter: "D", label: "deleted", className: "border-destructive/30 bg-destructive/10 text-destructive" },
|
|
45
|
+
renamed: { letter: "R", label: "renamed", className: "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-400" },
|
|
46
|
+
copied: { letter: "C", label: "copied", className: "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-400" },
|
|
47
|
+
"type-changed": { letter: "T", label: "type changed", className: "border-border bg-muted text-muted-foreground" },
|
|
48
|
+
untracked: { letter: "U", label: "never added to git", className: "border-dashed border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400" },
|
|
49
|
+
unknown: { letter: "?", label: "unknown", className: "border-border bg-muted text-muted-foreground" },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `apps/api/src/` + `task-diff.ts` — the eye finds a file by its name, not by its directory. */
|
|
53
|
+
function splitPath(path: string): { dir: string; name: string } {
|
|
54
|
+
const cut = path.lastIndexOf("/")
|
|
55
|
+
return cut === -1 ? { dir: "", name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Five squares of add/delete ratio, GitHub's idea, warm palette.
|
|
62
|
+
*
|
|
63
|
+
* It is worth the pixels because it is read *without* reading: a row of green says "new code", a
|
|
64
|
+
* row of red says "something was removed", and mixed says "rewritten" — before the numbers next
|
|
65
|
+
* to it have been parsed at all.
|
|
66
|
+
*/
|
|
67
|
+
function RatioSquares({ additions, deletions }: { additions: number; deletions: number }) {
|
|
68
|
+
const total = additions + deletions
|
|
69
|
+
// Nothing counted (a file git never saw, a binary blob) gets no squares at all: five grey ones
|
|
70
|
+
// look like a measurement that came back empty, when there was no measurement to make.
|
|
71
|
+
if (total === 0) return null
|
|
72
|
+
// Each side that exists keeps at least one square: a file that only deletes must not read as
|
|
73
|
+
// one green square, which is what proportional rounding alone does to it.
|
|
74
|
+
const green = deletions === 0 ? 5 : additions === 0 ? 0 : clamp(Math.round((additions / total) * 5), 1, 4)
|
|
75
|
+
const red = 5 - green
|
|
76
|
+
return (
|
|
77
|
+
<span className="flex shrink-0 gap-[2px]" aria-hidden>
|
|
78
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
79
|
+
<span
|
|
80
|
+
key={i}
|
|
81
|
+
className={cn(
|
|
82
|
+
"h-2 w-2 rounded-[2px]",
|
|
83
|
+
i < green ? "bg-emerald-500" : i < green + red ? "bg-destructive" : "bg-muted-foreground/20",
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `+248 −7`, with the minus sign that is a minus sign rather than a hyphen. */
|
|
92
|
+
function Counts({ additions, deletions, binary }: { additions: number | null; deletions: number | null; binary: boolean }) {
|
|
93
|
+
if (binary) return <span className="font-mono text-[11px] text-muted-foreground">binary</span>
|
|
94
|
+
return (
|
|
95
|
+
<span className="font-mono text-[11px] tabular-nums">
|
|
96
|
+
{additions !== null && additions > 0 && <span className="text-emerald-600 dark:text-emerald-400">+{additions}</span>}
|
|
97
|
+
{additions !== null && additions > 0 && deletions !== null && deletions > 0 && " "}
|
|
98
|
+
{deletions !== null && deletions > 0 && <span className="text-destructive">−{deletions}</span>}
|
|
99
|
+
{(additions ?? 0) === 0 && (deletions ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
|
100
|
+
</span>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The span that actually changed inside a pair of lines.
|
|
106
|
+
*
|
|
107
|
+
* Common prefix and suffix, nothing cleverer: a real word diff would need a token model per
|
|
108
|
+
* language, and this catches the case that matters — one identifier, one string, one number
|
|
109
|
+
* changed in a long line — without pretending to understand the code. Returns `null` when the
|
|
110
|
+
* lines share almost nothing, where highlighting "the middle" would just be noise.
|
|
111
|
+
*/
|
|
112
|
+
function changedSpan(a: string, b: string): { start: number; endA: number; endB: number } | null {
|
|
113
|
+
if (!a || !b) return null
|
|
114
|
+
let start = 0
|
|
115
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++
|
|
116
|
+
let end = 0
|
|
117
|
+
while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
|
|
118
|
+
const endA = a.length - end
|
|
119
|
+
const endB = b.length - end
|
|
120
|
+
if (endA <= start && endB <= start) return null
|
|
121
|
+
// Shared edges shorter than a fifth of the line: these are two different lines, not an edit.
|
|
122
|
+
if (start + end < Math.min(a.length, b.length) / 5) return null
|
|
123
|
+
return { start, endA, endB }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function LineText({ text, span, side }: { text: string; span: { start: number; endA: number; endB: number } | null; side: "del" | "add" }) {
|
|
127
|
+
if (!span) return <>{text || " "}</>
|
|
128
|
+
const end = side === "del" ? span.endA : span.endB
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
{text.slice(0, span.start)}
|
|
132
|
+
<mark
|
|
133
|
+
className={cn(
|
|
134
|
+
"rounded-[2px] bg-transparent px-0 text-inherit",
|
|
135
|
+
side === "del" ? "bg-destructive/25" : "bg-emerald-500/25",
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{text.slice(span.start, end)}
|
|
139
|
+
</mark>
|
|
140
|
+
{text.slice(end)}
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One rendered hunk: the `@@` label, then the lines with both gutters. */
|
|
146
|
+
function HunkBlock({ hunk }: { hunk: Hunk }) {
|
|
147
|
+
// Pair a run of deletions with the run of additions right after it, so line *n* of one is
|
|
148
|
+
// compared with line *n* of the other — which is what a human assumes is being shown.
|
|
149
|
+
const spans = useMemo(() => {
|
|
150
|
+
const out = new Map<number, { start: number; endA: number; endB: number } | null>()
|
|
151
|
+
for (let i = 0; i < hunk.lines.length; i++) {
|
|
152
|
+
if (hunk.lines[i].kind !== "del") continue
|
|
153
|
+
let dels = 0
|
|
154
|
+
while (hunk.lines[i + dels]?.kind === "del") dels++
|
|
155
|
+
let adds = 0
|
|
156
|
+
while (hunk.lines[i + dels + adds]?.kind === "add") adds++
|
|
157
|
+
if (dels === adds) {
|
|
158
|
+
for (let k = 0; k < dels; k++) {
|
|
159
|
+
const span = changedSpan(hunk.lines[i + k].text, hunk.lines[i + dels + k].text)
|
|
160
|
+
out.set(i + k, span)
|
|
161
|
+
out.set(i + dels + k, span)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
i += dels + adds - 1
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}, [hunk])
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="border-t border-border/60 first:border-t-0">
|
|
171
|
+
<div className="flex items-baseline gap-2 bg-muted/40 px-3 py-1 font-mono text-[10px] text-muted-foreground">
|
|
172
|
+
<span>{hunk.header}</span>
|
|
173
|
+
{hunk.context && <span className="truncate opacity-70">{hunk.context}</span>}
|
|
174
|
+
</div>
|
|
175
|
+
<div className="overflow-x-auto">
|
|
176
|
+
<table className="w-full border-collapse font-mono text-[11.5px] leading-[1.55]">
|
|
177
|
+
<tbody>
|
|
178
|
+
{hunk.lines.map((line, i) => (
|
|
179
|
+
<tr
|
|
180
|
+
key={i}
|
|
181
|
+
className={cn(
|
|
182
|
+
line.kind === "add" && "bg-emerald-500/[0.07]",
|
|
183
|
+
line.kind === "del" && "bg-destructive/[0.06]",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<td className="w-[1%] select-none whitespace-nowrap border-r border-border/50 px-2 text-right text-[10px] tabular-nums text-muted-foreground/70">
|
|
187
|
+
{line.old ?? ""}
|
|
188
|
+
</td>
|
|
189
|
+
<td className="w-[1%] select-none whitespace-nowrap border-r border-border/50 px-2 text-right text-[10px] tabular-nums text-muted-foreground/70">
|
|
190
|
+
{line.new ?? ""}
|
|
191
|
+
</td>
|
|
192
|
+
<td
|
|
193
|
+
className={cn(
|
|
194
|
+
"w-[1%] select-none border-r-2 px-1.5 text-center text-[10px]",
|
|
195
|
+
line.kind === "add" && "border-r-emerald-500/60 text-emerald-600 dark:text-emerald-400",
|
|
196
|
+
line.kind === "del" && "border-r-destructive/60 text-destructive",
|
|
197
|
+
line.kind === "context" && "border-r-transparent text-transparent",
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
{line.kind === "add" ? "+" : line.kind === "del" ? "−" : "·"}
|
|
201
|
+
</td>
|
|
202
|
+
<td className="whitespace-pre px-3">
|
|
203
|
+
{line.kind === "context" ? (
|
|
204
|
+
<span className="text-muted-foreground">{line.text || " "}</span>
|
|
205
|
+
) : (
|
|
206
|
+
<LineText text={line.text} span={spans.get(i) ?? null} side={line.kind === "add" ? "add" : "del"} />
|
|
207
|
+
)}
|
|
208
|
+
</td>
|
|
209
|
+
</tr>
|
|
210
|
+
))}
|
|
211
|
+
</tbody>
|
|
212
|
+
</table>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function patchText(patch: FilePatch): string {
|
|
219
|
+
return patch.hunks
|
|
220
|
+
.flatMap((hunk) => [
|
|
221
|
+
`${hunk.header}${hunk.context ? ` ${hunk.context}` : ""}`,
|
|
222
|
+
...hunk.lines.map((l) => `${l.kind === "add" ? "+" : l.kind === "del" ? "-" : " "}${l.text}`),
|
|
223
|
+
])
|
|
224
|
+
.join("\n")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type PatchState = { status: "loading" } | { status: "loaded"; patch: FilePatch } | { status: "error"; message: string }
|
|
228
|
+
|
|
229
|
+
function FileRow({
|
|
230
|
+
file,
|
|
231
|
+
open,
|
|
232
|
+
onToggle,
|
|
233
|
+
patchState,
|
|
234
|
+
}: {
|
|
235
|
+
file: DiffFile
|
|
236
|
+
open: boolean
|
|
237
|
+
onToggle: () => void
|
|
238
|
+
patchState: PatchState | undefined
|
|
239
|
+
}) {
|
|
240
|
+
const style = STATUS_STYLE[file.status] ?? STATUS_STYLE.unknown
|
|
241
|
+
const { dir, name } = splitPath(file.path)
|
|
242
|
+
const [copied, setCopied] = useState(false)
|
|
243
|
+
const patch = patchState?.status === "loaded" ? patchState.patch : undefined
|
|
244
|
+
const isLoading = !patchState || patchState.status === "loading"
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div className="border-b border-border/60 last:border-b-0">
|
|
248
|
+
<div className="group flex items-center gap-2 px-3 py-1.5 hover:bg-muted/40">
|
|
249
|
+
<button type="button" onClick={onToggle} className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
|
250
|
+
<ChevronRightIcon className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
|
|
251
|
+
<span
|
|
252
|
+
title={style.label}
|
|
253
|
+
className={cn("flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[9px] font-semibold", style.className)}
|
|
254
|
+
>
|
|
255
|
+
{style.letter}
|
|
256
|
+
</span>
|
|
257
|
+
<span className="min-w-0 truncate text-xs">
|
|
258
|
+
<span className="text-muted-foreground">{dir}</span>
|
|
259
|
+
<span className="font-medium">{name}</span>
|
|
260
|
+
{file.oldPath && (
|
|
261
|
+
<span className="ml-1.5 text-[10px] text-muted-foreground">← {splitPath(file.oldPath).name}</span>
|
|
262
|
+
)}
|
|
263
|
+
</span>
|
|
264
|
+
</button>
|
|
265
|
+
<Counts additions={file.additions} deletions={file.deletions} binary={!!file.binary} />
|
|
266
|
+
{!file.binary && <RatioSquares additions={file.additions ?? 0} deletions={file.deletions ?? 0} />}
|
|
267
|
+
{open && patch && !patch.binary && patch.hunks.length > 0 && (
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
title="Copy this patch"
|
|
271
|
+
onClick={() => {
|
|
272
|
+
navigator.clipboard?.writeText(patchText(patch))
|
|
273
|
+
setCopied(true)
|
|
274
|
+
setTimeout(() => setCopied(false), 1500)
|
|
275
|
+
}}
|
|
276
|
+
className="shrink-0 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
|
277
|
+
>
|
|
278
|
+
{copied ? "copied" : <CopyIcon className="h-3 w-3" />}
|
|
279
|
+
</button>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{open && (
|
|
284
|
+
<div className="border-t border-border/60 bg-background">
|
|
285
|
+
{isLoading && <p className="px-3 py-2 text-[11px] text-muted-foreground">Reading the file in the worker…</p>}
|
|
286
|
+
{patchState?.status === "error" && (
|
|
287
|
+
<p className="px-3 py-2 text-[11px] text-destructive">{patchState.message}</p>
|
|
288
|
+
)}
|
|
289
|
+
{patch?.binary && <p className="px-3 py-2 text-[11px] text-muted-foreground">Binary file — nothing to read line by line.</p>}
|
|
290
|
+
{patch && !patch.binary && patch.hunks.length === 0 && (
|
|
291
|
+
<p className="px-3 py-2 text-[11px] text-muted-foreground">
|
|
292
|
+
{file.status === "deleted" ? "File deleted." : "No textual change to show."}
|
|
293
|
+
</p>
|
|
294
|
+
)}
|
|
295
|
+
{patch?.hunks.map((hunk, i) => <HunkBlock key={i} hunk={hunk} />)}
|
|
296
|
+
{patch?.truncated && (
|
|
297
|
+
<p className="border-t border-border/60 px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400">
|
|
298
|
+
This file is larger than what a patch is read in — the end is not shown.
|
|
299
|
+
</p>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The whole-change bar: one strip where additions and deletions are to scale against each other. */
|
|
308
|
+
function TotalsBar({ additions, deletions }: { additions: number; deletions: number }) {
|
|
309
|
+
const total = additions + deletions
|
|
310
|
+
if (total === 0) return null
|
|
311
|
+
return (
|
|
312
|
+
<div className="flex h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
313
|
+
<div className="bg-emerald-500/80" style={{ width: `${(additions / total) * 100}%` }} />
|
|
314
|
+
<div className="bg-destructive/70" style={{ width: `${(deletions / total) * 100}%` }} />
|
|
315
|
+
</div>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const UNAVAILABLE: Record<string, { title: string; detail: string }> = {
|
|
320
|
+
"no-machine": {
|
|
321
|
+
title: "No machine to read",
|
|
322
|
+
detail: "This task never got a worker, so there is no checkout to compare. Nothing was lost — there is simply nothing to look at yet.",
|
|
323
|
+
},
|
|
324
|
+
"machine-stopped": {
|
|
325
|
+
title: "The machine is asleep",
|
|
326
|
+
detail: "This project parks a task's worker while it waits for review. Start it again — the Machine panel does it in one click — and the diff is readable.",
|
|
327
|
+
},
|
|
328
|
+
"node-unreachable": {
|
|
329
|
+
title: "The node is not answering",
|
|
330
|
+
detail: "The compute node hosting this worker is offline right now. The diff is in the machine, not in Spunto, so it comes back when the node does.",
|
|
331
|
+
},
|
|
332
|
+
unsupported: {
|
|
333
|
+
title: "This node's agent is too old",
|
|
334
|
+
detail: "Reading a diff needs the worker agent's command execution. Update the agent on this node.",
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface TaskDiffPanelProps {
|
|
339
|
+
/** The summary: files, counts, per repository. `undefined`/`null` while the first read is in flight. */
|
|
340
|
+
diff: TaskDiff | null | undefined
|
|
341
|
+
/** First read in flight — "Asking the machine what changed…". */
|
|
342
|
+
loading?: boolean
|
|
343
|
+
/** A re-read in flight — spins the refresh button. */
|
|
344
|
+
fetching?: boolean
|
|
345
|
+
/** The refresh button. Omitted → no button. */
|
|
346
|
+
onRefresh?: () => void
|
|
347
|
+
/**
|
|
348
|
+
* One file's patch, fetched when the file is first opened and kept while its counts don't
|
|
349
|
+
* change. The component owns the per-file loading/error state; the app only fetches (wrap it
|
|
350
|
+
* in the app's query cache to share it across mounts).
|
|
351
|
+
*/
|
|
352
|
+
loadPatch: (args: { repo: string; file: TaskDiffFile }) => Promise<TaskFilePatch>
|
|
353
|
+
/** Rendered where the panel's title would be — the cockpit puts the Session/Diff switch there. */
|
|
354
|
+
tabs?: ReactNode
|
|
355
|
+
className?: string
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Identity of a file's *content*: new counts mean a new patch, the old one is stale. */
|
|
359
|
+
function patchKey(repo: string, file: DiffFile): string {
|
|
360
|
+
return `${repo}:${file.path}:${file.additions ?? "-"}:${file.deletions ?? "-"}`
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function TaskDiffPanel({ diff: data, loading: isLoading = false, fetching: isFetching = false, onRefresh, loadPatch, tabs, className }: TaskDiffPanelProps) {
|
|
364
|
+
const [open, setOpen] = useState<Set<string>>(new Set())
|
|
365
|
+
const [filter, setFilter] = useState("")
|
|
366
|
+
const [patches, setPatches] = useState<Record<string, PatchState>>({})
|
|
367
|
+
const requested = useRef(new Set<string>())
|
|
368
|
+
const loadPatchRef = useRef(loadPatch)
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
loadPatchRef.current = loadPatch
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// Every open file whose patch has not been asked for yet — opened one by one, or all at once
|
|
374
|
+
// by "Expand all". Tracked in a ref so a re-render mid-flight never asks twice.
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (!data?.available) return
|
|
377
|
+
for (const repo of data.repos) {
|
|
378
|
+
for (const file of repo.files) {
|
|
379
|
+
if (!open.has(`${repo.name}:${file.path}`)) continue
|
|
380
|
+
const key = patchKey(repo.name, file)
|
|
381
|
+
if (requested.current.has(key)) continue
|
|
382
|
+
requested.current.add(key)
|
|
383
|
+
setPatches((prev) => ({ ...prev, [key]: { status: "loading" } }))
|
|
384
|
+
loadPatchRef
|
|
385
|
+
.current({ repo: repo.name, file })
|
|
386
|
+
.then((patch) => setPatches((prev) => ({ ...prev, [key]: { status: "loaded", patch } })))
|
|
387
|
+
.catch((error: unknown) => {
|
|
388
|
+
requested.current.delete(key)
|
|
389
|
+
setPatches((prev) => ({
|
|
390
|
+
...prev,
|
|
391
|
+
[key]: { status: "error", message: error instanceof Error ? error.message : "Could not read this file." },
|
|
392
|
+
}))
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}, [open, data])
|
|
397
|
+
|
|
398
|
+
const toggle = (key: string) =>
|
|
399
|
+
setOpen((previous) => {
|
|
400
|
+
const next = new Set(previous)
|
|
401
|
+
if (next.has(key)) next.delete(key)
|
|
402
|
+
else next.add(key)
|
|
403
|
+
return next
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const needle = filter.trim().toLowerCase()
|
|
407
|
+
const repos = (data?.repos ?? []).map((repo) => ({
|
|
408
|
+
...repo,
|
|
409
|
+
files: needle ? repo.files.filter((f) => f.path.toLowerCase().includes(needle)) : repo.files,
|
|
410
|
+
}))
|
|
411
|
+
const showFilter = (data?.files ?? 0) > 8
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<div className={cn("flex min-h-0 flex-1 flex-col", className)}>
|
|
415
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2">
|
|
416
|
+
{tabs ?? <h2 className="text-xs font-medium">Diff</h2>}
|
|
417
|
+
{data?.available && (
|
|
418
|
+
<span className="truncate font-mono text-[11px] text-muted-foreground">
|
|
419
|
+
{data.repos[0]?.base ? `vs ${data.repos[0].base}` : data.baseBranch ? `vs ${data.baseBranch}` : ""}
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
{onRefresh && (
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
onClick={onRefresh}
|
|
426
|
+
title="Read it again"
|
|
427
|
+
className="ml-auto shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
|
428
|
+
>
|
|
429
|
+
<RefreshCwIcon className={cn("h-3 w-3", isFetching && "animate-spin")} />
|
|
430
|
+
</button>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
435
|
+
{isLoading && !data && <p className="p-4 text-xs text-muted-foreground">Asking the machine what changed…</p>}
|
|
436
|
+
|
|
437
|
+
{data && !data.available && (
|
|
438
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
439
|
+
<div className="max-w-sm text-center">
|
|
440
|
+
<PowerIcon className="mx-auto mb-2 h-5 w-5 text-muted-foreground" />
|
|
441
|
+
<p className="text-xs font-medium">{UNAVAILABLE[data.reason ?? ""]?.title ?? "Nothing to read"}</p>
|
|
442
|
+
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
|
|
443
|
+
{UNAVAILABLE[data.reason ?? ""]?.detail ?? "The diff could not be read."}
|
|
444
|
+
</p>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
)}
|
|
448
|
+
|
|
449
|
+
{data?.available && (
|
|
450
|
+
<>
|
|
451
|
+
<div className="space-y-2 border-b border-border bg-card/60 px-4 py-3">
|
|
452
|
+
<div className="flex items-center gap-2 text-xs">
|
|
453
|
+
<FileDiffIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
454
|
+
<span className="font-mono">{data.branch}</span>
|
|
455
|
+
<span className="text-muted-foreground">←</span>
|
|
456
|
+
<span className="font-mono text-muted-foreground">{data.repos[0]?.base || data.baseBranch || "base"}</span>
|
|
457
|
+
<span className="ml-auto shrink-0">
|
|
458
|
+
<Counts additions={data.additions} deletions={data.deletions} binary={false} />
|
|
459
|
+
</span>
|
|
460
|
+
</div>
|
|
461
|
+
<TotalsBar additions={data.additions} deletions={data.deletions} />
|
|
462
|
+
<div className="flex items-baseline gap-2">
|
|
463
|
+
<p className="text-[11px] text-muted-foreground">
|
|
464
|
+
{data.files === 0
|
|
465
|
+
? "No file differs from the base branch yet."
|
|
466
|
+
: `${data.files} file${data.files === 1 ? "" : "s"} changed · since the branch point, working tree included`}
|
|
467
|
+
</p>
|
|
468
|
+
{data.files > 0 && (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
onClick={() =>
|
|
472
|
+
setOpen((previous) =>
|
|
473
|
+
previous.size > 0
|
|
474
|
+
? new Set()
|
|
475
|
+
: new Set(repos.flatMap((r) => r.files.map((f) => `${r.name}:${f.path}`))),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground hover:text-foreground"
|
|
479
|
+
>
|
|
480
|
+
{open.size > 0 ? "Collapse all" : "Expand all"}
|
|
481
|
+
</button>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
{showFilter && (
|
|
485
|
+
<div className="flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1">
|
|
486
|
+
<SearchIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
487
|
+
<input
|
|
488
|
+
value={filter}
|
|
489
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
490
|
+
placeholder="Filter by path…"
|
|
491
|
+
className="w-full bg-transparent text-[11px] outline-none placeholder:text-muted-foreground"
|
|
492
|
+
/>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
{repos.map((repo) => (
|
|
498
|
+
<div key={repo.cwd ?? repo.name}>
|
|
499
|
+
{(data.repos.length > 1 || repo.error) && (
|
|
500
|
+
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-1.5">
|
|
501
|
+
<span className="font-mono text-[11px]">{repo.name}</span>
|
|
502
|
+
{repo.error ? (
|
|
503
|
+
<span className="truncate text-[11px] text-destructive">{repo.error}</span>
|
|
504
|
+
) : (
|
|
505
|
+
<span className="ml-auto">
|
|
506
|
+
<Counts additions={repo.additions} deletions={repo.deletions} binary={false} />
|
|
507
|
+
</span>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
{repo.files.map((file) => {
|
|
512
|
+
const key = `${repo.name}:${file.path}`
|
|
513
|
+
return (
|
|
514
|
+
<FileRow
|
|
515
|
+
key={key}
|
|
516
|
+
file={file}
|
|
517
|
+
open={open.has(key)}
|
|
518
|
+
onToggle={() => toggle(key)}
|
|
519
|
+
patchState={patches[patchKey(repo.name, file)]}
|
|
520
|
+
/>
|
|
521
|
+
)
|
|
522
|
+
})}
|
|
523
|
+
</div>
|
|
524
|
+
))}
|
|
525
|
+
|
|
526
|
+
{data.files > 0 && repos.every((r) => r.files.length === 0) && (
|
|
527
|
+
<p className="p-4 text-[11px] text-muted-foreground">No file matches “{filter}”.</p>
|
|
528
|
+
)}
|
|
529
|
+
</>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
)
|
|
534
|
+
}
|