// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP // hands over full `oldText`/`newText`, not a unified-diff patch, so the // coloured hunks the design calls for have to be computed here. export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof"; export interface DiffLine { kind: DiffLineKind; /** The line's text (a `hunk` line is a human summary, not source text). */ text: string; } /** Interior unchanged runs longer than this collapse to a summary line * flanked by this many lines of real context on each side. */ const CONTEXT = 3; /** Bounds the O(n·m) LCS table: above this cell count, the changed middle is * shown as one coarse del-then-add block instead of a fine-grained diff — * still correct, just not minimal. */ const LCS_CELL_LIMIT = 250_000; interface SourceLine { text: string; terminated: boolean; } function splitLines(text: string): SourceLine[] { if (text === "") return []; const parts = text.split("\n"); const hasFinalNewline = text.endsWith("\n"); if (hasFinalNewline) parts.pop(); return parts.map((line, index) => ({ text: line, terminated: index < parts.length - 1 || hasFinalNewline, })); } type Op = { kind: "add" | "del" | "ctx"; line: SourceLine }; function sameLine(a: SourceLine, b: SourceLine): boolean { return a.text === b.text && a.terminated === b.terminated; } /** Longest-common-subsequence diff of two line arrays, already trimmed of any * shared prefix/suffix by the caller. */ function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] { const n = a.length; const m = b.length; if (n === 0) return b.map((line) => ({ kind: "add", line })); if (m === 0) return a.map((line) => ({ kind: "del", line })); if (n * m > LCS_CELL_LIMIT) { return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))]; } // dp[i][j] = length of the LCS of a[i:] and b[j:]. const columns = m + 1; const dp = new Uint32Array((n + 1) * columns); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i * columns + j] = sameLine(a[i], b[j]) ? dp[(i + 1) * columns + j + 1] + 1 : Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]); } } const ops: Op[] = []; let i = 0; let j = 0; while (i < n && j < m) { if (sameLine(a[i], b[j])) { ops.push({ kind: "ctx", line: a[i] }); i++; j++; } else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) { ops.push({ kind: "del", line: a[i] }); i++; } else { ops.push({ kind: "add", line: b[j] }); j++; } } while (i < n) ops.push({ kind: "del", line: a[i++] }); while (j < m) ops.push({ kind: "add", line: b[j++] }); return ops; } function diffLines(a: SourceLine[], b: SourceLine[]): Op[] { let pre = 0; while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++; let suf = 0; while ( suf < a.length - pre && suf < b.length - pre && sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf]) ) { suf++; } const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line })); const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line })); const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)); return [...prefixCtx, ...middle, ...suffixCtx]; } /** Collapse a long run of unchanged lines to a summary, keeping up to * {@link CONTEXT} real lines adjacent to each neighbouring change — same * shape a unified diff gives a reader, without claiming line numbers this * module never tracked. A run touching the start/end of the whole diff has * no change to be adjacent to on that side, so it keeps no context there * (unless it is the *only* run, i.e. nothing changed at all). */ function hunk(ops: Op[]): DiffLine[] { const out: DiffLine[] = []; const push = (op: Op) => { out.push({ kind: op.kind, text: op.line.text }); if (!op.line.terminated) { out.push({ kind: "eof", text: "\\ No newline at end of file" }); } }; let i = 0; while (i < ops.length) { if (ops[i].kind !== "ctx") { push(ops[i]); i++; continue; } let j = i; while (j < ops.length && ops[j].kind === "ctx") j++; const run = ops.slice(i, j); const wholeDiff = i === 0 && j === ops.length; const keepHead = wholeDiff || i > 0 ? CONTEXT : 0; const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0; if (run.length <= keepHead + keepTail) { run.forEach(push); } else { if (keepHead) run.slice(0, keepHead).forEach(push); const collapsed = run.slice(keepHead, run.length - keepTail); out.push({ kind: "hunk", text: `⋯ ${collapsed.length} unchanged lines ⋯`, }); if (collapsed.some((op) => !op.line.terminated)) { out.push({ kind: "eof", text: "\\ No newline at end of file" }); } if (keepTail) run.slice(run.length - keepTail).forEach(push); } i = j; } return out; } /** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent * (a new file) renders every line of `newText` as added. */ export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] { const newLines = splitLines(newText); if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line }))); const ops = diffLines(splitLines(oldText), newLines); return hunk(ops); } /** `+added -removed` counts straight off the diff — real derived data, not a * fabricated duration or line count. */ export function diffStats(lines: DiffLine[]): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of lines) { if (line.kind === "add") added++; else if (line.kind === "del") removed++; } return { added, removed }; }