Diff Viewer
A diff you can style, and accept or reject hunk by hunk.
| Removed: export async function loadWorkspace(id: string) { |
| Removed: const response = await fetch(`/api/workspaces/${id}`); |
| Added: export async function loadWorkspace(id: string, signal?: AbortSignal) { |
| Added: const response = await fetch(`/api/workspaces/${id}`, { signal }); |
| Added: if (!response.ok) { |
| Added: throw new WorkspaceError(response.status); |
| Added: } |
| Unchanged: const workspace = await response.json(); |
| Unchanged: return workspace; |
| Unchanged: } |
| Unchanged: |
| Unchanged: export function formatSeats(count: number) { |
| Removed: return count + " seats"; |
| Added: return count === 1 ? "1 seat" : `${count} seats`; |
| Unchanged: } |
Installation
pnpm dlx @dowel-ui/cli add diff-viewernpm packages installed: diff.
Accessibility
A semantic table, not a grid. role="grid" would promise cell-by-cell arrow navigation that does not exist here and makes no sense for reading code. Every row states added, removed or unchanged in text, because a plus sign and a green tint are not information — a diff read aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a sighted reader, and announcing two numbers before every line makes the diff unlistenable. Changed words are marked with mark elements so they survive as structure, and the empty half of a split pair is hidden rather than read as a blank line of code. The optional wipe entrance is visual only — every row is in the DOM and its kind text is never clipped from assistive technology. With collapseRejected, a rejected hunk's lines are inert and hidden, its header says so in words, and Accept stays available so the decision can be reversed.
Props
DiffViewer
| Prop | Type | Default |
|---|---|---|
hunks (required) | DiffHunk[] | — |
label (required)Names the diff — usually the path of the file being changed. | string | — |
collapseRejectedCollapse a rejected hunk's lines. Its header and controls stay, so the decision can still be reversed, and the header says the lines are hidden. | boolean | false |
decisionsPer-hunk decisions, keyed by hunk id. Controlled. | Record<string, HunkDecision> | — |
entranceEntrance for added lines. | "none" | "wipe" | "none" |
onDecision | (hunkId: string, decision: HunkDecision) => void | — |
view | "unified" | "split" | "unified" |
Plus every attribute of <div> except children.
DiffViewerToolbar
| Prop | Type | Default |
|---|---|---|
onViewChange (required) | (view: "unified" | "split") => void | — |
view (required) | "unified" | "split" | — |
Plus every attribute of <div> except onChange.
Quality
9/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — fails
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add diff-viewer writes into your project, with imports rewritten to your own path alias.
import { diffLines, diffWordsWithSpace } from "diff";
/**
* Turning two versions of a file into rows a diff can render.
*
* The algorithm is not the interesting part and is not reimplemented here —
* jsdiff does Myers properly, is BSD-licensed, has no dependencies of its own,
* and getting an O(ND) diff subtly wrong is a poor use of anybody's afternoon.
* What every packaged *viewer* welds on is a styling strategy: emotion in
* react-diff-viewer-continued, HTML strings and a stylesheet in diff2html. That
* is what cannot be reached by design tokens, and it is why this exists.
*
* Everything below is pure, so the grouping, the context collapsing and the
* word-level pairing can be tested without rendering anything.
*/
export type RowKind = "context" | "added" | "removed";
export interface WordSegment {
text: string;
changed: boolean;
}
export interface DiffRow {
kind: RowKind;
/** 1-based line number in the original. Absent on an added line. */
before?: number;
/** 1-based line number in the result. Absent on a removed line. */
after?: number;
content: string;
/**
* Word-level split, present only where a removed line pairs with an added
* one. Without a pair there is nothing to compare against, and highlighting
* the whole line as "changed" would be noise.
*/
segments?: WordSegment[];
}
export interface DiffHunk {
id: string;
rows: DiffRow[];
/** Context lines hidden before this hunk, if any. */
skippedBefore: number;
}
export interface BuildDiffOptions {
/** Unchanged lines kept either side of a change. */
context?: number;
/**
* Compare word by word inside a changed line. Off for very large diffs,
* where the extra pass costs more than it explains.
*/
words?: boolean;
}
function splitLines(text: string): string[] {
const lines = text.split("\n");
// A trailing newline produces a final empty element that is not a line.
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
/**
* Gives both sides a trailing newline before they are compared.
*
* jsdiff's line tokens carry their own newline, so a final line written as `a`
* and one written as `a\n` are different tokens. Appending a line to a file
* that did not end in a newline would otherwise be reported as the last line
* being removed and re-added — a change the author did not make, sitting on
* top of the one they did.
*/
function withTrailingNewline(text: string): string {
return text.endsWith("\n") ? text : `${text}\n`;
}
/**
* Pairs removed lines with added ones inside a single change block.
*
* jsdiff reports a change as a run of removals followed by a run of additions.
* Comparing the first removal against the first addition is what turns "this
* line went away and another arrived" into "this word changed" — but only while
* the runs line up. Beyond the shorter run there is no counterpart, and
* inventing one produces confident nonsense.
*/
function pairWords(removed: DiffRow[], added: DiffRow[]): void {
const pairs = Math.min(removed.length, added.length);
for (let index = 0; index < pairs; index += 1) {
const from = removed[index];
const to = added[index];
if (!from || !to) continue;
const parts = diffWordsWithSpace(from.content, to.content);
from.segments = parts
.filter((part) => !part.added)
.map((part) => ({ text: part.value, changed: Boolean(part.removed) }));
to.segments = parts
.filter((part) => !part.removed)
.map((part) => ({ text: part.value, changed: Boolean(part.added) }));
}
}
export function buildDiff(
before: string,
after: string,
options: BuildDiffOptions = {},
): DiffHunk[] {
const { context = 3, words = true } = options;
const changes = diffLines(withTrailingNewline(before), withTrailingNewline(after));
const rows: DiffRow[] = [];
let beforeLine = 1;
let afterLine = 1;
// Flat rows first. Grouping into hunks is a separate concern and mixing the
// two is how these implementations become unreadable.
let pendingRemoved: DiffRow[] = [];
let pendingAdded: DiffRow[] = [];
const flushPair = () => {
if (words && pendingRemoved.length > 0 && pendingAdded.length > 0) {
pairWords(pendingRemoved, pendingAdded);
}
pendingRemoved = [];
pendingAdded = [];
};
for (const change of changes) {
const lines = splitLines(change.value);
if (change.added) {
for (const content of lines) {
const row: DiffRow = { kind: "added", after: afterLine, content };
afterLine += 1;
rows.push(row);
pendingAdded.push(row);
}
continue;
}
if (change.removed) {
// A removal run that follows an addition run starts a new pairing.
if (pendingAdded.length > 0) flushPair();
for (const content of lines) {
const row: DiffRow = { kind: "removed", before: beforeLine, content };
beforeLine += 1;
rows.push(row);
pendingRemoved.push(row);
}
continue;
}
flushPair();
for (const content of lines) {
rows.push({ kind: "context", before: beforeLine, after: afterLine, content });
beforeLine += 1;
afterLine += 1;
}
}
flushPair();
return groupIntoHunks(rows, context);
}
/**
* Groups rows into hunks, dropping context beyond `context` lines.
*
* A file with one changed line is otherwise thousands of rows of identical
* text, and the reader has to find the change in it.
*/
export function groupIntoHunks(rows: DiffRow[], context: number): DiffHunk[] {
const changedIndexes = rows
.map((row, index) => (row.kind === "context" ? -1 : index))
.filter((index) => index >= 0);
if (changedIndexes.length === 0) return [];
// Ranges of rows to keep, then merged where their context overlaps —
// otherwise two nearby changes produce two hunks separated by nothing.
const ranges: [number, number][] = [];
for (const index of changedIndexes) {
const start = Math.max(0, index - context);
const end = Math.min(rows.length - 1, index + context);
const last = ranges[ranges.length - 1];
if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
else ranges.push([start, end]);
}
let previousEnd = -1;
return ranges.map(([start, end], index) => {
const skippedBefore = start - previousEnd - 1;
previousEnd = end;
return {
id: `hunk-${String(index)}`,
rows: rows.slice(start, end + 1),
skippedBefore: Math.max(0, skippedBefore),
};
});
}
/** Row pairs for a side-by-side view, aligning removals against additions. */
export function toSplitRows(
rows: DiffRow[],
): { left: DiffRow | null; right: DiffRow | null }[] {
const pairs: { left: DiffRow | null; right: DiffRow | null }[] = [];
let index = 0;
while (index < rows.length) {
const row = rows[index];
if (!row) break;
if (row.kind === "context") {
pairs.push({ left: row, right: row });
index += 1;
continue;
}
// Take the whole removal run and the whole addition run that follows, then
// lay them alongside each other. Emitting them in document order instead
// would put every removal above every addition, which is the unified view
// wearing a two-column costume.
const removed: DiffRow[] = [];
while (rows[index]?.kind === "removed") {
removed.push(rows[index] as DiffRow);
index += 1;
}
const added: DiffRow[] = [];
while (rows[index]?.kind === "added") {
added.push(rows[index] as DiffRow);
index += 1;
}
const height = Math.max(removed.length, added.length);
for (let offset = 0; offset < height; offset += 1) {
pairs.push({ left: removed[offset] ?? null, right: added[offset] ?? null });
}
}
return pairs;
}
/** Counts, for a summary that says what the diff does before it is read. */
export function countChanges(hunks: DiffHunk[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const hunk of hunks) {
for (const row of hunk.rows) {
if (row.kind === "added") added += 1;
if (row.kind === "removed") removed += 1;
}
}
return { added, removed };
}
"use client";
// Motion from SmoothUI AI Diff (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentPropsWithRef,
type CSSProperties,
type ReactNode,
} from "react";
import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
import { countChanges, toSplitRows, type DiffHunk, type DiffRow } from "./diff-model";
/**
* A diff, and a decision about it.
*
* The rendering half exists because every packaged viewer brings its own
* styling system — emotion, or HTML strings plus a stylesheet — which design
* tokens cannot reach and which is awkward under RSC. The deciding half exists
* because Dowel is for AI products, and an agent proposing a change to a file is
* the case that needs it: what every coding agent ships, and no component
* library does.
*
* Decisions are controlled. The component never mutates the diff or applies
* anything; it reports which hunks were accepted and rejected and leaves the
* consequences to the application, the same way ai-action-ledger does.
*
* Not a grid. A code listing is a table of text, and role="grid" would promise
* cell-by-cell arrow navigation that neither exists here nor makes sense for
* reading code. Line kind is carried in text, because a plus sign and a green
* background are not information.
*/
export type HunkDecision = "accepted" | "rejected";
const KIND_LABEL: Record<DiffRow["kind"], string> = {
added: "Added",
removed: "Removed",
context: "Unchanged",
};
export interface DiffViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
hunks: DiffHunk[];
/** Names the diff — usually the path of the file being changed. */
label: string;
view?: "unified" | "split";
/** Per-hunk decisions, keyed by hunk id. Controlled. */
decisions?: Record<string, HunkDecision>;
onDecision?: (hunkId: string, decision: HunkDecision) => void;
children?: ReactNode;
/**
* Entrance for added lines. `wipe` draws each one in along the reading
* direction, a line after the other; context and removed lines were already
* there and do not move.
*/
entrance?: "none" | "wipe";
/**
* Collapse a rejected hunk's lines. Its header and controls stay, so the
* decision can still be reversed, and the header says the lines are hidden.
*/
collapseRejected?: boolean;
}
const PREFIX = "dowel-diff-viewer";
/* The wipe reveals an added line's code (never its line numbers) in reading
* order; its stagger is capped so a long diff does not take seconds to appear.
* The flash is a one-shot tint when a hunk's decision changes. */
const STYLES = `
@keyframes ${PREFIX}-wipe{from{clip-path:inset(0 100% 0 0)}}
@keyframes ${PREFIX}-wipe-rtl{from{clip-path:inset(0 0 0 100%)}}
@keyframes ${PREFIX}-flash-accepted{from{background-color:color-mix(in oklab,var(--color-success) 14%,transparent)}}
@keyframes ${PREFIX}-flash-rejected{from{background-color:color-mix(in oklab,var(--color-destructive) 12%,transparent)}}
.${PREFIX}-wipe{animation:${PREFIX}-wipe calc(280ms * var(--motion-scale,1)) var(--ease-out-quint) both;animation-delay:calc(var(--dowel-i,0) * 20ms * var(--motion-scale,1))}
.${PREFIX}-wipe:dir(rtl){animation-name:${PREFIX}-wipe-rtl}
[data-slot=diff-hunk][data-decision-changed=accepted]{animation:${PREFIX}-flash-accepted calc(350ms * var(--motion-scale,1)) var(--ease-out-quint)}
[data-slot=diff-hunk][data-decision-changed=rejected]{animation:${PREFIX}-flash-rejected calc(350ms * var(--motion-scale,1)) var(--ease-out-quint)}
`;
/** Lines after this many in a hunk share the last one's delay. */
const WIPE_STAGGER_CAP = 20;
/** Props for the content cell of an added line under `entrance="wipe"`. */
function wipe(index: number): { className: string; style: CSSProperties } {
return {
className: `${PREFIX}-wipe`,
style: { "--dowel-i": Math.min(index, WIPE_STAGGER_CAP) } as CSSProperties,
};
}
export function DiffViewer({
className,
hunks,
label,
view = "unified",
decisions,
onDecision,
children,
entrance = "none",
collapseRejected = false,
...props
}: DiffViewerProps) {
const counts = useMemo(() => countChanges(hunks), [hunks]);
return (
<div
data-slot="diff-viewer"
data-view={view}
className={cn("flex flex-col gap-2", className)}
{...props}
>
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-mono text-sm font-medium">{label}</span>
{/* Said in words as well as coloured, and before the diff rather than
after it: a reader deserves to know the size of the change before
they start reading it. */}
<span data-slot="diff-viewer-summary" className="text-xs text-muted-foreground">
<span className="text-success">+{counts.added}</span>{" "}
<span className="text-destructive">−{counts.removed}</span>
<span className="sr-only">
{` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`}
</span>
</span>
</div>
{children}
{hunks.length === 0 ? (
<p className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
No changes.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
{hunks.map((hunk) => (
<DiffHunkView
key={hunk.id}
hunk={hunk}
label={label}
view={view}
decision={decisions?.[hunk.id]}
onDecision={onDecision}
entrance={entrance}
collapseRejected={collapseRejected}
/>
))}
</div>
)}
</div>
);
}
function DiffHunkView({
hunk,
label,
view,
decision,
onDecision,
entrance,
collapseRejected,
}: {
hunk: DiffHunk;
label: string;
view: "unified" | "split";
decision?: HunkDecision;
onDecision?: (hunkId: string, decision: HunkDecision) => void;
entrance: "none" | "wipe";
collapseRejected: boolean;
}) {
const splitRows = useMemo(
() => (view === "split" ? toSplitRows(hunk.rows) : []),
[view, hunk.rows],
);
// A decision that changes after mount flashes once. Tracked during render,
// so a decision present on first render (a replay) does not flash.
const [previous, setPrevious] = useState(decision);
const [flash, setFlash] = useState<HunkDecision | undefined>(undefined);
if (decision !== previous) {
setPrevious(decision);
setFlash(decision);
}
// A native listener rather than onAnimationEnd: React picks a vendor-prefixed
// event name wherever AnimationEvent is missing, and would never hear it.
const sectionRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const section = sectionRef.current;
if (!section || !flash) return;
const clear = (event: Event) => {
// Wiped lines end their own animations inside the hunk; only the
// hunk's flash clears the flag.
if (event.target === section) setFlash(undefined);
};
section.addEventListener("animationend", clear);
return () => {
section.removeEventListener("animationend", clear);
};
}, [flash]);
const wiping = entrance === "wipe";
const collapsed = collapseRejected && decision === "rejected";
const hiddenNote = collapsed ? <span className="sr-only"> — lines hidden</span> : null;
const table = (
<div className="overflow-x-auto">
<table data-slot="diff-table" className="w-full border-collapse font-mono text-xs">
<tbody>
{view === "split"
? splitRows.map((pair, index) => (
<tr key={index} data-slot="diff-row">
<SplitCell row={pair.left} side="before" />
<SplitCell
row={pair.right}
side="after"
wipeIndex={wiping ? index : undefined}
/>
</tr>
))
: hunk.rows.map((row, index) => (
<UnifiedRow key={index} row={row} wipeIndex={wiping ? index : undefined} />
))}
</tbody>
</table>
</div>
);
return (
<section
data-slot="diff-hunk"
data-decision={decision}
ref={sectionRef}
data-decision-changed={flash}
aria-label={`${label}, hunk ${hunk.id}`}
className={cn(
"border-b border-border last:border-b-0",
decision === "rejected" && "opacity-55",
)}
>
{hunk.skippedBefore > 0 ? (
<p
data-slot="diff-hunk-skipped"
className="border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground"
>
{hunk.skippedBefore} unchanged {hunk.skippedBefore === 1 ? "line" : "lines"} hidden
</p>
) : null}
{onDecision ? (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5">
<span className="flex-1 text-xs text-muted-foreground">
{decision === "accepted"
? "Accepted"
: decision === "rejected"
? "Rejected"
: "Not decided"}
{hiddenNote}
</span>
<HunkButton
pressed={decision === "accepted"}
onClick={() => {
onDecision(hunk.id, "accepted");
}}
>
Accept
</HunkButton>
<HunkButton
pressed={decision === "rejected"}
onClick={() => {
onDecision(hunk.id, "rejected");
}}
>
Reject
</HunkButton>
</div>
) : null}
{!onDecision ? hiddenNote : null}
{collapseRejected ? (
// Grid rows animate between 1fr and 0fr, which is a height transition
// CSS can do. Collapsed lines are inert: hidden, and out of reach.
<div
data-slot="diff-hunk-lines"
data-collapsed={collapsed || undefined}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-[calc(250ms*var(--motion-scale))] ease-[var(--ease-out-quint)]",
collapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr]",
)}
>
<div className="min-h-0 overflow-hidden" inert={collapsed || undefined}>
{table}
</div>
</div>
) : (
table
)}
</section>
);
}
const ROW_STYLES: Record<DiffRow["kind"], string> = {
added: "bg-success/10",
removed: "bg-destructive/10",
context: "",
};
function UnifiedRow({ row, wipeIndex }: { row: DiffRow; wipeIndex?: number }) {
const motion = wipeIndex !== undefined && row.kind === "added" ? wipe(wipeIndex) : undefined;
return (
<tr data-slot="diff-row" data-kind={row.kind} className={ROW_STYLES[row.kind]}>
<LineNumber value={row.before} />
<LineNumber value={row.after} />
<td className="w-4 pe-1 text-center text-muted-foreground select-none" aria-hidden="true">
{row.kind === "added" ? "+" : row.kind === "removed" ? "−" : ""}
</td>
<td
className={cn("w-full py-0.5 pe-3 break-all whitespace-pre-wrap", motion?.className)}
style={motion?.style}
>
{/* The kind, for anyone who cannot see the sign or the tint. Reading a
diff aloud without it is reading the same file twice. */}
<span className="sr-only">{KIND_LABEL[row.kind]}: </span>
<RowContent row={row} />
</td>
</tr>
);
}
function SplitCell({
row,
side,
wipeIndex,
}: {
row: DiffRow | null;
side: "before" | "after";
wipeIndex?: number;
}) {
if (!row) {
// An empty half of a pair, not a blank line of code. Hidden from assistive
// technology so a reader is not read padding.
return (
<>
<td aria-hidden="true" className="w-10 bg-muted/40" />
<td aria-hidden="true" className="w-1/2 bg-muted/20" />
</>
);
}
const motion = wipeIndex !== undefined && row.kind === "added" ? wipe(wipeIndex) : undefined;
return (
<>
<LineNumber value={side === "before" ? row.before : row.after} />
<td
className={cn(
"w-1/2 py-0.5 pe-3 break-all whitespace-pre-wrap",
ROW_STYLES[row.kind],
motion?.className,
)}
style={motion?.style}
>
<span className="sr-only">{KIND_LABEL[row.kind]}: </span>
<RowContent row={row} />
</td>
</>
);
}
function RowContent({ row }: { row: DiffRow }) {
if (!row.segments) return <>{row.content}</>;
return (
<>
{row.segments.map((segment, index) =>
segment.changed ? (
// A mark element, so the changed words survive as structure rather
// than existing only as a slightly stronger background.
<mark
key={index}
className={cn(
"rounded-[2px] text-inherit",
row.kind === "added" ? "bg-success/30" : "bg-destructive/30",
)}
>
{segment.text}
</mark>
) : (
<span key={index}>{segment.text}</span>
),
)}
</>
);
}
function LineNumber({ value }: { value?: number }) {
return (
<td
// Decorative: the line number is orientation for a sighted reader, and
// announcing two numbers before every line makes the diff unlistenable.
aria-hidden="true"
className="w-10 border-e border-border bg-muted/40 px-2 py-0.5 text-end text-muted-foreground tabular-nums select-none"
>
{value ?? ""}
</td>
);
}
function HunkButton({
className,
pressed,
...props
}: ComponentPropsWithRef<"button"> & { pressed: boolean }) {
return (
<button
type="button"
aria-pressed={pressed}
className={cn(
"rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
pressed
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-accent hover:text-accent-foreground",
focusRing,
disabledStyles,
className,
)}
{...props}
/>
);
}
/** Switches between unified and side-by-side. */
export function DiffViewerToolbar({
className,
view,
onViewChange,
...props
}: Omit<ComponentPropsWithRef<"div">, "onChange"> & {
view: "unified" | "split";
onViewChange: (view: "unified" | "split") => void;
}) {
return (
<div
data-slot="diff-viewer-toolbar"
role="group"
aria-label="Diff layout"
className={cn("flex items-center gap-1", className)}
{...props}
>
{(["unified", "split"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={view === option}
onClick={() => {
onViewChange(option);
}}
className={cn(
"rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors",
view === option
? "border-transparent bg-secondary text-secondary-foreground"
: "border-input bg-background text-muted-foreground hover:text-foreground",
focusRing,
)}
>
{option}
</button>
))}
</div>
);
}