src/components/prompt-input.tsx+4−2
@@ -1,8 +1,10 @@
import * as React from "react";
import { CheckIcon, XMarkIcon } from "@heroicons-animated/react";
import { Button } from "@/components/ui/button";
import { DiffHeader, DiffHunkView, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { computeDiff, diffStats } from "@/lib/diff";
const before = `export function submit(value: string) {
if (!value) return;
onSubmit?.(value);
}
export function reset() {
setValue("");
}`;
const after = `export function submit(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
onSubmit?.(trimmed);
}
export function reset() {
setValue("");
textareaRef.current?.focus();
}`;
export default function DiffViewDemo() {
const hunks = React.useMemo(() => computeDiff(before, after, 2), []);
const stats = diffStats(hunks);
const [decisions, setDecisions] = React.useState<Record<string, "accepted" | "rejected">>({});
return (
<div className="w-full max-w-2xl">
<DiffView>
<DiffHeader path="src/components/prompt-input.tsx" additions={stats.additions} deletions={stats.deletions}>
<Button variant="ghost" size="xs" onClick={() => setDecisions({})}>Reset</Button>
</DiffHeader>
{hunks.map((hunk) => (
<DiffHunkView
key={hunk.id}
hunk={hunk}
decision={decisions[hunk.id]}
actions={
<>
<Button variant="ghost" size="xs" className="text-red-600 dark:text-red-400" onClick={() => setDecisions((d) => ({ ...d, [hunk.id]: "rejected" }))}>
<XMarkIcon size={12} className="flex" /> Reject
</Button>
<Button variant="ghost" size="xs" className="text-emerald-600 dark:text-emerald-400" onClick={() => setDecisions((d) => ({ ...d, [hunk.id]: "accepted" }))}>
<CheckIcon size={12} className="flex" /> Accept
</Button>
</>
}
/>
))}
</DiffView>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/diff-view.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/diff-view.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/diff-view.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/diff-view.jsonInstall the dependencies:
npm install @heroicons-animated/react motionCopy the source into your project:
"use client";
import * as React from "react";
import { DocumentTextIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import type { DiffHunk, DiffLine } from "@/lib/diff";
type DiffViewProps = React.ComponentProps<"div">;
function DiffView({ className, ...props }: DiffViewProps) {
return (
<div
data-slot="diff-view"
className={cn(
"not-prose flex w-full flex-col overflow-hidden rounded-xl border bg-card text-[13px] font-[450]",
className,
)}
{...props}
/>
);
}
type DiffHeaderProps = React.ComponentProps<"div"> & {
path: string;
additions?: number;
deletions?: number;
icon?: React.ReactNode;
};
function DiffHeader({ path, additions, deletions, icon, className, children, ...props }: DiffHeaderProps) {
return (
<div
data-slot="diff-header"
className={cn(
"flex h-10 items-center justify-between gap-3 border-b bg-surface px-3 text-xs",
className,
)}
{...props}
>
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
{icon ?? <DocumentTextIcon size={14} className="flex shrink-0" />}
<span className="truncate font-mono text-foreground">{path}</span>
{additions !== undefined || deletions !== undefined ? (
<DiffStats additions={additions ?? 0} deletions={deletions ?? 0} />
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">{children}</div>
</div>
);
}
function DiffStats({
additions,
deletions,
className,
...props
}: React.ComponentProps<"span"> & { additions: number; deletions: number }) {
return (
<span
data-slot="diff-stats"
className={cn("flex items-center gap-1.5 font-mono tabular-nums", className)}
{...props}
>
<span className="text-emerald-600 dark:text-emerald-400">+{additions}</span>
<span className="text-red-600 dark:text-red-400">−{deletions}</span>
</span>
);
}
type DiffHunkViewProps = Omit<React.ComponentProps<"div">, "children"> & {
hunk: DiffHunk;
/** Rendered at the right end of the hunk header (accept / reject buttons). */
actions?: React.ReactNode;
/** Visual state after a decision has been made. */
decision?: "accepted" | "rejected";
lineNumbers?: boolean;
};
function DiffHunkView({
hunk,
actions,
decision,
lineNumbers = true,
className,
...props
}: DiffHunkViewProps) {
return (
<div
data-slot="diff-hunk"
data-decision={decision}
className={cn(
"border-b last:border-b-0",
decision === "rejected" && "opacity-50",
className,
)}
{...props}
>
<div className="flex h-8 items-center justify-between gap-2 bg-brand-soft/40 px-3 font-mono text-[11px] text-muted-foreground dark:bg-brand-soft/20">
<span>
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</span>
<div className="flex items-center gap-1">
{decision ? (
<span
className={cn(
"rounded-full px-2 py-0.5 font-sans text-[10px] font-medium uppercase",
decision === "accepted"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400",
)}
>
{decision}
</span>
) : (
actions
)}
</div>
</div>
<div className="no-scrollbar overflow-x-auto">
<table className="w-full border-collapse font-mono">
<tbody>
{hunk.lines.map((line, i) => (
<DiffLineRow key={i} line={line} lineNumbers={lineNumbers} />
))}
</tbody>
</table>
</div>
</div>
);
}
function DiffLineRow({ line, lineNumbers }: { line: DiffLine; lineNumbers: boolean }) {
const sign = line.type === "add" ? "+" : line.type === "delete" ? "−" : " ";
return (
<tr
data-slot="diff-line"
data-type={line.type}
className={cn(
"leading-6",
line.type === "add" && "bg-emerald-500/10 text-emerald-900 dark:text-emerald-100",
line.type === "delete" && "bg-red-500/10 text-red-900 dark:text-red-100",
)}
>
{lineNumbers ? (
<>
<td className="w-10 select-none border-r px-2 text-right text-[11px] text-muted-foreground/70 tabular-nums">
{line.oldLine ?? ""}
</td>
<td className="w-10 select-none border-r px-2 text-right text-[11px] text-muted-foreground/70 tabular-nums">
{line.newLine ?? ""}
</td>
</>
) : null}
<td className="w-5 select-none pl-2 text-muted-foreground/70">{sign}</td>
<td className="whitespace-pre pr-4">{line.text || " "}</td>
</tr>
);
}
export { DiffView, DiffHeader, DiffStats, DiffHunkView, DiffLineRow };/**
* Minimal line diff (LCS) and hunk splitter. Good enough for review UIs where
* the model proposes a patch; swap for `diff` from npm if you need word-level
* or very large inputs.
*/
export type DiffLineType = "context" | "add" | "delete";
export type DiffLine = {
type: DiffLineType;
text: string;
/** 1-based line number in the old file, if present there. */
oldLine?: number;
/** 1-based line number in the new file, if present there. */
newLine?: number;
};
export type DiffHunk = {
id: string;
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: DiffLine[];
};
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split("\n");
const b = after.split("\n");
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--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ type: "context", text: a[i], oldLine: i + 1, newLine: j + 1 });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: "delete", text: a[i], oldLine: i + 1 });
i++;
} else {
out.push({ type: "add", text: b[j], newLine: j + 1 });
j++;
}
}
while (i < n) out.push({ type: "delete", text: a[i], oldLine: ++i });
while (j < m) out.push({ type: "add", text: b[j], newLine: ++j });
return out;
}
/** Group changed lines into hunks with `context` unchanged lines around them. */
export function toHunks(lines: DiffLine[], context = 3): DiffHunk[] {
const hunks: DiffHunk[] = [];
const changed = lines.map((l) => l.type !== "context");
let idx = 0;
let hunkNo = 0;
while (idx < lines.length) {
if (!changed[idx]) {
idx++;
continue;
}
const start = Math.max(0, idx - context);
let end = idx;
let lastChange = idx;
while (end < lines.length) {
if (changed[end]) lastChange = end;
else if (end - lastChange > context * 2) break;
end++;
}
end = Math.min(lines.length, lastChange + context + 1);
const slice = lines.slice(start, end);
const oldStart = slice.find((l) => l.oldLine)?.oldLine ?? 1;
const newStart = slice.find((l) => l.newLine)?.newLine ?? 1;
hunks.push({
id: `hunk-${++hunkNo}`,
oldStart,
newStart,
oldLines: slice.filter((l) => l.type !== "add").length,
newLines: slice.filter((l) => l.type !== "delete").length,
lines: slice,
});
idx = end;
}
return hunks;
}
export function computeDiff(before: string, after: string, context = 3) {
return toHunks(diffLines(before, after), context);
}
export function diffStats(hunks: DiffHunk[]) {
let additions = 0;
let deletions = 0;
for (const h of hunks) {
for (const l of h.lines) {
if (l.type === "add") additions++;
if (l.type === "delete") deletions++;
}
}
return { additions, deletions };
}Ships with lib/diff.ts, a small LCS line diff. Swap it for the diff package when you need word-level diffs or very large files.
Usage
import { DiffHeader, DiffHunkView, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { computeDiff, diffStats } from "@/lib/diff";const hunks = computeDiff(before, after);
const { additions, deletions } = diffStats(hunks);
<DiffView>
<DiffHeader path="src/app.tsx" additions={additions} deletions={deletions} />
{hunks.map((hunk) => (
<DiffHunkView
key={hunk.id}
hunk={hunk}
decision={decisions[hunk.id]}
actions={<AcceptRejectButtons onDecide={(d) => decide(hunk.id, d)} />}
/>
))}
</DiffView>If your API already returns hunks, skip computeDiff and pass them straight in. The shape is { id, oldStart, oldLines, newStart, newLines, lines: { type, text, oldLine?, newLine? }[] }.
API Reference
DiffHeader
| Prop | Type | Description |
|---|---|---|
path |
string |
File path shown in monospace. |
additions / deletions |
number |
Renders the +/− stats. |
children |
ReactNode |
Right-aligned actions. |
DiffHunkView
| Prop | Type | Description |
|---|---|---|
hunk |
DiffHunk |
The hunk to render. |
actions |
ReactNode |
Shown in the hunk header until a decision is made. |
decision |
"accepted" | "rejected" |
Replaces actions with a badge; rejected hunks dim. |
lineNumbers |
boolean |
Default true. |