/* ---------------------------------------------------------------------------- DiffView - what changed in a piece of text, with enough context to judge it. One view for knowledge updates, instruction changes, new skills and revision history. Added and removed text is marked with a glyph and a screen-reader label as well as colour; changed lines also highlight the words that changed. Variants: - "inline" (default): one column, removed lines above added ones. - "split": before and after side by side; stacks on narrow widths. - "prose": the text as it reads, with removed words struck through and added words highlighted. For short prose like knowledge entries. -------------------------------------------------------------------------- */ import { Fragment, useId, useMemo, useState, type ReactNode } from "react"; import { ChevronsUpDownIcon } from "lucide-react"; import { cn } from "../lib/cn"; import { Button } from "./button"; import { ErrorMessage, type ErrorDetail } from "./error-message"; import { Skeleton } from "./skeleton"; import { apiErrorTechnicalFacts, userErrorTextWithoutReference } from "./api-error"; /* ============================================================================ Failed loads ========================================================================== */ /** A load that failed: what happened, what to do next, and an optional retry. */ export interface FailedLoad { /** What happened: "Couldn't load the history". */ message: string; /** What to do next. A raw "Opengeni API 404: ..." string is replaced with advice. */ detail?: ReactNode | undefined; /** The error itself: its advice stands in for a missing detail, its facts go behind Technical details. */ cause?: unknown | undefined; onRetry?: (() => void) | undefined; } const RAW_API_ERROR = /^(?:(?:OpenGeni|Opengeni) )?API (\d{3})\b/u; /** * The line under a failed load's title and its Technical details. An API * error never shows as its raw string: the status and reference go behind * Technical details (DESIGN.md section 6). */ export function failedLoadParts(failure: Pick): { detail: ReactNode; details: ErrorDetail[]; } { const raw = typeof failure.detail === "string" ? RAW_API_ERROR.exec(failure.detail) : null; const cause = failure.cause ?? (raw ? Object.assign(new Error(String(failure.detail)), { status: Number(raw[1]) }) : undefined); if (cause === undefined) return { detail: failure.detail, details: [] }; return { detail: failure.detail && !raw ? failure.detail : userErrorTextWithoutReference(cause), details: apiErrorTechnicalFacts(cause), }; } /* ============================================================================ Diff logic (pure) ========================================================================== */ export type DiffLineKind = "context" | "added" | "removed"; export interface DiffLine { kind: DiffLineKind; text: string; } export interface DiffSegment { kind: "same" | "added" | "removed"; text: string; } /** Longest common subsequence table, bottom-up. Inputs are small (a document's lines). */ function lcsMatrix(a: readonly T[], b: readonly T[]): Uint32Array[] { const rows: Uint32Array[] = Array.from( { length: a.length + 1 }, () => new Uint32Array(b.length + 1), ); for (let i = a.length - 1; i >= 0; i -= 1) { const row = rows[i]!; const next = rows[i + 1]!; for (let j = b.length - 1; j >= 0; j -= 1) { row[j] = a[i] === b[j] ? next[j + 1]! + 1 : Math.max(next[j]!, row[j + 1]!); } } return rows; } function diffSequence( a: readonly T[], b: readonly T[], ): Array<{ kind: "same" | "added" | "removed"; value: T }> { const table = lcsMatrix(a, b); const out: Array<{ kind: "same" | "added" | "removed"; value: T }> = []; let i = 0; let j = 0; while (i < a.length && j < b.length) { if (a[i] === b[j]) { out.push({ kind: "same", value: a[i]! }); i += 1; j += 1; } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { out.push({ kind: "removed", value: a[i]! }); i += 1; } else { out.push({ kind: "added", value: b[j]! }); j += 1; } } while (i < a.length) out.push({ kind: "removed", value: a[i++]! }); while (j < b.length) out.push({ kind: "added", value: b[j++]! }); return out; } function splitLines(text: string): string[] { if (text === "") return []; return text.replace(/\r\n?/g, "\n").split("\n"); } /** * Line diff of two texts. Within each changed block, removed lines come * before added ones, like a reviewer reads them. */ export function diffLines(before: string, after: string): DiffLine[] { const raw = diffSequence(splitLines(before), splitLines(after)); const out: DiffLine[] = []; let removed: string[] = []; let added: string[] = []; const flush = () => { for (const text of removed) out.push({ kind: "removed", text }); for (const text of added) out.push({ kind: "added", text }); removed = []; added = []; }; for (const step of raw) { if (step.kind === "same") { flush(); out.push({ kind: "context", text: step.value }); } else if (step.kind === "removed") removed.push(step.value); else added.push(step.value); } flush(); return out; } /** Word diff of two lines. Whitespace stays attached so the text reads naturally. */ export function diffWords(before: string, after: string): DiffSegment[] { const tokens = (text: string) => text.match(/\s+|[\p{L}\p{N}_'-]+|[^\s\p{L}\p{N}_'-]/gu) ?? []; const merged: DiffSegment[] = []; for (const step of diffSequence(tokens(before), tokens(after))) { const last = merged.at(-1); if (last && last.kind === step.kind) last.text += step.value; else merged.push({ kind: step.kind, text: step.value }); } // Fold whitespace that sits between two changes of the same kind into them, // so "since the 1 Sep storage migration" reads as one change, not six. const folded: DiffSegment[] = []; for (let index = 0; index < merged.length; index += 1) { const segment = merged[index]!; const previous = folded.at(-1); const next = merged[index + 1]; const bridging = segment.kind === "same" && /^\s+$/.test(segment.text) && previous !== undefined && next !== undefined && previous.kind !== "same" && previous.kind === next.kind; if (bridging) { previous.text += segment.text; continue; } if (previous && previous.kind === segment.kind) previous.text += segment.text; else folded.push({ ...segment }); } return folded; } export function diffStats(lines: readonly DiffLine[]): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of lines) { if (line.kind === "added") added += 1; else if (line.kind === "removed") removed += 1; } return { added, removed }; } /** A row the views render: a single line, a changed pair, or a run of hidden context. */ export type DiffRow = | { type: "line"; line: DiffLine; words?: DiffSegment[] | undefined } | { type: "collapsed"; lines: DiffLine[]; id: string }; /** * Pairs removed and added lines one to one for word highlights, and hides * long runs of unchanged lines, keeping `context` lines around each change. */ export function buildDiffRows(lines: readonly DiffLine[], context = 3): DiffRow[] { const rows: DiffRow[] = []; let index = 0; while (index < lines.length) { const line = lines[index]!; if (line.kind === "context") { let end = index; while (end < lines.length && lines[end]!.kind === "context") end += 1; const run = lines.slice(index, end); const atStart = index === 0; const atEnd = end === lines.length; const keepBefore = atStart ? 0 : context; const keepAfter = atEnd ? 0 : context; if (Number.isFinite(context) && run.length > keepBefore + keepAfter + 1) { for (const each of run.slice(0, keepBefore)) rows.push({ type: "line", line: each }); rows.push({ type: "collapsed", id: `collapsed-${index}`, lines: run.slice(keepBefore, run.length - keepAfter), }); for (const each of run.slice(run.length - keepAfter)) rows.push({ type: "line", line: each }); } else { for (const each of run) rows.push({ type: "line", line: each }); } index = end; continue; } // A changed block: removed lines, then added lines. let end = index; while (end < lines.length && lines[end]!.kind !== "context") end += 1; const block = lines.slice(index, end); const removed = block.filter((each) => each.kind === "removed"); const added = block.filter((each) => each.kind === "added"); const paired = removed.length === added.length; removed.forEach((each, at) => rows.push({ type: "line", line: each, words: paired ? diffWords(each.text, added[at]!.text).filter((s) => s.kind !== "added") : undefined, }), ); added.forEach((each, at) => rows.push({ type: "line", line: each, words: paired ? diffWords(removed[at]!.text, each.text).filter((s) => s.kind !== "removed") : undefined, }), ); index = end; } return rows; } /* ============================================================================ Rendering ========================================================================== */ export type DiffViewVariant = "inline" | "split" | "prose"; export type DiffFormat = "text" | "markdown" | "code"; export interface DiffViewProps { /** Precomputed lines, for example from the server. */ lines?: readonly DiffLine[] | undefined; /** Or two texts to compare. */ before?: string | undefined; after?: string | undefined; variant?: DiffViewVariant | undefined; /** * "markdown" (default) shows headings and bullets as they read; "text" * shows lines as they are; "code" uses the mono face. */ format?: DiffFormat | undefined; /** "md" is 14px text; "sm" is 12px for lists and review queues. */ size?: "sm" | "md" | undefined; /** Unchanged lines kept around each change before the rest collapses. Infinity shows all. */ context?: number | undefined; /** A heading inside the frame, for example the entry's name. */ title?: ReactNode | undefined; /** Quiet line under the title: "From schedule Weekly dependency update PR". */ meta?: ReactNode | undefined; /** Buttons on the right of the header: Approve, Restore. */ actions?: ReactNode | undefined; /** Hide the added and removed counts. */ hideStats?: boolean | undefined; /** Accessible name when there is no title. */ label?: string | undefined; loading?: boolean | undefined; /** What happened, what to do next, and an optional retry. */ error?: FailedLoad | undefined; /** Shown when nothing changed. */ emptyMessage?: ReactNode | undefined; /** No frame: for use inside a sheet section or a review pane that has its own. */ bare?: boolean | undefined; className?: string | undefined; } const GLYPH: Record = { context: "", added: "+", removed: "−" }; const SR_LABEL: Record = { context: "", added: "Added: ", removed: "Removed: ", }; function isBlank(text: string) { return text.trim() === ""; } /** Headings and bullets read as text, not as markdown syntax. */ function renderText( text: string, format: DiffFormat, ): { node: ReactNode; heading: boolean; bullet: boolean } { if (format !== "markdown") return { node: text, heading: false, bullet: false }; const heading = /^(#{1,6})\s+(.*)$/.exec(text); if (heading) return { node: heading[2], heading: true, bullet: false }; const bullet = /^\s*[-*+]\s+(.*)$/.exec(text); if (bullet) return { node: bullet[1], heading: false, bullet: true }; return { node: text, heading: false, bullet: false }; } /** Strip the markdown marker from the first segment so word highlights line up. */ function stripMarker(words: DiffSegment[], format: DiffFormat): DiffSegment[] { if (format !== "markdown" || words.length === 0) return words; const [first, ...rest] = words; const stripped = first!.text.replace(/^(#{1,6}\s+|\s*[-*+]\s+)/, ""); return stripped === "" ? rest : [{ ...first!, text: stripped }, ...rest]; } function Words({ words, kind }: { words: DiffSegment[]; kind: DiffLineKind }) { return ( <> {words.map((segment, index) => segment.kind === "same" ? ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder {segment.text} ) : kind === "added" ? ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder {segment.text} ) : ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder {segment.text} ), )} ); } function LineRow({ line, words, format, size, side, }: { line: DiffLine; words?: DiffSegment[] | undefined; format: DiffFormat; size: "sm" | "md"; /** In split view, which column this row is in. */ side?: "before" | "after" | undefined; }) { const { node, heading, bullet } = renderText(line.text, format); const blank = isBlank(line.text); return (
{bullet ? ( ) : null} {SR_LABEL[line.kind] ? {SR_LABEL[line.kind]} : null} {words ? : node}
); } function CollapsedRow({ count, onExpand, size, }: { count: number; onExpand: () => void; size: "sm" | "md"; }) { return ( ); } function Stats({ added, removed }: { added: number; removed: number }) { return ( {added > 0 ? ( +{added} added ) : null} {removed > 0 ? ( −{removed} removed ) : null} ); } function InlineBody({ rows, format, size, expanded, expand, }: { rows: DiffRow[]; format: DiffFormat; size: "sm" | "md"; expanded: Set; expand: (id: string) => void; }) { return (
{rows.map((row, index) => row.type === "collapsed" ? ( expanded.has(row.id) ? ( row.lines.map((line, at) => ( // oxlint-disable-next-line react/no-array-index-key -- lines never reorder )) ) : ( expand(row.id)} /> ) ) : ( // oxlint-disable-next-line react/no-array-index-key -- rows never reorder ), )}
); } function SplitBody({ rows, format, size, expanded, expand, }: { rows: DiffRow[]; format: DiffFormat; size: "sm" | "md"; expanded: Set; expand: (id: string) => void; }) { // Align each changed block: removed on the left, added on the right. type Pair = | { key: string; before?: (DiffRow & { type: "line" }) | undefined; after?: (DiffRow & { type: "line" }) | undefined; } | { collapsed: DiffRow & { type: "collapsed" } }; const pairs: Pair[] = []; const key = () => `row-${pairs.length}`; let index = 0; while (index < rows.length) { const row = rows[index]!; if (row.type === "collapsed") { if (expanded.has(row.id)) { for (const line of row.lines) { const each = { type: "line" as const, line }; pairs.push({ key: key(), before: each, after: each }); } } else pairs.push({ collapsed: row }); index += 1; continue; } if (row.line.kind === "context") { pairs.push({ key: key(), before: row, after: row }); index += 1; continue; } const removed: Array = []; const added: Array = []; while (index < rows.length) { const next = rows[index]!; if (next.type !== "line" || next.line.kind === "context") break; if (next.line.kind === "removed") removed.push(next); else added.push(next); index += 1; } for (let at = 0; at < Math.max(removed.length, added.length); at += 1) { pairs.push({ key: key(), before: removed[at], after: added[at] }); } } const column = (side: "before" | "after") => (
{side === "before" ? "Before" : "After"}
{pairs.map((pair) => { if ("collapsed" in pair) { return ( expand(pair.collapsed.id)} /> ); } const row = side === "before" ? pair.before : pair.after; if (!row) { return ( ); return (
{column("before")} {column("after")}
); } function ProseBody({ lines, format, size, }: { lines: readonly DiffLine[]; format: DiffFormat; size: "sm" | "md"; }) { // Pair changes into single flowing lines where possible. const rows = buildDiffRows(lines, Number.POSITIVE_INFINITY); const out: ReactNode[] = []; let index = 0; while (index < rows.length) { const row = rows[index]!; if (row.type !== "line") { index += 1; continue; } const next = rows[index + 1]; const pairedRemoved = row.line.kind === "removed" && next?.type === "line" && next.line.kind === "added" && row.words; if (pairedRemoved && next.type === "line") { const words = stripMarker(diffWords(row.line.text, next.line.text), format); const { heading, bullet } = renderText(next.line.text, format); out.push(

{bullet ? ( ) : null} Changed: {words.map((segment, at) => segment.kind === "same" ? ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder {segment.text} ) : segment.kind === "added" ? ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder added {segment.text} ) : ( // oxlint-disable-next-line react/no-array-index-key -- segments never reorder removed {segment.text} ), )}

, ); index += 2; continue; } const { node, heading, bullet } = renderText(row.line.text, format); if (isBlank(row.line.text)) { index += 1; continue; } out.push(

{bullet ? ( ) : null} {row.line.kind === "added" ? ( Added: {node} ) : row.line.kind === "removed" ? ( Removed: {node} ) : ( node )}

, ); index += 1; } return (
{out}
); } /** Loading placeholder lines, shaped like a short document. */ function LoadingBody() { return ( ); } /** Inline added and removed text with context. */ export function DiffView({ lines: linesProp, before = "", after = "", variant = "inline", format = "markdown", size = "md", context = 3, title, meta, actions, hideStats, label, loading, error, emptyMessage = "No changes. This version matches the one before it.", bare, className, }: DiffViewProps) { const titleId = useId(); const lines = useMemo(() => { const all = linesProp ?? diffLines(before, after); // Blank lines only space out markdown; as diff rows they'd be empty "+" rows. return format === "markdown" ? all.filter((line) => !isBlank(line.text)) : all; }, [after, before, format, linesProp]); const rows = useMemo(() => buildDiffRows(lines, context), [context, lines]); const [expanded, setExpanded] = useState>(() => new Set()); const expand = (id: string) => setExpanded((current) => new Set(current).add(id)); const stats = diffStats(lines); const failure = error ? failedLoadParts(error) : undefined; const unchanged = !loading && !error && stats.added === 0 && stats.removed === 0; const hasHeader = Boolean( title || meta || actions || (!hideStats && !loading && !error && !unchanged), ); return (
{hasHeader ? (
{title ? (
{title} {!hideStats && !loading && !error && !unchanged ? : null}
) : !hideStats && !loading && !error && !unchanged ? ( ) : null} {meta ?
{meta}
: null}
{actions ? (
{actions}
) : null}
) : null} {loading ? ( <> Loading the changes ) : error ? ( Try again ) : undefined } className="px-4 py-4" > {failure?.detail} ) : unchanged ? (

{emptyMessage}

) : variant === "prose" ? ( ) : variant === "split" ? ( ) : ( )}
); }