// Adapted from jalcoui (MIT) — github.com/jal-co/ui // // Line-level diff via Longest Common Subsequence. The jalcoui original // uses the `diff` npm package (Myers diff + unified patches). We don't // pull that dependency: a 60-line LCS in TS handles up to ~5k lines // fast enough for the viewer's use case (small/medium code reviews) and // keeps the bundle lean. For pre-computed patches the caller can pass // `patch` and we parse the unified-diff text directly — no algorithm // runs at all. import type { DiffLine } from '../types'; /** * Compute line-level diff between two strings. Returns a flat sequence * of `DiffLine`s in display order. `context` controls how many unchanged * lines are emitted around each change-hunk; pass `Infinity` to keep the * full file. */ export function diffLines( oldCode: string, newCode: string, context = 3, ): DiffLine[] { const oldLines = oldCode.split('\n'); const newLines = newCode.split('\n'); // Build LCS table (rows = oldLines + 1, cols = newLines + 1). // For each cell, store the length of the longest common subsequence // of `oldLines[0..i]` and `newLines[0..j]`. const m = oldLines.length; const n = newLines.length; // Use a single flat Int32Array for cache friendliness. const lcs = new Int32Array((m + 1) * (n + 1)); const cols = n + 1; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { const idx = i * cols + j; if (oldLines[i - 1] === newLines[j - 1]) { lcs[idx] = lcs[(i - 1) * cols + (j - 1)] + 1; } else { const up = lcs[(i - 1) * cols + j]; const left = lcs[i * cols + (j - 1)]; lcs[idx] = up >= left ? up : left; } } } // Walk back from (m, n) to (0, 0) emitting tagged lines. type Tagged = { type: DiffLine['type']; oldNum: number | null; newNum: number | null; content: string }; const tagged: Tagged[] = []; let i = m; let j = n; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { tagged.push({ type: 'context', oldNum: i, newNum: j, content: oldLines[i - 1] }); i--; j--; } else if (j > 0 && (i === 0 || lcs[i * cols + (j - 1)] >= lcs[(i - 1) * cols + j])) { tagged.push({ type: 'added', oldNum: null, newNum: j, content: newLines[j - 1] }); j--; } else if (i > 0) { tagged.push({ type: 'removed', oldNum: i, newNum: null, content: oldLines[i - 1] }); i--; } } tagged.reverse(); // Apply context window — drop runs of `context`-only lines that are // farther than `context` from any change. `Infinity` keeps every line. if (!Number.isFinite(context)) { return tagged.map((t) => ({ type: t.type, content: t.content, oldNumber: t.oldNum, newNumber: t.newNum, })); } // Mark indices that are within `context` of a change. const keep = new Array(tagged.length).fill(false); for (let k = 0; k < tagged.length; k++) { if (tagged[k].type !== 'context') { const from = Math.max(0, k - context); const to = Math.min(tagged.length - 1, k + context); for (let s = from; s <= to; s++) keep[s] = true; } } // Drop unkept context lines. We don't render hunk separators (`@@`) — // the viewer is meant for compact diffs, not full-file patches. const out: DiffLine[] = []; for (let k = 0; k < tagged.length; k++) { if (!keep[k]) continue; out.push({ type: tagged[k].type, content: tagged[k].content, oldNumber: tagged[k].oldNum, newNumber: tagged[k].newNum, }); } return out; } /** * Parse a unified diff patch (`@@ -a,b +c,d @@` hunks) into the same * `DiffLine[]` shape `diffLines()` produces. Only the line tags and * numbers from the hunk headers are honored — file headers * (`--- a/file`, `+++ b/file`) are skipped. */ export function parseUnifiedPatch(patch: string): DiffLine[] { const lines: DiffLine[] = []; const HUNK = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/; let oldNum = 0; let newNum = 0; let inHunk = false; for (const raw of patch.split('\n')) { const hunk = HUNK.exec(raw); if (hunk) { oldNum = parseInt(hunk[1], 10); newNum = parseInt(hunk[2], 10); inHunk = true; continue; } if (!inHunk) continue; if (raw.startsWith('--- ') || raw.startsWith('+++ ') || raw.startsWith('\\')) continue; if (raw.startsWith('+')) { lines.push({ type: 'added', content: raw.slice(1), oldNumber: null, newNumber: newNum++ }); } else if (raw.startsWith('-')) { lines.push({ type: 'removed', content: raw.slice(1), oldNumber: oldNum++, newNumber: null }); } else { const content = raw.startsWith(' ') ? raw.slice(1) : raw; lines.push({ type: 'context', content, oldNumber: oldNum++, newNumber: newNum++ }); } } return lines; } /** Widest gutter required for the line numbers, in characters. */ export function lineNumberWidth(lines: DiffLine[]): number { let max = 0; for (const line of lines) { if (line.oldNumber && line.oldNumber > max) max = line.oldNumber; if (line.newNumber && line.newNumber > max) max = line.newNumber; } return Math.max(String(max).length, 2); } /** Count `+` and `-` lines for the header stat pill. */ export function computeStats(lines: DiffLine[]): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of lines) { if (line.type === 'added') added++; else if (line.type === 'removed') removed++; } return { added, removed }; }