@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.
@@ -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
+ }