// 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"; 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; function splitLines(text: string): string[] { if (text === "") return []; const body = text.endsWith("\n") ? text.slice(0, -1) : text; return body.split("\n"); } type Op = { kind: "add" | "del" | "ctx"; text: string }; /** Longest-common-subsequence diff of two line arrays, already trimmed of any * shared prefix/suffix by the caller. */ function lcsDiff(a: string[], b: string[]): Op[] { const n = a.length; const m = b.length; if (n === 0) return b.map((text) => ({ kind: "add", text })); if (m === 0) return a.map((text) => ({ kind: "del", text })); if (n * m > LCS_CELL_LIMIT) { return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))]; } // dp[i][j] = length of the LCS of a[i:] and b[j:]. const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : 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: "ctx", text: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ kind: "del", text: a[i] }); i++; } else { ops.push({ kind: "add", text: b[j] }); j++; } } while (i < n) ops.push({ kind: "del", text: a[i++] }); while (j < m) ops.push({ kind: "add", text: b[j++] }); return ops; } function diffLines(a: string[], b: string[]): Op[] { let pre = 0; while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++; let suf = 0; while ( suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf] ) { suf++; } const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text })); const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text })); 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[] = []; let i = 0; while (i < ops.length) { if (ops[i].kind !== "ctx") { out.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) { out.push(...run); } else { if (keepHead) out.push(...run.slice(0, keepHead)); out.push({ kind: "hunk", text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`, }); if (keepTail) out.push(...run.slice(run.length - keepTail)); } 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 newLines.map((text) => ({ kind: "add", text })); 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 }; }