Diff Viewer
AIThe review UI for an AI edit: a real diff between two versions with word-level highlights and per-hunk accept/reject you can drive from the keyboard. The engine is an actual longest-common-subsequence diff (DP + backtrack) run over lines to find changed regions, then again over the words of a changed line pair to highlight exactly what moved. Each hunk resolves to its new side (accept) or old side (reject); the composed document is handed back via onResolve. Arrow/j/k move, a/r/u resolve, and the active hunk scrolls into view. Monochrome and dependency-light.
1 change · 1 pending
export function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price;
}
return sum;
export function total(items, tax = 0) {
const subtotal = items.reduce((s, item) => s + item.price, 0);
return subtotal * (1 + tax);
}
Click the diff, then use ↑/↓ and a / r to review each hunk.
components/ui/diff-viewer.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* DiffViewer — the review UI for an AI edit: a real diff between two versions,
* with word-level highlights and per-hunk accept / reject you can drive from the
* keyboard.
*
* <DiffViewer original={before} modified={after} onResolve={setDoc} />
*
* Three hard parts:
*
* 1. **A real diff.** `lcsDiff` is an actual longest-common-subsequence diff
* (DP table + backtrack), run first over lines to find changed regions, then
* again over the *words* of a changed line pair to highlight exactly what
* moved — not a fuzzy heuristic.
* 2. **Per-hunk accept / reject.** Changed lines are grouped into hunks; each
* resolves to its new side (accept) or old side (reject), and the visible
* document + the value handed to `onResolve` update to match.
* 3. **Keyboard nav.** ↑/↓ (or j/k) move between hunks, `a`/`r` resolve the
* focused one, `u` reopens it — the active hunk scrolls into view.
*
* Monochrome (changes read by weight + under/strike-through, not colour),
* dependency-light, and reduced-motion aware.
*/
type DiffOp = { type: "eq" | "del" | "ins"; value: string };
/** Longest-common-subsequence diff over a token array. O(n·m) DP + backtrack. */
function lcsDiff(a: string[], b: string[]): DiffOp[] {
const n = a.length;
const m = b.length;
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array<number>(m + 1).fill(0),
);
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] =
a[i] === b[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ type: "eq", value: a[i] });
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: "del", value: a[i] });
i += 1;
} else {
ops.push({ type: "ins", value: b[j] });
j += 1;
}
}
while (i < n) ops.push({ type: "del", value: a[i++] });
while (j < m) ops.push({ type: "ins", value: b[j++] });
return ops;
}
interface Hunk {
id: number;
del: string[];
ins: string[];
}
type Block =
{ kind: "context"; text: string; key: string } | { kind: "hunk"; hunk: Hunk };
/** Line-diff `original`→`modified`, grouping runs of changes into hunks. */
function buildBlocks(original: string, modified: string): Block[] {
const ops = lcsDiff(original.split("\n"), modified.split("\n"));
const blocks: Block[] = [];
let del: string[] = [];
let ins: string[] = [];
let hid = 0;
let ctx = 0;
const flush = () => {
if (del.length || ins.length) {
blocks.push({ kind: "hunk", hunk: { id: hid++, del, ins } });
del = [];
ins = [];
}
};
for (const op of ops) {
if (op.type === "eq") {
flush();
blocks.push({ kind: "context", text: op.value, key: `c${ctx++}` });
} else if (op.type === "del") {
del.push(op.value);
} else {
ins.push(op.value);
}
}
flush();
return blocks;
}
/** Split into words + whitespace runs, both kept so the line rejoins exactly. */
function words(line: string): string[] {
return line.split(/(\s+)/).filter((t) => t !== "");
}
type Decision = "pending" | "accepted" | "rejected";
export interface DiffViewerProps extends Omit<
React.ComponentProps<"div">,
"onResolve"
> {
original: string;
modified: string;
/** Called with the current document as hunks are accepted / rejected. */
onResolve?: (text: string) => void;
}
export function DiffViewer({
original,
modified,
onResolve,
className,
...props
}: DiffViewerProps) {
const blocks = React.useMemo(
() => buildBlocks(original, modified),
[original, modified],
);
const hunkIds = React.useMemo(
() =>
blocks
.filter((b): b is { kind: "hunk"; hunk: Hunk } => b.kind === "hunk")
.map((b) => b.hunk.id),
[blocks],
);
const [decisions, setDecisions] = React.useState<Record<number, Decision>>(
{},
);
const [active, setActive] = React.useState(0);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const hunkRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
const decisionOf = (id: number): Decision => decisions[id] ?? "pending";
const pending = hunkIds.filter((id) => decisionOf(id) === "pending").length;
// Compose the resolved document and hand it back.
const composed = React.useMemo(() => {
const out: string[] = [];
for (const b of blocks) {
if (b.kind === "context") {
out.push(b.text);
} else {
const d = decisions[b.hunk.id] ?? "pending";
if (d === "rejected") out.push(...b.hunk.del);
else out.push(...b.hunk.ins); // accepted or (optimistically) pending
}
}
return out.join("\n");
}, [blocks, decisions]);
React.useEffect(() => {
onResolve?.(composed);
}, [composed, onResolve]);
const decide = React.useCallback((id: number, d: Decision) => {
setDecisions((prev) => ({ ...prev, [id]: d }));
}, []);
const setAll = (d: Decision) => {
setDecisions(() => Object.fromEntries(hunkIds.map((id) => [id, d])));
};
// Keep the focused hunk in view — but only ever scroll our own container,
// never an ancestor. `element.scrollIntoView()` walks up to the page scroller
// and would yank the whole document to this component on mount (active = 0),
// so nudge `scrollerRef.scrollTop` by the exact overflow instead.
React.useEffect(() => {
const id = hunkIds[active];
if (id == null) return;
const scroller = scrollerRef.current;
const el = hunkRefs.current.get(id);
if (!scroller || !el) return;
const er = el.getBoundingClientRect();
const sr = scroller.getBoundingClientRect();
if (er.top < sr.top) scroller.scrollTop += er.top - sr.top;
else if (er.bottom > sr.bottom) scroller.scrollTop += er.bottom - sr.bottom;
}, [active, hunkIds]);
const onKeyDown = (e: React.KeyboardEvent) => {
const k = e.key;
if (k === "ArrowDown" || k === "j") {
e.preventDefault();
setActive((a) => Math.min(a + 1, hunkIds.length - 1));
} else if (k === "ArrowUp" || k === "k") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (k === "a" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "accepted");
} else if (k === "r" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "rejected");
} else if (k === "u" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "pending");
}
};
return (
<div
data-slot="diff-viewer"
className={cn(
"overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
{...props}
>
<div className="flex items-center gap-3 border-b border-zinc-200 px-3 py-2 text-xs dark:border-zinc-800">
<span className="font-mono text-zinc-500 dark:text-zinc-400">
{hunkIds.length} {hunkIds.length === 1 ? "change" : "changes"}
{hunkIds.length > 0 ? ` · ${pending} pending` : ""}
</span>
{hunkIds.length > 0 ? (
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={() => setAll("rejected")}
className="rounded-md px-2 py-1 text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Reject all
</button>
<button
type="button"
onClick={() => setAll("accepted")}
className="rounded-md border border-zinc-200 px-2 py-1 font-medium text-zinc-950 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
Accept all
</button>
</div>
) : null}
</div>
<div
ref={scrollerRef}
role="group"
aria-label="Diff — use arrow keys to move, a to accept, r to reject"
tabIndex={0}
onKeyDown={onKeyDown}
className="max-h-96 overflow-auto py-1 focus-visible:ring-2 focus-visible:ring-zinc-950/40 focus-visible:outline-none focus-visible:ring-inset dark:focus-visible:ring-zinc-50/40"
>
{blocks.map((b) => {
if (b.kind === "context") {
return <Gutter key={b.key} sign=" " text={b.text} />;
}
const id = b.hunk.id;
const d = decisionOf(id);
const isActive = hunkIds[active] === id;
return (
<HunkView
key={`h${id}`}
ref={(el) => {
const m = hunkRefs.current;
if (el) m.set(id, el);
else m.delete(id);
}}
hunk={b.hunk}
decision={d}
active={isActive}
index={hunkIds.indexOf(id) + 1}
onFocusHunk={() => setActive(hunkIds.indexOf(id))}
onDecide={(dec) => decide(id, dec)}
/>
);
})}
{hunkIds.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
No changes.
</div>
) : null}
</div>
</div>
);
}
function Gutter({
sign,
text,
tone,
children,
}: {
sign: string;
text?: string;
tone?: "del" | "ins";
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"flex gap-2 px-3 font-mono text-xs leading-6",
tone === "del" && "bg-zinc-100/40 dark:bg-zinc-800/40",
tone === "ins" && "bg-zinc-100/70 dark:bg-zinc-800/70",
)}
>
<span
aria-hidden="true"
className="w-3 shrink-0 text-center text-zinc-500/60 select-none dark:text-zinc-400/60"
>
{sign}
</span>
<span
className={cn(
"min-w-0 break-words whitespace-pre-wrap",
tone === "del" && "text-zinc-500 dark:text-zinc-400",
tone === "ins" && "text-zinc-950 dark:text-zinc-50",
!tone && "text-zinc-500 dark:text-zinc-400",
)}
>
{children ?? (text === "" ? "" : text)}
</span>
</div>
);
}
/** Word-level highlight for one changed line pair, one side at a time. */
function wordLine(
from: string,
to: string,
side: "del" | "ins",
): React.ReactNode {
const ops = lcsDiff(words(from), words(to));
const keep = side === "del" ? "del" : "ins";
let key = 0;
return ops
.filter((o) => o.type === "eq" || o.type === keep)
.map((o) =>
o.type === "eq" ? (
<span key={key++}>{o.value}</span>
) : side === "del" ? (
<span
key={key++}
className="rounded-[3px] bg-zinc-100 text-zinc-950 line-through decoration-zinc-950/40 dark:bg-zinc-800 dark:text-zinc-50 dark:decoration-zinc-50/40"
>
{o.value}
</span>
) : (
<span
key={key++}
className="rounded-[3px] bg-zinc-100 font-medium text-zinc-950 underline decoration-zinc-950/40 underline-offset-2 dark:bg-zinc-800 dark:text-zinc-50 dark:decoration-zinc-50/40"
>
{o.value}
</span>
),
);
}
interface HunkViewProps {
hunk: Hunk;
decision: Decision;
active: boolean;
index: number;
onFocusHunk: () => void;
onDecide: (d: Decision) => void;
}
const HunkView = React.forwardRef<HTMLDivElement, HunkViewProps>(
function HunkView(
{ hunk, decision, active, index, onFocusHunk, onDecide },
ref,
) {
const paired = hunk.del.length === hunk.ins.length && hunk.del.length > 0;
let body: React.ReactNode;
if (decision === "accepted") {
body = hunk.ins.map((l, i) => (
<Gutter key={`i${i}`} sign="+" tone="ins" text={l} />
));
} else if (decision === "rejected") {
body = hunk.del.map((l, i) => <Gutter key={`d${i}`} sign=" " text={l} />);
} else if (paired) {
// 1:1 replacement — show both sides with word-level highlights.
body = hunk.del.map((dl, i) => (
<React.Fragment key={`p${i}`}>
<Gutter sign="−" tone="del">
{wordLine(dl, hunk.ins[i], "del")}
</Gutter>
<Gutter sign="+" tone="ins">
{wordLine(dl, hunk.ins[i], "ins")}
</Gutter>
</React.Fragment>
));
} else {
body = (
<>
{hunk.del.map((l, i) => (
<Gutter key={`d${i}`} sign="−" tone="del" text={l} />
))}
{hunk.ins.map((l, i) => (
<Gutter key={`i${i}`} sign="+" tone="ins" text={l} />
))}
</>
);
}
return (
<div
ref={ref}
onMouseDown={onFocusHunk}
data-active={active}
className={cn(
"relative my-0.5 border-l-2 transition-colors",
active ? "border-zinc-950 dark:border-zinc-50" : "border-transparent",
decision !== "pending" && "opacity-80",
)}
>
{body}
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1",
active ? "opacity-100" : "opacity-0 focus-within:opacity-100",
"transition-opacity motion-reduce:transition-none",
)}
>
{decision === "pending" ? (
<>
<button
type="button"
onClick={() => onDecide("accepted")}
className="rounded border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-950 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
Accept <kbd className="ml-0.5 opacity-60">a</kbd>
</button>
<button
type="button"
onClick={() => onDecide("rejected")}
className="rounded px-2 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Reject <kbd className="ml-0.5 opacity-60">r</kbd>
</button>
</>
) : (
<>
<span className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
{decision === "accepted" ? "accepted" : "rejected"} · hunk{" "}
{index}
</span>
<button
type="button"
onClick={() => onDecide("pending")}
className="rounded px-2 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Undo <kbd className="ml-0.5 opacity-60">u</kbd>
</button>
</>
)}
</div>
</div>
);
},
);Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/diff-viewer.json1. Install dependencies
Terminal
npm install clsx tailwind-merge2. Copy the source into your project
components/ui/diff-viewer.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* DiffViewer — the review UI for an AI edit: a real diff between two versions,
* with word-level highlights and per-hunk accept / reject you can drive from the
* keyboard.
*
* <DiffViewer original={before} modified={after} onResolve={setDoc} />
*
* Three hard parts:
*
* 1. **A real diff.** `lcsDiff` is an actual longest-common-subsequence diff
* (DP table + backtrack), run first over lines to find changed regions, then
* again over the *words* of a changed line pair to highlight exactly what
* moved — not a fuzzy heuristic.
* 2. **Per-hunk accept / reject.** Changed lines are grouped into hunks; each
* resolves to its new side (accept) or old side (reject), and the visible
* document + the value handed to `onResolve` update to match.
* 3. **Keyboard nav.** ↑/↓ (or j/k) move between hunks, `a`/`r` resolve the
* focused one, `u` reopens it — the active hunk scrolls into view.
*
* Monochrome (changes read by weight + under/strike-through, not colour),
* dependency-light, and reduced-motion aware.
*/
type DiffOp = { type: "eq" | "del" | "ins"; value: string };
/** Longest-common-subsequence diff over a token array. O(n·m) DP + backtrack. */
function lcsDiff(a: string[], b: string[]): DiffOp[] {
const n = a.length;
const m = b.length;
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array<number>(m + 1).fill(0),
);
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] =
a[i] === b[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ type: "eq", value: a[i] });
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: "del", value: a[i] });
i += 1;
} else {
ops.push({ type: "ins", value: b[j] });
j += 1;
}
}
while (i < n) ops.push({ type: "del", value: a[i++] });
while (j < m) ops.push({ type: "ins", value: b[j++] });
return ops;
}
interface Hunk {
id: number;
del: string[];
ins: string[];
}
type Block =
{ kind: "context"; text: string; key: string } | { kind: "hunk"; hunk: Hunk };
/** Line-diff `original`→`modified`, grouping runs of changes into hunks. */
function buildBlocks(original: string, modified: string): Block[] {
const ops = lcsDiff(original.split("\n"), modified.split("\n"));
const blocks: Block[] = [];
let del: string[] = [];
let ins: string[] = [];
let hid = 0;
let ctx = 0;
const flush = () => {
if (del.length || ins.length) {
blocks.push({ kind: "hunk", hunk: { id: hid++, del, ins } });
del = [];
ins = [];
}
};
for (const op of ops) {
if (op.type === "eq") {
flush();
blocks.push({ kind: "context", text: op.value, key: `c${ctx++}` });
} else if (op.type === "del") {
del.push(op.value);
} else {
ins.push(op.value);
}
}
flush();
return blocks;
}
/** Split into words + whitespace runs, both kept so the line rejoins exactly. */
function words(line: string): string[] {
return line.split(/(\s+)/).filter((t) => t !== "");
}
type Decision = "pending" | "accepted" | "rejected";
export interface DiffViewerProps extends Omit<
React.ComponentProps<"div">,
"onResolve"
> {
original: string;
modified: string;
/** Called with the current document as hunks are accepted / rejected. */
onResolve?: (text: string) => void;
}
export function DiffViewer({
original,
modified,
onResolve,
className,
...props
}: DiffViewerProps) {
const blocks = React.useMemo(
() => buildBlocks(original, modified),
[original, modified],
);
const hunkIds = React.useMemo(
() =>
blocks
.filter((b): b is { kind: "hunk"; hunk: Hunk } => b.kind === "hunk")
.map((b) => b.hunk.id),
[blocks],
);
const [decisions, setDecisions] = React.useState<Record<number, Decision>>(
{},
);
const [active, setActive] = React.useState(0);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const hunkRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
const decisionOf = (id: number): Decision => decisions[id] ?? "pending";
const pending = hunkIds.filter((id) => decisionOf(id) === "pending").length;
// Compose the resolved document and hand it back.
const composed = React.useMemo(() => {
const out: string[] = [];
for (const b of blocks) {
if (b.kind === "context") {
out.push(b.text);
} else {
const d = decisions[b.hunk.id] ?? "pending";
if (d === "rejected") out.push(...b.hunk.del);
else out.push(...b.hunk.ins); // accepted or (optimistically) pending
}
}
return out.join("\n");
}, [blocks, decisions]);
React.useEffect(() => {
onResolve?.(composed);
}, [composed, onResolve]);
const decide = React.useCallback((id: number, d: Decision) => {
setDecisions((prev) => ({ ...prev, [id]: d }));
}, []);
const setAll = (d: Decision) => {
setDecisions(() => Object.fromEntries(hunkIds.map((id) => [id, d])));
};
// Keep the focused hunk in view — but only ever scroll our own container,
// never an ancestor. `element.scrollIntoView()` walks up to the page scroller
// and would yank the whole document to this component on mount (active = 0),
// so nudge `scrollerRef.scrollTop` by the exact overflow instead.
React.useEffect(() => {
const id = hunkIds[active];
if (id == null) return;
const scroller = scrollerRef.current;
const el = hunkRefs.current.get(id);
if (!scroller || !el) return;
const er = el.getBoundingClientRect();
const sr = scroller.getBoundingClientRect();
if (er.top < sr.top) scroller.scrollTop += er.top - sr.top;
else if (er.bottom > sr.bottom) scroller.scrollTop += er.bottom - sr.bottom;
}, [active, hunkIds]);
const onKeyDown = (e: React.KeyboardEvent) => {
const k = e.key;
if (k === "ArrowDown" || k === "j") {
e.preventDefault();
setActive((a) => Math.min(a + 1, hunkIds.length - 1));
} else if (k === "ArrowUp" || k === "k") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (k === "a" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "accepted");
} else if (k === "r" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "rejected");
} else if (k === "u" && hunkIds[active] != null) {
e.preventDefault();
decide(hunkIds[active], "pending");
}
};
return (
<div
data-slot="diff-viewer"
className={cn(
"overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
className,
)}
{...props}
>
<div className="flex items-center gap-3 border-b border-zinc-200 px-3 py-2 text-xs dark:border-zinc-800">
<span className="font-mono text-zinc-500 dark:text-zinc-400">
{hunkIds.length} {hunkIds.length === 1 ? "change" : "changes"}
{hunkIds.length > 0 ? ` · ${pending} pending` : ""}
</span>
{hunkIds.length > 0 ? (
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={() => setAll("rejected")}
className="rounded-md px-2 py-1 text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Reject all
</button>
<button
type="button"
onClick={() => setAll("accepted")}
className="rounded-md border border-zinc-200 px-2 py-1 font-medium text-zinc-950 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
Accept all
</button>
</div>
) : null}
</div>
<div
ref={scrollerRef}
role="group"
aria-label="Diff — use arrow keys to move, a to accept, r to reject"
tabIndex={0}
onKeyDown={onKeyDown}
className="max-h-96 overflow-auto py-1 focus-visible:ring-2 focus-visible:ring-zinc-950/40 focus-visible:outline-none focus-visible:ring-inset dark:focus-visible:ring-zinc-50/40"
>
{blocks.map((b) => {
if (b.kind === "context") {
return <Gutter key={b.key} sign=" " text={b.text} />;
}
const id = b.hunk.id;
const d = decisionOf(id);
const isActive = hunkIds[active] === id;
return (
<HunkView
key={`h${id}`}
ref={(el) => {
const m = hunkRefs.current;
if (el) m.set(id, el);
else m.delete(id);
}}
hunk={b.hunk}
decision={d}
active={isActive}
index={hunkIds.indexOf(id) + 1}
onFocusHunk={() => setActive(hunkIds.indexOf(id))}
onDecide={(dec) => decide(id, dec)}
/>
);
})}
{hunkIds.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
No changes.
</div>
) : null}
</div>
</div>
);
}
function Gutter({
sign,
text,
tone,
children,
}: {
sign: string;
text?: string;
tone?: "del" | "ins";
children?: React.ReactNode;
}) {
return (
<div
className={cn(
"flex gap-2 px-3 font-mono text-xs leading-6",
tone === "del" && "bg-zinc-100/40 dark:bg-zinc-800/40",
tone === "ins" && "bg-zinc-100/70 dark:bg-zinc-800/70",
)}
>
<span
aria-hidden="true"
className="w-3 shrink-0 text-center text-zinc-500/60 select-none dark:text-zinc-400/60"
>
{sign}
</span>
<span
className={cn(
"min-w-0 break-words whitespace-pre-wrap",
tone === "del" && "text-zinc-500 dark:text-zinc-400",
tone === "ins" && "text-zinc-950 dark:text-zinc-50",
!tone && "text-zinc-500 dark:text-zinc-400",
)}
>
{children ?? (text === "" ? "" : text)}
</span>
</div>
);
}
/** Word-level highlight for one changed line pair, one side at a time. */
function wordLine(
from: string,
to: string,
side: "del" | "ins",
): React.ReactNode {
const ops = lcsDiff(words(from), words(to));
const keep = side === "del" ? "del" : "ins";
let key = 0;
return ops
.filter((o) => o.type === "eq" || o.type === keep)
.map((o) =>
o.type === "eq" ? (
<span key={key++}>{o.value}</span>
) : side === "del" ? (
<span
key={key++}
className="rounded-[3px] bg-zinc-100 text-zinc-950 line-through decoration-zinc-950/40 dark:bg-zinc-800 dark:text-zinc-50 dark:decoration-zinc-50/40"
>
{o.value}
</span>
) : (
<span
key={key++}
className="rounded-[3px] bg-zinc-100 font-medium text-zinc-950 underline decoration-zinc-950/40 underline-offset-2 dark:bg-zinc-800 dark:text-zinc-50 dark:decoration-zinc-50/40"
>
{o.value}
</span>
),
);
}
interface HunkViewProps {
hunk: Hunk;
decision: Decision;
active: boolean;
index: number;
onFocusHunk: () => void;
onDecide: (d: Decision) => void;
}
const HunkView = React.forwardRef<HTMLDivElement, HunkViewProps>(
function HunkView(
{ hunk, decision, active, index, onFocusHunk, onDecide },
ref,
) {
const paired = hunk.del.length === hunk.ins.length && hunk.del.length > 0;
let body: React.ReactNode;
if (decision === "accepted") {
body = hunk.ins.map((l, i) => (
<Gutter key={`i${i}`} sign="+" tone="ins" text={l} />
));
} else if (decision === "rejected") {
body = hunk.del.map((l, i) => <Gutter key={`d${i}`} sign=" " text={l} />);
} else if (paired) {
// 1:1 replacement — show both sides with word-level highlights.
body = hunk.del.map((dl, i) => (
<React.Fragment key={`p${i}`}>
<Gutter sign="−" tone="del">
{wordLine(dl, hunk.ins[i], "del")}
</Gutter>
<Gutter sign="+" tone="ins">
{wordLine(dl, hunk.ins[i], "ins")}
</Gutter>
</React.Fragment>
));
} else {
body = (
<>
{hunk.del.map((l, i) => (
<Gutter key={`d${i}`} sign="−" tone="del" text={l} />
))}
{hunk.ins.map((l, i) => (
<Gutter key={`i${i}`} sign="+" tone="ins" text={l} />
))}
</>
);
}
return (
<div
ref={ref}
onMouseDown={onFocusHunk}
data-active={active}
className={cn(
"relative my-0.5 border-l-2 transition-colors",
active ? "border-zinc-950 dark:border-zinc-50" : "border-transparent",
decision !== "pending" && "opacity-80",
)}
>
{body}
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1",
active ? "opacity-100" : "opacity-0 focus-within:opacity-100",
"transition-opacity motion-reduce:transition-none",
)}
>
{decision === "pending" ? (
<>
<button
type="button"
onClick={() => onDecide("accepted")}
className="rounded border border-zinc-200 px-2 py-0.5 text-[11px] font-medium text-zinc-950 transition-colors hover:bg-zinc-100 dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
Accept <kbd className="ml-0.5 opacity-60">a</kbd>
</button>
<button
type="button"
onClick={() => onDecide("rejected")}
className="rounded px-2 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Reject <kbd className="ml-0.5 opacity-60">r</kbd>
</button>
</>
) : (
<>
<span className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
{decision === "accepted" ? "accepted" : "rejected"} · hunk{" "}
{index}
</span>
<button
type="button"
onClick={() => onDecide("pending")}
className="rounded px-2 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Undo <kbd className="ml-0.5 opacity-60">u</kbd>
</button>
</>
)}
</div>
</div>
);
},
);lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
original / modified | string | — | The two versions to diff, line by line. |
onResolve | (text: string) => void | — | Called with the composed document as hunks are accepted / rejected. |
...props | React.ComponentProps<"div"> | — | All native div attributes are forwarded. |
Dependencies
clsxtailwind-merge