← snapshot
28077 bytes
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api";
import { diffLines, statOf, withElisions, type DiffRow } from "@/lib/diff";
import {
applyJudgement,
describe,
editsOf,
groupFiles,
type DiffGroup,
type FileEdits,
type JudgedGroup,
type Judgement,
} from "@/lib/diff-group";
import { judgeGroups } from "@/lib/judge";
import { governing } from "@/lib/paths";
import type { ActorView, DecisionDto } from "@/lib/types";
import { pairChanges, type Span } from "@/lib/word-diff";
import { RemarkZone } from "./remarks";
/**
* A diff, read without leaving the tour.
*
* Jackie opens this over whatever she was showing you rather than navigating,
* because a walkthrough that dumps you on another page is not a walkthrough.
*
* The organising idea is that a bulk edit is one change, not N. A rename
* across a hundred and fifty files folds into a single entry describing the
* substitution once — and the fold is refused for any file that also changed
* something else, so nothing hides inside it. See `lib/diff-group.ts`.
*
* On colour: additions and removals sit deliberately outside the verdict
* palette. Madder means blocked, verdant means admitted, amber means
* ungoverned; a green line background meaning "added" would be one glance from
* meaning "this passed". Diffs use teal and plum, and the two vocabularies
* never share a shape — verdicts are pills, diffs are rows.
*/
interface FileDiff {
path: string;
rows: (DiffRow | null)[];
added: number;
removed: number;
governedBy: DecisionDto[];
edits: FileEdits;
/** Set when the path exists at only one end. */
note: string | null;
}
type Selection = { kind: "file"; path: string } | { kind: "group"; id: string };
/** Reading every path at once, without opening a hundred sockets. */
async function pooled<T>(items: string[], limit: number, run: (item: string) => Promise<T>) {
const out: T[] = [];
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const i = cursor;
cursor += 1;
const item = items[i];
if (item === undefined) return;
out.push(await run(item));
}
});
await Promise.all(workers);
return out;
}
async function readAt(
repo: string,
snapshot: string | null,
path: string,
): Promise<string | null> {
if (!snapshot) return null;
try {
const snap = await api.snapshot(repo, snapshot);
const file = snap.files.find((f) => f.path === path);
if (!file) return null;
const blob = await api.blob(repo, file.blob);
return blob.content ?? "";
} catch {
return null;
}
}
export function DiffModal({
repo,
fromSnapshot,
intoSnapshot,
paths,
initialPath,
decisions,
humans,
onClose,
}: {
repo: string;
/** The snapshot the change comes *from* (the newer side). */
fromSnapshot: string;
/** The snapshot it would land on (the older side). */
intoSnapshot: string | null;
paths: string[];
initialPath?: string;
decisions: DecisionDto[];
humans: ActorView[];
onClose: () => void;
}) {
const [files, setFiles] = useState<Record<string, FileDiff>>({});
const [progress, setProgress] = useState(0);
const [ready, setReady] = useState(false);
const [split, setSplit] = useState(false);
const [selection, setSelection] = useState<Selection | null>(
initialPath ? { kind: "file", path: initialPath } : null,
);
/** Row the keyboard is on, or -1 before anyone has pressed a key. */
const [cursor, setCursor] = useState(-1);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [showKeys, setShowKeys] = useState(false);
const bodyRef = useRef<HTMLDivElement | null>(null);
/* ---- read every path, then decide what folds ------------------------- */
useEffect(() => {
let alive = true;
setReady(false);
setProgress(0);
const collected: Record<string, FileDiff> = {};
void pooled(paths, 6, async (want) => {
const [before, after] = await Promise.all([
readAt(repo, intoSnapshot, want),
readAt(repo, fromSnapshot, want),
]);
const raw = diffLines(before ?? "", after ?? "");
const rows = withElisions(raw, 4);
const stat = statOf(raw);
collected[want] = {
path: want,
rows,
added: stat.added,
removed: stat.removed,
edits: editsOf(rows),
governedBy: governing(want, decisions),
note:
before === null && after !== null
? "added in this change"
: before !== null && after === null
? "removed in this change"
: null,
};
if (alive) setProgress((n) => n + 1);
}).then(() => {
if (!alive) return;
setFiles(collected);
setReady(true);
});
return () => {
alive = false;
};
}, [repo, paths, fromSnapshot, intoSnapshot, decisions]);
const [judgement, setJudgement] = useState<Judgement | null>(null);
const [judging, setJudging] = useState(false);
const deterministic = useMemo(
() => (ready ? groupFiles(paths, (p) => files[p]?.edits) : null),
[ready, paths, files],
);
/* ---- the judge: advisory, and constrained ----------------------------
* Deterministic folding cannot see that two spellings are one change. A
* model can. Everything it says is filtered by `applyJudgement`, which
* refuses invented ids and never lets a lone file be absorbed.
*/
useEffect(() => {
if (!deterministic || deterministic.groups.length < 2) return;
let alive = true;
setJudging(true);
void judgeGroups(deterministic.groups)
.then((j) => {
if (alive) setJudgement(j);
})
.finally(() => {
if (alive) setJudging(false);
});
return () => {
alive = false;
};
}, [deterministic]);
const grouping = useMemo(
() => (deterministic ? applyJudgement(deterministic, judgement) : null),
[deterministic, judgement],
);
/**
* Keeps the selection pointing at something real.
*
* A judgement merges folds under a new id, which strands a selection made
* against one of the originals. Follow it into whatever absorbed it rather
* than dumping the reader back at the top — they were reading that change,
* and it still exists, just under one heading now.
*/
useEffect(() => {
if (!grouping) return;
if (selection?.kind === "file") return;
const stillThere =
selection?.kind === "group" &&
grouping.groups.some((g) => g.id === selection.id);
if (stillThere) return;
const absorbed =
selection?.kind === "group"
? grouping.groups.find((g) =>
g.judged?.from.some((f) => f.id === selection.id),
)
: undefined;
const next = absorbed ?? grouping.groups[0];
setSelection(
next
? { kind: "group", id: next.id }
: grouping.singles[0]
? { kind: "file", path: grouping.singles[0] }
: null,
);
}, [grouping, selection]);
useEffect(() => {
bodyRef.current?.scrollTo({ top: 0 });
setCursor(-1);
setExpanded(new Set());
}, [selection]);
/** Every entry in the rail, in the order it is shown. */
const entries: Selection[] = useMemo(() => {
if (!grouping) return paths.map((p) => ({ kind: "file", path: p }) as Selection);
return [
...grouping.groups.map((g) => ({ kind: "group", id: g.id }) as Selection),
...grouping.singles.map((p) => ({ kind: "file", path: p }) as Selection),
];
}, [grouping, paths]);
const step = useCallback(
(delta: number) => {
setSelection((cur) => {
const key = (s: Selection) => (s.kind === "file" ? `f:${s.path}` : `g:${s.id}`);
const i = cur ? entries.findIndex((e) => key(e) === key(cur)) : -1;
const next = Math.max(0, Math.min(entries.length - 1, i + delta));
return entries[next] ?? cur;
});
},
[entries],
);
const group =
selection?.kind === "group"
? (grouping?.groups.find((g) => g.id === selection.id) ?? null)
: null;
const file = selection?.kind === "file" ? files[selection.path] : undefined;
/* ---- keyboard navigation --------------------------------------------
* A diff is a reading surface, and reading with a mouse wheel is how you
* miss the third hunk. `n`/`p` jump between runs of changed lines, which is
* the unit a reviewer actually moves in; `j`/`k` step a line at a time when
* something needs a closer look.
*/
const rows: (DiffRow | null)[] = useMemo(() => {
if (group) return files[group.paths[0] as string]?.rows ?? [];
return file?.rows ?? [];
}, [group, file, files]);
/** First row of each run of changed lines. */
const hunks = useMemo(() => {
const out: number[] = [];
let inRun = false;
rows.forEach((r, i) => {
const changed = r !== null && r.kind !== "ctx";
if (changed && !inRun) out.push(i);
inRun = changed;
});
return out;
}, [rows]);
const moveTo = useCallback((next: number) => {
setCursor(next);
// Centre it: a cursor pinned to the viewport edge hides its own context.
requestAnimationFrame(() => {
document
.querySelector(`[data-row="${next}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if ((e.target as HTMLElement | null)?.closest?.("input, textarea, select")) return;
if (e.metaKey || e.ctrlKey) return;
const line = (delta: number) => {
e.preventDefault();
const from = cursor < 0 ? (delta > 0 ? -1 : rows.length) : cursor;
moveTo(Math.max(0, Math.min(rows.length - 1, from + delta)));
};
const hunk = (delta: number) => {
e.preventDefault();
if (hunks.length === 0) return;
const at = hunks.findIndex((h) => (delta > 0 ? h > cursor : h >= cursor));
const next =
delta > 0
? (hunks[at === -1 ? 0 : at] ?? hunks[0])
: (hunks[(at === -1 ? hunks.length : at) - 1] ?? hunks[hunks.length - 1]);
moveTo(next as number);
};
switch (e.key) {
case "]":
step(1);
break;
case "[":
step(-1);
break;
case "s":
setSplit((v) => !v);
break;
case "n":
hunk(1);
break;
case "p":
hunk(-1);
break;
case "j":
case "ArrowDown":
if (e.altKey) step(1);
else line(1);
break;
case "k":
case "ArrowUp":
if (e.altKey) step(-1);
else line(-1);
break;
case "ArrowRight":
if (e.altKey) step(1);
break;
case "ArrowLeft":
if (e.altKey) step(-1);
break;
case "g":
moveTo(0);
break;
case "G":
moveTo(rows.length - 1);
break;
case "e":
// Expand the collapsed stretch the cursor is sitting on.
if (rows[cursor] === null) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(cursor)) next.delete(cursor);
else next.add(cursor);
return next;
});
}
break;
case "?":
setShowKeys((v) => !v);
break;
default:
break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, step, cursor, rows, hunks, moveTo]);
const folded = grouping?.groups.reduce((n, g) => n + g.paths.length, 0) ?? 0;
return (
<div
className="jac-diffm-scrim"
role="dialog"
aria-modal="true"
aria-label="diff"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="jac-diffm">
<header className="jac-diffm-head">
<div className="jac-diffm-title">
{group ? (
<span className="jac-diffm-path">
<b>{group.paths.length} files</b>
<span className="jac-diffm-path-dir">
{" "}
· one change, shown once
</span>
</span>
) : file ? (
<>
<PathName path={file.path} />
<StatStrip added={file.added} removed={file.removed} />
</>
) : (
<span className="jac-diffm-path jac-diffm-path-dir">
{ready ? "nothing selected" : `reading ${progress}/${paths.length}…`}
</span>
)}
</div>
<div className="jac-diffm-govern">
<Governed decisions={file?.governedBy ?? groupGovern(group, files)} />
</div>
<div className="jac-diffm-tools">
<div className="jac-seg">
<button type="button" data-on={!split} onClick={() => setSplit(false)}>
unified
</button>
<button type="button" data-on={split} onClick={() => setSplit(true)}>
split
</button>
</div>
<button type="button" className="jac-diffm-close" onClick={onClose}>
Close <kbd>esc</kbd>
</button>
</div>
</header>
<div className="jac-diffm-main">
{entries.length > 1 ? (
<nav className="jac-diffm-rail" aria-label="what changed">
<p className="jac-diffm-rail-label">
{paths.length} paths
{folded > 0 ? ` · ${folded} folded` : ""}
{judging ? " · judging…" : ""}
</p>
{grouping?.groups.map((g) => (
<button
key={g.id}
type="button"
className="jac-diffm-file jac-diffm-fold"
data-judged={Boolean(g.judged)}
data-on={selection?.kind === "group" && selection.id === g.id}
onClick={() => setSelection({ kind: "group", id: g.id })}
>
<span className="jac-diffm-fold-badge">×{g.paths.length}</span>
<span className="jac-diffm-file-name">
{g.judged?.label ?? describe(g.substitutions[0] as never)}
</span>
<span className="jac-diffm-file-dir">
{g.judged
? `${g.judged.from.length} spellings · proposed`
: g.substitutions.length > 1
? `${g.substitutions.length} substitutions`
: `${g.substitutions[0]?.count ?? 0} occurrences`}
</span>
</button>
))}
{grouping?.singles.map((p) => {
const f = files[p];
return (
<button
key={p}
type="button"
className="jac-diffm-file"
data-on={selection?.kind === "file" && selection.path === p}
onClick={() => setSelection({ kind: "file", path: p })}
>
<span className="jac-diffm-file-name">{p.split("/").pop()}</span>
<span className="jac-diffm-file-dir">
{p.split("/").slice(0, -1).join("/")}
</span>
{f ? (
<span className="jac-diffm-file-stat">
<b>+{f.added}</b>
<i>−{f.removed}</i>
</span>
) : null}
</button>
);
})}
</nav>
) : null}
<div className="jac-diffm-body" ref={bodyRef}>
{!ready ? (
<p className="jac-diffm-empty">
reading {progress}/{paths.length} paths…
</p>
) : group ? (
<GroupView
group={group}
files={files}
repo={repo}
humans={humans}
split={split}
cursor={cursor}
expanded={expanded}
onToggleGap={(i) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
})
}
onOpenFile={(p) => setSelection({ kind: "file", path: p })}
/>
) : file ? (
<RemarkZone
repo={repo}
anchor={{ kind: "file", id: file.path }}
humans={humans}
>
{file.note ? <p className="jac-diffm-note">{file.note}</p> : null}
{file.added === 0 && file.removed === 0 ? (
<p className="jac-diffm-note">
No lines changed here — shown for context.
</p>
) : null}
<DiffTable
rows={file.rows}
split={split}
cursor={cursor}
expanded={expanded}
onToggle={(i) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
})
}
/>
</RemarkZone>
) : (
<p className="jac-diffm-empty">nothing to show.</p>
)}
</div>
</div>
<footer className="jac-diffm-foot">
<span className="jac-small">
<kbd>n</kbd> <kbd>p</kbd> hunk · <kbd>j</kbd> <kbd>k</kbd> line ·{" "}
<kbd>[</kbd> <kbd>]</kbd> file · <kbd>?</kbd> keys
</span>
<span className="jac-spacer" />
<span className="jac-small">
select a line and press <kbd>⌥S</kbd> to say something about it
</span>
</footer>
{showKeys ? (
<div className="jac-diffm-keys" onClick={() => setShowKeys(false)}>
<dl>
<dt>
<kbd>n</kbd> <kbd>p</kbd>
</dt>
<dd>next / previous hunk — the unit a reviewer moves in</dd>
<dt>
<kbd>j</kbd> <kbd>k</kbd> <kbd>↓</kbd> <kbd>↑</kbd>
</dt>
<dd>one line at a time</dd>
<dt>
<kbd>g</kbd> <kbd>G</kbd>
</dt>
<dd>first / last line</dd>
<dt>
<kbd>[</kbd> <kbd>]</kbd>
</dt>
<dd>previous / next file or fold</dd>
<dt>
<kbd>e</kbd>
</dt>
<dd>expand the collapsed stretch under the cursor</dd>
<dt>
<kbd>s</kbd>
</dt>
<dd>unified / split</dd>
<dt>
<kbd>⌥S</kbd>
</dt>
<dd>say something about the selected text</dd>
<dt>
<kbd>esc</kbd>
</dt>
<dd>close</dd>
</dl>
</div>
) : null}
</div>
</div>
);
}
/** Decisions governing a whole fold — the union across its paths. */
function groupGovern(
group: JudgedGroup | null,
files: Record<string, FileDiff>,
): DecisionDto[] {
if (!group) return [];
const seen = new Map<string, DecisionDto>();
for (const p of group.paths) {
for (const d of files[p]?.governedBy ?? []) seen.set(d.id, d);
}
return [...seen.values()];
}
function Governed({ decisions }: { decisions: DecisionDto[] }) {
if (decisions.length === 0) {
return (
<span className="jac-tag jac-tag--warn" title="no decision claims this path">
ungoverned
</span>
);
}
return (
<>
{decisions.map((d) => (
<span
key={d.id}
className={`jac-tag${d.state === "unsettled" ? " jac-tag--warn" : ""}`}
title={d.title}
>
{d.state === "unsettled" ? "⚠ unsettled" : "settled"} · {d.title}
</span>
))}
</>
);
}
/**
* One fold: the substitution described once, one representative diff, and the
* list of everything that received exactly it.
*/
function GroupView({
group,
files,
repo,
humans,
split,
cursor,
expanded,
onToggleGap,
onOpenFile,
}: {
group: JudgedGroup;
files: Record<string, FileDiff>;
repo: string;
humans: ActorView[];
split: boolean;
cursor: number;
expanded: Set<number>;
onToggleGap: (index: number) => void;
onOpenFile: (path: string) => void;
}) {
const sample = files[group.paths[0] as string];
return (
<RemarkZone repo={repo} anchor={{ kind: "file", id: group.paths[0] as string }} humans={humans}>
{group.judged ? (
<div className="jac-diffm-judged">
<p className="jac-panel-label">
Grouped by a model — {group.judged.from.length} spellings
</p>
<p className="jac-diffm-judged-why">{group.judged.why}</p>
<p className="jac-small">
A machine's reading, not a proof. The folds below were each
established by exact match; only the claim that they are{" "}
<em>one change</em> came from the model. Nothing was hidden to make
it fit — a file whose changes were not fully accounted for is still
listed on its own.
</p>
</div>
) : null}
<div className="jac-diffm-subs">
{group.substitutions.map((s) => (
<div key={`${s.before}->${s.after}`} className="jac-diffm-sub">
<code className="jac-diffm-sub-before">{s.before.trim() || "␠"}</code>
<span className="jac-diffm-sub-arrow">→</span>
<code className="jac-diffm-sub-after">{s.after.trim() || "␠"}</code>
<span className="jac-diffm-sub-count">
{s.count} {s.count === 1 ? "line" : "lines"}
</span>
</div>
))}
</div>
<p className="jac-diffm-note">
Shown once. Every file below received exactly this and nothing else — a
file with any other change is listed separately, never folded in.
</p>
{sample ? (
<>
<p className="jac-diffm-sample-label">
as it appears in <b>{sample.path}</b>
</p>
<DiffTable
rows={sample.rows}
split={split}
cursor={cursor}
expanded={expanded}
onToggle={onToggleGap}
/>
</>
) : null}
<ul className="jac-diffm-members">
{group.paths.map((p) => {
const f = files[p];
return (
<li key={p}>
<button type="button" onClick={() => onOpenFile(p)}>
<span className="jac-diffm-member-path">{p}</span>
{f ? (
<span className="jac-diffm-file-stat">
<b>+{f.added}</b>
<i>−{f.removed}</i>
</span>
) : null}
</button>
</li>
);
})}
</ul>
</RemarkZone>
);
}
function DiffTable({
rows,
split,
cursor,
expanded,
onToggle,
}: {
rows: (DiffRow | null)[];
split: boolean;
/** Row the keyboard is on, or -1 when nobody has pressed a key. */
cursor: number;
expanded: Set<number>;
onToggle: (index: number) => void;
}) {
const spans = useMemo(() => pairChanges(rows), [rows]);
return (
<table className="jac-diffm-table" data-split={split}>
<tbody>
{rows.map((row, i) =>
row === null ? (
<Elision
key={`gap-${i}`}
index={i}
open={expanded.has(i)}
cursor={cursor === i}
onToggle={() => onToggle(i)}
/>
) : split ? (
<SplitRow
key={`r-${i}`}
index={i}
row={row}
spans={spans.get(i)}
cursor={cursor === i}
/>
) : (
<UnifiedRow
key={`r-${i}`}
index={i}
row={row}
spans={spans.get(i)}
cursor={cursor === i}
/>
),
)}
</tbody>
</table>
);
}
/** Directory dimmed, filename bright — the eye wants the leaf. */
function PathName({ path }: { path: string }) {
const parts = path.split("/");
const name = parts.pop();
return (
<span className="jac-diffm-path">
{parts.length ? <span className="jac-diffm-path-dir">{parts.join("/")}/</span> : null}
<b>{name}</b>
</span>
);
}
/** The proportional bar every reviewer reads before the lines themselves. */
function StatStrip({ added, removed }: { added: number; removed: number }) {
const total = Math.max(1, added + removed);
const blocks = 12;
const filled = Math.round((added / total) * blocks);
return (
<span className="jac-diffm-stat">
<b>+{added}</b>
<i>−{removed}</i>
<span className="jac-diffm-bar" aria-hidden="true">
{Array.from({ length: blocks }, (_, i) => (
<span key={i} data-kind={i < filled ? "add" : "del"} />
))}
</span>
</span>
);
}
function Elision({
index,
open,
cursor,
onToggle,
}: {
index: number;
open: boolean;
cursor: boolean;
onToggle: () => void;
}) {
return (
<tr className="jac-diffm-gap" data-row={index} data-cursor={cursor}>
<td colSpan={4}>
<button type="button" onClick={onToggle}>
{open ? "hide unchanged" : "unchanged lines"}
{cursor ? <kbd>e</kbd> : null}
</button>
</td>
</tr>
);
}
function Text({ text, spans }: { text: string; spans?: Span[] }) {
if (!spans) return <>{text || " "}</>;
return (
<>
{spans.map((s, i) =>
s.changed ? (
<mark key={i} className="jac-diffm-word">
{s.text}
</mark>
) : (
<span key={i}>{s.text}</span>
),
)}
</>
);
}
function UnifiedRow({
index,
row,
spans,
cursor,
}: {
index: number;
row: DiffRow;
spans?: Span[];
cursor: boolean;
}) {
return (
<tr className="jac-diffm-row" data-kind={row.kind} data-row={index} data-cursor={cursor}>
<td className="jac-diffm-ln">{row.a ?? ""}</td>
<td className="jac-diffm-ln">{row.b ?? ""}</td>
<td className="jac-diffm-mark" aria-hidden="true">
{row.kind === "add" ? "+" : row.kind === "del" ? "−" : ""}
</td>
<td className="jac-diffm-text">
<Text text={row.text} spans={spans} />
</td>
</tr>
);
}
function SplitRow({
index,
row,
spans,
cursor,
}: {
index: number;
row: DiffRow;
spans?: Span[];
cursor: boolean;
}) {
const left = row.kind !== "add";
const right = row.kind !== "del";
return (
<tr
className="jac-diffm-row"
data-kind={row.kind}
data-split="1"
data-row={index}
data-cursor={cursor}
>
<td className="jac-diffm-ln">{row.a ?? ""}</td>
<td className="jac-diffm-text" data-side="a" data-kind={left ? row.kind : "none"}>
{left ? <Text text={row.text} spans={spans} /> : null}
</td>
<td className="jac-diffm-ln">{row.b ?? ""}</td>
<td className="jac-diffm-text" data-side="b" data-kind={right ? row.kind : "none"}>
{right ? <Text text={row.text} spans={spans} /> : null}
</td>
</tr>
);
}