← snapshot
21957 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,
);
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 });
}, [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],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if ((e.target as HTMLElement | null)?.closest?.("input, textarea, select")) return;
if (e.key === "]" || (e.altKey && e.key === "ArrowRight")) step(1);
else if (e.key === "[" || (e.altKey && e.key === "ArrowLeft")) step(-1);
else if (e.key === "s" && !e.altKey && !e.metaKey) setSplit((v) => !v);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, step]);
const group =
selection?.kind === "group"
? (grouping?.groups.find((g) => g.id === selection.id) ?? null)
: null;
const file = selection?.kind === "file" ? files[selection.path] : undefined;
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}
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} />
</RemarkZone>
) : (
<p className="jac-diffm-empty">nothing to show.</p>
)}
</div>
</div>
<footer className="jac-diffm-foot">
<span className="jac-small">
<kbd>[</kbd> <kbd>]</kbd> step · <kbd>s</kbd> split · <kbd>esc</kbd> close
</span>
<span className="jac-spacer" />
<span className="jac-small">
select a line and press <kbd>⌥S</kbd> to say something about it
</span>
</footer>
</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,
onOpenFile,
}: {
group: JudgedGroup;
files: Record<string, FileDiff>;
repo: string;
humans: ActorView[];
split: boolean;
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} />
</>
) : 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 }: { rows: (DiffRow | null)[]; split: boolean }) {
const [expanded, setExpanded] = useState<Set<number>>(new Set());
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}`}
open={expanded.has(i)}
onToggle={() =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
})
}
/>
) : split ? (
<SplitRow key={`r-${i}`} row={row} spans={spans.get(i)} />
) : (
<UnifiedRow key={`r-${i}`} row={row} spans={spans.get(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({ open, onToggle }: { open: boolean; onToggle: () => void }) {
return (
<tr className="jac-diffm-gap">
<td colSpan={4}>
<button type="button" onClick={onToggle}>
{open ? "hide unchanged" : "unchanged lines"}
</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({ row, spans }: { row: DiffRow; spans?: Span[] }) {
return (
<tr className="jac-diffm-row" data-kind={row.kind}>
<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({ row, spans }: { row: DiffRow; spans?: Span[] }) {
const left = row.kind !== "add";
const right = row.kind !== "del";
return (
<tr className="jac-diffm-row" data-kind={row.kind} data-split="1">
<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>
);
}