TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import { useMemo, useState } from "react";4import type { DiffSummary } from "@/lib/api";56type Tab = "unified" | "split" | "semantic" | "raw";78interface Line {9 kind: "add" | "del" | "ctx" | "hunk" | "meta";10 text: string;11}1213function parseUnified(patch: string): Line[] {14 return patch.split("\n").map((l): Line => {15 if (l.startsWith("+++") || l.startsWith("---") || l.startsWith("Index:") || l.startsWith("====")) return { kind: "meta", text: l };16 if (l.startsWith("@@")) return { kind: "hunk", text: l };17 if (l.startsWith("+")) return { kind: "add", text: l.slice(1) };18 if (l.startsWith("-")) return { kind: "del", text: l.slice(1) };19 return { kind: "ctx", text: l.startsWith(" ") ? l.slice(1) : l };20 });21}2223/** Pair deletions and additions inside each hunk into left/right rows. */24function splitRows(lines: Line[]): { left: Line | null; right: Line | null }[] {25 const rows: { left: Line | null; right: Line | null }[] = [];26 let i = 0;27 while (i < lines.length) {28 const l = lines[i]!;29 if (l.kind === "ctx" || l.kind === "hunk" || l.kind === "meta") {30 rows.push({ left: l, right: l });31 i++;32 continue;33 }34 const dels: Line[] = [];35 const adds: Line[] = [];36 while (i < lines.length && lines[i]!.kind === "del") dels.push(lines[i++]!);37 while (i < lines.length && lines[i]!.kind === "add") adds.push(lines[i++]!);38 const n = Math.max(dels.length, adds.length);39 for (let k = 0; k < n; k++) rows.push({ left: dels[k] ?? null, right: adds[k] ?? null });40 }41 return rows;42}4344const cls = (k: Line["kind"] | undefined): string => (k === "add" ? "diff-line-add" : k === "del" ? "diff-line-del" : k === "hunk" ? "diff-line-hunk" : k === "meta" ? "diff-line-meta" : "");4546export function DiffViewer({ unified, summary, defaultTab = "semantic" }: { unified: string | null; summary: DiffSummary | null | undefined; defaultTab?: Tab }) {47 const [tab, setTab] = useState<Tab>(unified ? defaultTab : "semantic");48 const lines = useMemo(() => (unified ? parseUnified(unified) : []), [unified]);49 const rows = useMemo(() => splitRows(lines), [lines]);50 const tabs: [Tab, string][] = [51 ["unified", "Unified"],52 ["split", "Side-by-side"],53 ["semantic", "Semantic"],54 ["raw", "Raw"],55 ];56 return (57 <div className="panel overflow-hidden">58 <div className="flex items-center gap-1 border-b border-line px-2 py-1.5">59 {tabs.map(([k, label]) => (60 <button key={k} type="button" onClick={() => setTab(k)} className={`rounded-md px-2 py-1 text-[12px] ${tab === k ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"}`}>61 {label}62 </button>63 ))}64 {summary?.stats && (65 <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">66 <span className="text-ok">+{summary.stats.added}</span> <span className="text-danger">−{summary.stats.removed}</span> <span className="text-info">~{summary.stats.modified}</span>67 </span>68 )}69 {summary?.counts && (70 <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">71 <span className="text-ok">+{summary.counts.added}</span> <span className="text-danger">−{summary.counts.removed}</span> <span className="text-info">~{summary.counts.modified}</span>72 </span>73 )}74 </div>75 <div className="max-h-[70vh] overflow-auto font-mono text-[12px] leading-5">76 {tab === "unified" && (lines.length ? lines.map((l, i) => (77 <div key={i} className={`grid grid-cols-[1.25rem_1fr] whitespace-pre-wrap break-words px-2 ${cls(l.kind)}`}>78 <span className="select-none text-fg-subtle">{l.kind === "add" ? "+" : l.kind === "del" ? "−" : " "}</span>79 <span>{l.text}</span>80 </div>81 )) : <NoPatch />)}82 {tab === "split" && (rows.length ? (83 <div className="grid grid-cols-2 divide-x divide-line">84 <div>{rows.map((r, i) => <div key={i} className={`min-h-5 whitespace-pre-wrap break-words px-2 ${cls(r.left?.kind === "add" ? "ctx" : r.left?.kind)}`}>{r.left?.kind === "add" ? "" : r.left?.text ?? ""}</div>)}</div>85 <div>{rows.map((r, i) => <div key={i} className={`min-h-5 whitespace-pre-wrap break-words px-2 ${cls(r.right?.kind === "del" ? "ctx" : r.right?.kind)}`}>{r.right?.kind === "del" ? "" : r.right?.text ?? ""}</div>)}</div>86 </div>87 ) : <NoPatch />)}88 {tab === "semantic" && <Semantic summary={summary} />}89 {tab === "raw" && (unified ? <pre className="whitespace-pre-wrap break-words p-3">{unified}</pre> : <NoPatch />)}90 </div>91 </div>92 );93}9495function NoPatch() {96 return <div className="p-4 text-fg-subtle">No textual patch stored for this change.</div>;97}9899function Semantic({ summary }: { summary: DiffSummary | null | undefined }) {100 if (!summary) return <div className="p-4 text-fg-subtle">No structured summary.</div>;101 const str = (v: unknown): string => (typeof v === "string" ? v : v && typeof v === "object" ? String((v as { title?: unknown; url?: unknown; key?: unknown }).title ?? (v as { url?: unknown }).url ?? (v as { key?: unknown }).key ?? JSON.stringify(v)) : JSON.stringify(v));102 if (summary.kind === "json") {103 return (104 <div className="p-2">105 {(summary.changes ?? []).map((c, i) => (106 <div key={i} className="grid grid-cols-[auto_1fr] gap-x-3 px-2 py-1 hairline">107 <span className={c.op === "add" ? "text-ok" : c.op === "remove" ? "text-danger" : "text-info"}>{c.op}</span>108 <span className="whitespace-pre-wrap break-words">109 <span className="text-fg-muted">{c.path}</span>110 {c.op !== "add" && <span className="diff-line-del ml-2 rounded px-1">{JSON.stringify(c.before)}</span>}111 {c.op !== "remove" && <span className="diff-line-add ml-2 rounded px-1">{JSON.stringify(c.after)}</span>}112 </span>113 </div>114 ))}115 {!summary.changes?.length && <NoPatch />}116 </div>117 );118 }119 const mods = (summary.modified ?? []) as ({ before: unknown; after: unknown; fields?: string[]; key?: string })[];120 return (121 <div className="p-2 text-[12.5px]">122 {(summary.added ?? []).length > 0 && (123 <Section title={summary.kind === "list" ? "New items" : "Added"} tone="ok">124 {(summary.added ?? []).map((a, i) => (125 <Item key={i} tone="ok">126 {str(a)}127 {typeof a === "object" && a && (a as { summary?: string }).summary && <div className="text-fg-muted">{String((a as { summary?: string }).summary).slice(0, 400)}</div>}128 {typeof a === "object" && a && (a as { url?: string }).url && (a as { title?: string }).title && <div className="truncate text-fg-subtle">{String((a as { url?: string }).url)}</div>}129 </Item>130 ))}131 </Section>132 )}133 {(summary.removed ?? []).length > 0 && (134 <Section title={summary.kind === "list" ? "Removed items" : "Removed"} tone="danger">135 {(summary.removed ?? []).map((a, i) => <Item key={i} tone="danger">{str(a)}</Item>)}136 </Section>137 )}138 {mods.length > 0 && (139 <Section title={summary.kind === "list" ? "Updated items" : "Modified lines"} tone="info">140 {mods.map((m, i) => (141 <div key={i} className="px-2 py-1 hairline">142 {m.fields && <div className="text-[11px] text-fg-subtle">{str(m.after)} · {m.fields.join(", ")}</div>}143 <div className="diff-line-del whitespace-pre-wrap break-words rounded px-1">{m.fields ? m.fields.map((f) => `${f}: ${JSON.stringify((m.before as Record<string, unknown>)[f])}`).join(" · ") : str(m.before)}</div>144 <div className="diff-line-add mt-0.5 whitespace-pre-wrap break-words rounded px-1">{m.fields ? m.fields.map((f) => `${f}: ${JSON.stringify((m.after as Record<string, unknown>)[f])}`).join(" · ") : str(m.after)}</div>145 </div>146 ))}147 </Section>148 )}149 {summary.truncated && <div className="px-2 py-1 text-[11px] text-fg-subtle">Summary truncated — see the Unified tab for the full patch.</div>}150 {!(summary.added ?? []).length && !(summary.removed ?? []).length && !mods.length && <NoPatch />}151 </div>152 );153}154155function Section({ title, tone, children }: { title: string; tone: "ok" | "danger" | "info"; children: React.ReactNode }) {156 return (157 <div className="mb-2">158 <div className={`label mb-1 px-2 ${tone === "ok" ? "!text-ok" : tone === "danger" ? "!text-danger" : "!text-info"}`}>{title}</div>159 {children}160 </div>161 );162}163function Item({ tone, children }: { tone: "ok" | "danger"; children: React.ReactNode }) {164 return <div className={`whitespace-pre-wrap break-words rounded px-2 py-0.5 ${tone === "ok" ? "diff-line-add" : "diff-line-del"}`}>{children}</div>;165}166