A change to one file, for review: its path, lines added and removed, numbered and highlighted lines, and buttons to accept or reject it.
+5 lines added,−3 lines removed
pnpm dlx shadcn@latest add @latheui/diff-viewFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { CheckIcon, FileCodeIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { duration, ease, useMotionSafe } from "@/lib/motion"
export type DiffLine = {
type: "add" | "remove" | "same"
text: string
/** Line number before the change. Absent on added lines. */
oldNumber?: number
/** Line number after the change. Absent on removed lines. */
newNumber?: number
}
export type DiffDecision = "pending" | "accepted" | "rejected"
export type DiffViewProps = Omit<React.ComponentProps<"section">, "children"> & {
/** The file's path from the project root, shown top left. */
path: string
/** The file before the change. The diff is worked out from before and after. */
before?: string
after?: string
/** Lines to show as they are, instead of before and after. */
lines?: DiffLine[]
/** Unchanged lines kept around each change. Longer runs fold into a row that expands. */
context?: number
/** Shows a check button. The view marks itself accepted. */
onAccept?: () => void
/** Shows an x button. The view marks itself rejected. */
onReject?: () => void
/** Show a decision made elsewhere. */
decision?: DiffDecision
}
/** A line diff by longest common subsequence. Fine for the files a review shows. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split("\n")
const b = after.split("\n")
const table = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
for (let i = a.length - 1; i >= 0; i--)
for (let j = b.length - 1; j >= 0; j--)
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1])
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < a.length || j < b.length) {
if (i < a.length && j < b.length && a[i] === b[j]) {
out.push({ type: "same", text: a[i], oldNumber: i + 1, newNumber: j + 1 })
i++
j++
} else if (j < b.length && (i >= a.length || table[i][j + 1] >= table[i + 1][j])) {
out.push({ type: "add", text: b[j], newNumber: j + 1 })
j++
} else {
out.push({ type: "remove", text: a[i], oldNumber: i + 1 })
i++
}
}
// Removals read first within each change.
for (let k = 1; k < out.length; k++)
if (out[k].type === "remove" && out[k - 1].type === "add") {
let start = k - 1
while (start > 0 && out[start - 1].type === "add") start--
const removal = out.splice(k, 1)[0]
out.splice(start, 0, removal)
}
return out
}
type Row = { kind: "line"; line: DiffLine } | { kind: "fold"; id: number; lines: DiffLine[] }
function fold(lines: DiffLine[], context: number): Row[] {
const near = lines.map((_, index) =>
lines.slice(Math.max(0, index - context), index + context + 1).some((l) => l.type !== "same")
)
const rows: Row[] = []
let run: DiffLine[] = []
const flush = () => {
if (!run.length) return
// Folding a line or two saves nothing.
if (run.length <= 2) run.forEach((line) => rows.push({ kind: "line", line }))
else rows.push({ kind: "fold", id: rows.length, lines: run })
run = []
}
lines.forEach((line, index) => {
if (line.type === "same" && !near[index]) run.push(line)
else {
flush()
rows.push({ kind: "line", line })
}
})
flush()
return rows
}
const rowTone = {
add: "bg-[color-mix(in_oklab,var(--chart-4)_14%,transparent)]",
remove: "bg-[color-mix(in_oklab,var(--destructive)_13%,transparent)]",
same: "",
}
const signTone = {
add: "text-[color-mix(in_oklab,var(--chart-4)_80%,var(--foreground))]",
remove: "text-destructive",
same: "text-transparent",
}
/**
* A change to one file, for review: the path, how many lines were added and removed, the lines
* themselves with numbers and highlights, and buttons to accept or reject it.
*/
function DiffView({
path,
before = "",
after = "",
lines: linesProp,
context = 3,
onAccept,
onReject,
decision: decisionProp,
className,
...props
}: DiffViewProps) {
const lines = React.useMemo(() => linesProp ?? diffLines(before, after), [linesProp, before, after])
const [opened, setOpened] = React.useState<Set<number>>(new Set())
const [chosen, setChosen] = React.useState<DiffDecision>("pending")
const decision = decisionProp ?? chosen
const added = lines.filter((l) => l.type === "add").length
const removed = lines.filter((l) => l.type === "remove").length
const rows = React.useMemo(() => fold(lines, context), [lines, context])
const { safe } = useMotionSafe()
const decide = (next: DiffDecision, callback?: () => void) => {
setChosen(next)
callback?.()
}
return (
<section
data-slot="diff-view"
data-decision={decision}
aria-label={`Changes to ${path}`}
className={cn("w-full overflow-hidden rounded-xl border border-border bg-card text-[13px]", className)}
{...props}
>
<header className="flex min-h-[45px] items-center gap-3 border-b border-border px-3.5 py-2">
<FileCodeIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<h2 className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={path}>
{path}
</h2>
<p className="flex shrink-0 gap-1.5 font-mono text-[12px] tabular-nums">
<span className={signTone.add}>
+{added}
<span className="sr-only"> lines added,</span>
</span>
<span className={signTone.remove}>
−{removed}
<span className="sr-only"> lines removed</span>
</span>
</p>
{(onAccept || onReject) && (
<AnimatePresence mode="wait" initial={false}>
{decision === "pending" ? (
<motion.div
key="buttons"
exit={{ opacity: 0 }}
transition={safe({ duration: duration.fast })}
className="-mr-1.5 flex shrink-0 gap-0.5"
>
{onReject && (
<button
type="button"
aria-label={`Reject changes to ${path}`}
onClick={() => decide("rejected", onReject)}
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/12 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-4" />
</button>
)}
{onAccept && (
<button
type="button"
aria-label={`Accept changes to ${path}`}
onClick={() => decide("accepted", onAccept)}
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--chart-4)_16%,transparent)] hover:text-[color-mix(in_oklab,var(--chart-4)_80%,var(--foreground))] focus-visible:ring-2 focus-visible:ring-ring"
>
<CheckIcon aria-hidden="true" className="size-4" />
</button>
)}
</motion.div>
) : (
<motion.p
key="result"
role="status"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={safe({ duration: duration.base, ease: ease.out })}
className={cn(
"flex h-7 shrink-0 items-center gap-1 text-[12.5px] font-medium",
decision === "accepted" ? signTone.add : "text-destructive"
)}
>
{decision === "accepted" ? <CheckIcon aria-hidden="true" className="size-3.5" /> : <XIcon aria-hidden="true" className="size-3.5" />}
{decision === "accepted" ? "Accepted" : "Rejected"}
</motion.p>
)}
</AnimatePresence>
)}
</header>
<div
// Long lines scroll sideways; keyboard users can reach the scroll area.
tabIndex={0}
aria-label={`Lines of ${path}`}
role="region"
className={cn(
"overflow-x-auto py-1 font-mono text-[12.5px] leading-6 outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
decision === "rejected" && "opacity-55"
)}
>
<ol className="w-max min-w-full">
{rows.map((row, index) => {
if (row.kind === "fold" && !opened.has(row.id))
return (
<li key={`fold-${row.id}`}>
<button
type="button"
onClick={() => setOpened((all) => new Set(all).add(row.id))}
className="flex w-full cursor-pointer items-center gap-3 bg-muted/50 px-3.5 py-0.5 text-left text-[12px] text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span aria-hidden="true" className="w-[5.5rem] text-center tracking-widest">
···
</span>
{row.lines.length} unchanged lines
</button>
</li>
)
const shown = row.kind === "fold" ? row.lines : [row.line]
return shown.map((line, k) => (
<li key={`${index}-${k}`} className={cn("grid grid-cols-[2.75rem_2.75rem_1.5rem_1fr]", rowTone[line.type])}>
<span aria-hidden="true" className="bg-card pr-2 text-right text-muted-foreground tabular-nums select-none">
{line.oldNumber ?? ""}
</span>
<span aria-hidden="true" className="bg-card pr-2 text-right text-muted-foreground tabular-nums select-none">
{line.newNumber ?? ""}
</span>
<span aria-hidden="true" className={cn("text-center select-none", signTone[line.type])}>
{line.type === "add" ? "+" : line.type === "remove" ? "−" : " "}
</span>
<span className="pr-4 whitespace-pre">
{line.type !== "same" && <span className="sr-only">{line.type === "add" ? "Added: " : "Removed: "}</span>}
{line.text || " "}
</span>
</li>
))
})}
</ol>
</div>
</section>
)
}
export { DiffView }Depends on
Also accepts native section attributes. diffLines(before, after) is exported for your own use.
| Prop | Type | Default | Description |
|---|---|---|---|
| path* (required) | string | — | The file's path from the project root. |
| before | string | — | The file before the change. |
| after | string | — | The file after the change. |
| lines | { type: "add" | "remove" | "same"; text; oldNumber?; newNumber? }[] | — | Lines to show as they are, in place of before and after. |
| context | number | 3 | Unchanged lines kept around each change. |
| onAccept | () => void | — | Shows a check button. The view then marks itself accepted. |
| onReject | () => void | — | Shows an x button. The view then marks itself rejected. |
| decision | "pending" | "accepted" | "rejected" | — | Show a decision made elsewhere instead of the buttons. |
<DiffView path={path} before={before} after={after} context={6} />