42 / Code
Diff View
A proposed change shown as a unified or side-by-side diff, with optional accept and reject controls.
| @@ -1 +1 @@ | |
| export function total(items) { | |
| export function total(items, { taxRate = 0 } = {}) { | |
| let sum = 0 | |
| for (const item of items) { | |
| sum += item.price | |
| sum += item.price * item.quantity | |
| } | |
| return sum | |
| return sum * (1 + taxRate) | |
| } | |
"use client"
import * as React from "react"
import { DemoVariants } from "@/components/demos/demo-variants"
import { RestartButton } from "@/components/demos/restart-button"
import { DiffView } from "mischief-ui/diff-view"
const before = `export function total(items) {
let sum = 0
for (const item of items) {
sum += item.price
}
return sum
}`
const after = `export function total(items, { taxRate = 0 } = {}) {
let sum = 0
for (const item of items) {
sum += item.price * item.quantity
}
return sum * (1 + taxRate)
}`
function Decision() {
const [status, setStatus] = React.useState<
"pending" | "accepted" | "rejected"
>("pending")
return (
<div className="grid gap-4">
<DiffView
filename="lib/total.ts"
before={before}
after={after}
status={status}
onAccept={() => setStatus("accepted")}
onReject={() => setStatus("rejected")}
/>
{status === "pending" ? null : (
<RestartButton onClick={() => setStatus("pending")} />
)}
</div>
)
}
export function DiffViewDemo() {
return (
<DemoVariants
label="Diff layout"
variants={[
{ id: "decide", label: "To review", render: () => <Decision /> },
{
id: "unified",
label: "Unified",
render: () => (
<DiffView filename="lib/total.ts" before={before} after={after} />
),
},
{
id: "split",
label: "Split",
render: () => (
<DiffView
filename="lib/total.ts"
before={before}
after={after}
view="split"
/>
),
},
]}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/diff-viewimport { DiffView } from "mischief-ui/diff-view"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, FileDiff, X } from "lucide-react" import { cn } from "@/lib/utils" export type DiffLineKind = "context" | "add" | "remove" export type DiffLine = { kind: DiffLineKind text: string beforeNumber?: number afterNumber?: numberUsage
export function Review() {
return (
<DiffView
filename="server.ts"
before={original}
after={proposed}
onAccept={apply}
onReject={discard}
/>
)
}How the diff is computed
Diffing happens by line, and only when you do not supply hunks yourself. Matching lines at the start and end are peeled off first, so an edit to one line of a long file only ever costs the length of that file. What is left in the middle goes through a longest-common-subsequence table.
That table is quadratic, so it is capped: past two million cells the two middles are reported as one wholesale replacement instead of a line-by-line match. You will only meet this with two large and almost entirely different files, and the output stays correct -- it simply stops being minimal.
diffLines and toHunks are exported, so you can run the same diff outside the component -- to count what changed before deciding whether to show anything at all.
import { diffLines, toHunks } from "mischief-ui/diff-view"
const lines = diffLines(before, after)
const changed = lines.filter((line) => line.kind !== "context")
if (changed.length > 0) {
show(toHunks(lines, 3))
}Bringing your own hunks
Pass hunks and the built-in diff is skipped entirely. This is the path to take when something upstream has already done the work and done it better -- git, a language server, or a model that returned a patch -- or when you want word-level detail the line diff cannot produce.
<DiffView
filename="lib/total.ts"
hunks={parseUnifiedDiff(patch)}
/>Line numbers come from beforeNumber and afterNumber on each line rather than being counted, so a hunk starting at line 400 reads as line 400.
API
before, afterstring, stringThe two sides. Diffed by line when no hunks are given.hunksDiffHunk[]Precomputed hunks, which win over before and after.filenamestringShown in the header. Falls back to Proposed change.view"unified" | "split"Layout. Defaults to "unified".contextnumberUnchanged lines kept either side of a change. Defaults to 3.showLineNumbersbooleanShows the number gutters. Defaults to true.onAccept, onReject() => void, () => voidAdds the decision footer when either is given.status"pending" | "accepted" | "rejected"Replaces the controls with the outcome.DiffLine
kind"context" | "add" | "remove"What happened to this line.textstringThe line, without its ending.beforeNumbernumberLine number on the old side. Absent on an addition.afterNumbernumberLine number on the new side. Absent on a removal.DiffHunk
headerstringThe band above the hunk. Generated when omitted.linesDiffLine[]The lines in order, context included.Accessibility
The diff is a table with a caption naming the file and the added and removed counts, so it can be read without colour. Every line carries a + or - marker alongside its tint for the same reason. Line numbers are aria-hidden decoration. Once a decision is made the outcome is announced through a status region.