DiffReview
When the assistant proposes a change to text (a document, a config, a
query) the human reviews it as a diff and takes it hunk by hunk. Give it
the two texts and it computes the diff itself (diffHunks from
@zuilib/ai/diff-engine), or give it precomputed hunks or a unified
patch; decisions come back through callbacks and you decide what
accepting means.
import DiffReview from '@zuilib/ai/diff-review'
Unified
decisions is a map from hunk index to 'accepted' | 'rejected'. Leave it
out and the component keeps the decisions itself (uncontrolled): every
button updates them, and onDecisionsChange reports the whole map after
each change together with the text it produces, so the simplest wiring
is one callback. The per-hunk callbacks still fire alongside. A decided
hunk shows a badge instead of its buttons, a rejected one dims, and the
header buttons disable once nothing is left to decide.
const [accepted, setAccepted] = useState(document)
<DiffReview original={document} modified={proposal} onDecisionsChange={(_, text) => setAccepted(text)} />
That text is applyDecisions(original, hunks, decisions) from
@zuilib/ai/diff-engine: original with every accepted hunk applied,
keeping its newline style and whether it ended with a newline (an empty
original yields the accepted lines with no trailing newline); a
rejected or undecided hunk leaves its lines as they were.
Hunks and patches
hunks takes what diffHunks (or your own diff) produced; patch takes
a unified diff (diff -u, git diff) and parses it with parsePatch.
Either replaces the texts, which is what a server-side diff needs. The
accepted text reported by onDecisionsChange is then built from the
hunks' own lines (applyHunkDecisions) rather than from original.
Long diffs and the render path
Up to maxLines lines per side (5,000 by default) the diff is an exact
LCS table. Beyond it the linear-space Myers walk (myersDiff) runs
instead: the same minimal edit script in O(N + M) memory, so a 100k-line
file never allocates a table. Past hardMaxLines (200,000) no line diff
is attempted: the result is the common prefix and suffix plus one replace
hunk, and the card shows truncatedNotice as a status row
(data-slot="diff-review-truncated"). The root carries
data-algorithm="lcs" | "myers" | "replace". The same limits are
DiffOptions on diffLines / diffHunks; diffLinesWithInfo /
diffHunksWithInfo return {…, truncated, algorithm}.
The diff never blocks an urgent update: without computeDiff it runs on
deferred inputs (useDeferredValue), so typing into the texts renders
with the previous hunks first and the recomputation follows; the root
is data-pending meanwhile. computeDiff moves it off the main thread
entirely: a function receiving {original, modified, context, options}
and resolving to diffHunksWithInfo's result (run it in a Web Worker);
the card is data-pending and aria-busy until it resolves.
// diff.worker.ts
import {diffHunksWithInfo} from '@zuilib/ai/diff-engine'
self.onmessage = ({data}) => postMessage(diffHunksWithInfo(data.original, data.modified, data.context, data.options))
// the host
<DiffReview original={a} modified={b} computeDiff={(request) => call(worker, request)} />
Split
Two columns need room: when the container is narrower than 384px the
component renders the unified layout instead, and data-mode on the root
reports the layout actually shown.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
original / modified | string | — | The two texts |
hunks | DiffHunk[] | — | Precomputed hunks; wins over patch and the texts |
patch | string | — | A unified diff, parsed with parsePatch |
computeDiff | (request: DiffComputeRequest) => Promise<DiffHunksResult> | — | Computes the diff off-thread; data-pending until it resolves |
mode | 'unified' | 'split' | 'unified' | |
context | number | 3 | Equal lines around each change |
originalLabel / modifiedLabel | string | 'Original' / 'Proposed' | |
decisions | Record<number, 'accepted' | 'rejected'> | — | Controlled; omit and the component keeps them |
onDecisionsChange | (decisions, accepted: string) => void | — | Every change, with the text the decisions produce |
onAcceptHunk / onRejectHunk | (index: number, hunk: DiffHunk) => void | — | Per-hunk buttons appear when either, or onDecisionsChange, is given |
onAcceptAll / onRejectAll | () => void | — | Header buttons appear when either, or onDecisionsChange, is given |
lineNumbers | boolean | true | |
maxLines | number | 5000 | Lines per side handled by the LCS table; beyond it the Myers walk runs |
hardMaxLines | number | 200000 | Lines per side beyond which the diff becomes one replace hunk |
truncatedNotice | ReactNode | 'This diff is too large…' | Shown when truncated |
className | string | — |
diffLines, diffHunks, diffLinesWithInfo, diffHunksWithInfo,
myersDiff, parsePatch, toHunks, toSplitRows, applyDecisions,
applyHunkDecisions, DEFAULT_MAX_DIFF_LINES and
DEFAULT_HARD_MAX_DIFF_LINES are re-exported from this entry and from
@zuilib/ai/diff-engine, so you can also apply a hunk yourself: each
DiffHunk carries its lines with old and new line numbers.