/** * [Gitea #144] Word-level diff for the "(edited)" hover. Plain text only; the * caller decides what to do with formatted bodies. LCS over word tokens, with * whitespace preserved on the token that precedes it so the rendered result * reads like the original message. */ export type DiffOp = 'same' | 'add' | 'del'; export type DiffToken = { op: DiffOp; text: string }; /** Above this many words (before + after) we give up: O(n·m) memory and an unreadable tooltip. */ export const WORD_DIFF_LIMIT = 400; const tokenize = (text: string): string[] => text.match(/\S+\s*|\s+/g) ?? []; const word = (token: string) => token.trim(); /** * Diff `before` → `after` into runs. Returns `undefined` when the texts are * identical or too long to diff meaningfully (caller should fall back to the * full history viewer). */ export function wordDiff(before: string, after: string): DiffToken[] | undefined { if (before === after) return undefined; const a = tokenize(before); const b = tokenize(after); if (a.length + b.length > WORD_DIFF_LIMIT) return undefined; // LCS table on trimmed words so whitespace-only changes don't count as edits. const n = a.length; const m = b.length; const lcs: Uint16Array[] = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1)); for (let i = n - 1; i >= 0; i -= 1) { for (let j = m - 1; j >= 0; j -= 1) { lcs[i][j] = word(a[i]) === word(b[j]) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } } const out: DiffToken[] = []; const push = (op: DiffOp, text: string) => { const last = out[out.length - 1]; if (last && last.op === op) last.text += text; else out.push({ op, text }); }; let i = 0; let j = 0; while (i < n && j < m) { if (word(a[i]) === word(b[j])) { push('same', b[j]); i += 1; j += 1; } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { push('del', a[i]); i += 1; } else { push('add', b[j]); j += 1; } } while (i < n) push('del', a[i++]); while (j < m) push('add', b[j++]); // Only whitespace moved around: nothing worth showing. if (out.every((t) => t.op === 'same' || t.text.trim() === '')) return undefined; return out; } /** Number of changed words in a diff (for a compact summary). */ export const countChanges = (tokens: DiffToken[]): { added: number; removed: number } => { let added = 0; let removed = 0; tokens.forEach((t) => { const words = t.text.trim() === '' ? 0 : t.text.trim().split(/\s+/).length; if (t.op === 'add') added += words; if (t.op === 'del') removed += words; }); return { added, removed }; };