SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
8.7 KB · 166 lines tsx
Raw Blame History
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