// Word-level text diff for the Edit History viewer. Pure and dependency-free. export type DiffSegment = { type: 'equal' | 'added' | 'removed'; text: string; }; // Split into a sequence of tokens where each token is either a run of // whitespace or a run of non-whitespace, so word boundaries and the original // spacing/newlines are both preserved when segments are re-joined. const tokenize = (text: string): string[] => text.match(/\s+|\S+/g) ?? []; // Above this many tokens per side the O(n*m) LCS table gets expensive/large, so // we fall back to a coarse whole-block "replaced" diff. Message bodies this long // are rare; the exact word diff isn't worth a multi-million-cell allocation. const MAX_DIFF_TOKENS = 2000; const coarseDiff = (oldText: string, newText: string): DiffSegment[] => { const segments: DiffSegment[] = []; if (oldText) segments.push({ type: 'removed', text: oldText }); if (newText) segments.push({ type: 'added', text: newText }); return segments; }; /** * Word-level diff of `oldText` → `newText` via a classic LCS. Returns segments * in reading order: `equal` runs, `removed` runs (present only in old), and * `added` runs (present only in new). Consecutive same-type tokens are merged. * Pure — never mutates its inputs. Whitespace is preserved. */ export function diffWords(oldText: string, newText: string): DiffSegment[] { const a = tokenize(oldText); const b = tokenize(newText); if (a.length > MAX_DIFF_TOKENS || b.length > MAX_DIFF_TOKENS) { return coarseDiff(oldText, newText); } const n = a.length; const m = b.length; // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..]. const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i -= 1) { for (let j = m - 1; j >= 0; j -= 1) { lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } } const raw: { type: DiffSegment['type']; text: string }[] = []; let i = 0; let j = 0; while (i < n && j < m) { if (a[i] === b[j]) { raw.push({ type: 'equal', text: a[i] }); i += 1; j += 1; } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { raw.push({ type: 'removed', text: a[i] }); i += 1; } else { raw.push({ type: 'added', text: b[j] }); j += 1; } } while (i < n) { raw.push({ type: 'removed', text: a[i] }); i += 1; } while (j < m) { raw.push({ type: 'added', text: b[j] }); j += 1; } // Merge consecutive same-type tokens into segments. const segments: DiffSegment[] = []; raw.forEach((tok) => { const last = segments[segments.length - 1]; if (last && last.type === tok.type) last.text += tok.text; else segments.push({ type: tok.type, text: tok.text }); }); return segments; }