/** * Minimal unified-diff for the cockpit (DRAFT-001 v0.3.0 B2). * * LCS-based line diff with hunked output and 3 lines of context. No * external deps. Designed for spec-sized files (hundreds of lines, not * thousands); the O(nm) LCS table is fine at that scale. */ const CONTEXT = 3; export type DiffLineKind = "context" | "add" | "remove" | "hunk"; export interface DiffLine { kind: DiffLineKind; /** 1-based original line number (for context/remove). */ origLine?: number; /** 1-based new line number (for context/add). */ newLine?: number; /** Display text, without leading marker (caller adds " ", "+", "-"). */ text: string; } export function unifiedDiff(original: string | null, current: string): DiffLine[] { const a = (original ?? "").split("\n"); const b = current.split("\n"); // Trim trailing empty line that comes from "abc\n".split("\n") → ["abc", ""]. if (a.length > 0 && a[a.length - 1] === "" && original !== null) a.pop(); if (b.length > 0 && b[b.length - 1] === "") b.pop(); if (original === null) { // New file: every line is an add. return b.map((text, i) => ({ kind: "add" as const, newLine: i + 1, text })); } const ops = lcsDiff(a, b); return hunkify(ops); } interface Op { kind: "eq" | "add" | "del"; origLine?: number; // 1-based, set for eq and del newLine?: number; // 1-based, set for eq and add text: string; } function lcsDiff(a: string[], b: string[]): Op[] { const n = a.length; const m = b.length; // LCS table. const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { if (a[i] === b[j]) dp[i]![j] = dp[i + 1]![j + 1]! + 1; else dp[i]![j] = Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!); } } const ops: Op[] = []; let i = 0; let j = 0; while (i < n && j < m) { if (a[i] === b[j]) { ops.push({ kind: "eq", origLine: i + 1, newLine: j + 1, text: a[i]! }); i++; j++; } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) { ops.push({ kind: "del", origLine: i + 1, text: a[i]! }); i++; } else { ops.push({ kind: "add", newLine: j + 1, text: b[j]! }); j++; } } while (i < n) { ops.push({ kind: "del", origLine: i + 1, text: a[i]! }); i++; } while (j < m) { ops.push({ kind: "add", newLine: j + 1, text: b[j]! }); j++; } return ops; } function hunkify(ops: Op[]): DiffLine[] { // Find indices of changed (non-eq) ops. const changed: number[] = []; ops.forEach((op, idx) => { if (op.kind !== "eq") changed.push(idx); }); if (changed.length === 0) return []; // Group changed indices into hunks: any two that are within CONTEXT*2+1 of // each other share a hunk so their context windows overlap. const hunkRanges: Array<{ from: number; to: number }> = []; let curFrom = Math.max(0, changed[0]! - CONTEXT); let curTo = Math.min(ops.length - 1, changed[0]! + CONTEXT); for (let k = 1; k < changed.length; k++) { const c = changed[k]!; const lo = Math.max(0, c - CONTEXT); const hi = Math.min(ops.length - 1, c + CONTEXT); if (lo <= curTo + 1) { curTo = Math.max(curTo, hi); } else { hunkRanges.push({ from: curFrom, to: curTo }); curFrom = lo; curTo = hi; } } hunkRanges.push({ from: curFrom, to: curTo }); const out: DiffLine[] = []; for (const { from, to } of hunkRanges) { // Compute hunk header line numbers. let origStart = 0; let newStart = 0; let origCount = 0; let newCount = 0; for (let k = from; k <= to; k++) { const op = ops[k]!; if (op.kind !== "add") { if (origStart === 0 && op.origLine) origStart = op.origLine; origCount++; } if (op.kind !== "del") { if (newStart === 0 && op.newLine) newStart = op.newLine; newCount++; } } out.push({ kind: "hunk", text: `@@ -${origStart || 0},${origCount} +${newStart || 0},${newCount} @@`, }); for (let k = from; k <= to; k++) { const op = ops[k]!; out.push({ kind: op.kind === "eq" ? "context" : op.kind === "del" ? "remove" : "add", origLine: op.origLine, newLine: op.newLine, text: op.text, }); } } return out; }