"use client"; import { useState, useMemo } from "react"; import { Columns2, AlignLeft, Copy, Check } from "lucide-react"; import { cn } from "@/lib/utils"; import { useHighlightedCode } from "./SyntaxHighlight"; // ─── Diff algorithm ────────────────────────────────────────────────────────── type DiffLineType = "equal" | "add" | "remove"; interface DiffLine { type: DiffLineType; content: string; oldLineNo?: number; newLineNo?: number; } function computeDiff(oldStr: string, newStr: string): DiffLine[] { const oldLines = oldStr.split("\n"); const newLines = newStr.split("\n"); const m = oldLines.length; const n = newLines.length; // Build LCS table const dp: Uint32Array[] = Array.from( { length: m + 1 }, () => new Uint32Array(n + 1) ); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (oldLines[i - 1] === newLines[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } // Backtrack to build diff const result: DiffLine[] = []; let i = m; let j = n; let oldLineNo = m; let newLineNo = n; while (i > 0 || j > 0) { if ( i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1] ) { result.unshift({ type: "equal", content: oldLines[i - 1], oldLineNo: oldLineNo--, newLineNo: newLineNo--, }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { result.unshift({ type: "add", content: newLines[j - 1], newLineNo: newLineNo--, }); j--; } else { result.unshift({ type: "remove", content: oldLines[i - 1], oldLineNo: oldLineNo--, }); i--; } } return result; } // ─── Copy button ───────────────────────────────────────────────────────────── function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = () => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return ( ); } // ─── Unified diff view ─────────────────────────────────────────────────────── const CONTEXT_LINES = 3; interface UnifiedDiffProps { lines: DiffLine[]; lang: string; } function UnifiedDiff({ lines, lang: _lang }: UnifiedDiffProps) { const [expandedHunks, setExpandedHunks] = useState>(new Set()); // Identify collapsed regions (equal lines away from changes) const visible = useMemo(() => { const changed = new Set(); lines.forEach((l, i) => { if (l.type !== "equal") { for ( let k = Math.max(0, i - CONTEXT_LINES); k <= Math.min(lines.length - 1, i + CONTEXT_LINES); k++ ) { changed.add(k); } } }); return changed; }, [lines]); const items: Array< | { kind: "line"; line: DiffLine; idx: number } | { kind: "hunk"; start: number; end: number; count: number } > = useMemo(() => { const result: typeof items = []; let i = 0; while (i < lines.length) { if (visible.has(i) || expandedHunks.has(i)) { result.push({ kind: "line", line: lines[i], idx: i }); i++; } else { // Find the extent of the collapsed hunk let end = i; while (end < lines.length && !visible.has(end) && !expandedHunks.has(end)) { end++; } result.push({ kind: "hunk", start: i, end, count: end - i }); i = end; } } return result; }, [lines, visible, expandedHunks]); return (
{items.map((item, idx) => { if (item.kind === "hunk") { return ( ); } const { line } = item; const bgClass = line.type === "add" ? "bg-green-950/50 hover:bg-green-950/70" : line.type === "remove" ? "bg-red-950/50 hover:bg-red-950/70" : "hover:bg-surface-800/30"; const prefixClass = line.type === "add" ? "text-green-400" : line.type === "remove" ? "text-red-400" : "text-surface-600"; const prefix = line.type === "add" ? "+" : line.type === "remove" ? "−" : " "; return ( {/* Old line number */} {/* New line number */} {/* Content */} ); })}
{line.type !== "add" ? line.oldLineNo : ""} {line.type !== "remove" ? line.newLineNo : ""} {prefix} {line.content}
); } // ─── Side-by-side diff ─────────────────────────────────────────────────────── interface SideBySideDiffProps { lines: DiffLine[]; } function SideBySideDiff({ lines }: SideBySideDiffProps) { // Build paired columns: match adds to removes const pairs: Array<{ left: DiffLine | null; right: DiffLine | null; }> = useMemo(() => { const result: Array<{ left: DiffLine | null; right: DiffLine | null }> = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.type === "equal") { result.push({ left: line, right: line }); i++; } else if (line.type === "remove") { // Pair with next add if exists const next = lines[i + 1]; if (next?.type === "add") { result.push({ left: line, right: next }); i += 2; } else { result.push({ left: line, right: null }); i++; } } else { result.push({ left: null, right: line }); i++; } } return result; }, [lines]); return (
{pairs.map((pair, idx) => ( {/* Left column */} {/* Right column */} ))}
Before After
{pair.left?.oldLineNo ?? ""} {pair.left?.content ?? ""} {pair.right?.newLineNo ?? ""} {pair.right?.content ?? ""}
); } // ─── Public component ──────────────────────────────────────────────────────── interface DiffViewProps { oldContent: string; newContent: string; lang?: string; defaultMode?: "unified" | "side-by-side"; className?: string; } export function DiffView({ oldContent, newContent, lang = "text", defaultMode = "unified", className, }: DiffViewProps) { const [mode, setMode] = useState<"unified" | "side-by-side">(defaultMode); const lines = useMemo( () => computeDiff(oldContent, newContent), [oldContent, newContent] ); const addCount = lines.filter((l) => l.type === "add").length; const removeCount = lines.filter((l) => l.type === "remove").length; return (
{/* Header */}
+{addCount} −{removeCount}
{/* Diff content */}
{mode === "unified" ? ( ) : ( )}
); }