import type * as DiffLib from "diff"; import DiffMatchPatch from "diff-match-patch"; import { escapeHtml } from "./html.ts"; import { normalizeLF } from "./normalize.ts"; const dmp = new DiffMatchPatch(); // Lazy-loaded — only history pages need it. let diffLib: typeof DiffLib | null = null; function getDiff(): typeof DiffLib { if (!diffLib) diffLib = require("diff") as typeof DiffLib; return diffLib; } const INSERT = 1; const DELETE = -1; const EQUAL = 0; function renderEqual(text: string): string { return `${escapeHtml(text)}`; } // Keep ins/del backgrounds off edge whitespace, else padding shows as a stray colored gap. function splitWhitespaceEdges(text: string): { lead: string; body: string; trail: string; } { const lead = text.match(/^\s*/)?.[0] ?? ""; const trail = text.match(/\s*$/)?.[0] ?? ""; const body = text.slice(lead.length, text.length - trail.length); return { lead, body, trail }; } function renderOne([op, text]: [number, string]): string { if (op !== INSERT && op !== DELETE) return renderEqual(text); if (text.trim().length === 0) return ""; const { lead, body, trail } = splitWhitespaceEdges(text); const tag = op === INSERT ? `${escapeHtml(body)}` : `${escapeHtml(body)}`; return `${escapeHtml(lead)}${tag}${escapeHtml(trail)}`; } const CONTEXT_LINES = 2; const HUNK_SEPARATOR = `
⋯
`; // Collapse long equal regions to ±CONTEXT_LINES of context, git-style. function renderDiffs(diffs: [number, string][]): string { const out: string[] = []; for (let i = 0; i < diffs.length; i++) { const diff = diffs[i]; if (!diff) continue; const [op, text] = diff; if (op !== EQUAL) { out.push(renderOne(diff)); continue; } const lines = text.split("\n"); const isFirst = i === 0; const isLast = i === diffs.length - 1; if (isFirst && isLast) { out.push(renderEqual(text)); } else if (isFirst) { if (lines.length <= CONTEXT_LINES) { out.push(renderEqual(text)); } else { out.push(HUNK_SEPARATOR); out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); } } else if (isLast) { if (lines.length <= CONTEXT_LINES) { out.push(renderEqual(text)); } else { out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); out.push(HUNK_SEPARATOR); } } else { if (lines.length <= CONTEXT_LINES * 2) { out.push(renderEqual(text)); } else { out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); out.push(HUNK_SEPARATOR); out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); } } } return `
${out.join("")}
`; } function toTuples(changes: DiffLib.Change[]): [number, string][] { return changes.map((c) => { const op = c.added ? INSERT : c.removed ? DELETE : EQUAL; return [op, c.value]; }); } // Below this shared-content ratio, word-level refinement turns a rewrite into zebra striping. const REFINE_SIMILARITY = 0.4; function shouldRefine( before: string, after: string, lib: typeof DiffLib, ): boolean { const inner = lib.diffWordsWithSpace(before, after); let equal = 0; for (const c of inner) { if (!c.added && !c.removed) equal += c.value.length; } return equal / (before.length + after.length) >= REFINE_SIMILARITY; } // Pair removed+added lines, then word-diff inside each pair when similar enough to read as an edit. function lineThenWord( before: string, after: string, lib: typeof DiffLib, ): [number, string][] { const lines = lib.diffLines(before, after); const out: [number, string][] = []; for (let i = 0; i < lines.length; i++) { const cur = lines[i]; const next = lines[i + 1]; if (!cur) continue; if (cur.removed && next?.added) { if (shouldRefine(cur.value, next.value, lib)) { out.push(...toTuples(lib.diffWordsWithSpace(cur.value, next.value))); } else { out.push([DELETE, cur.value], [INSERT, next.value]); } i++; } else { out.push(...toTuples([cur])); } } return out; } export function renderContentDiff(before: string, after: string): string { const beforeLF = normalizeLF(before); const afterLF = normalizeLF(after); if (beforeLF === afterLF) return ""; return renderDiffs(lineThenWord(beforeLF, afterLF, getDiff())); } // Fallback for revisions older than the snapshot cap: render a stored dmp patch directly. export function renderDiffInline(patchText: string): string { if (!patchText.trim()) return ""; let patches: { diffs: [number, string][] }[]; try { patches = dmp.patch_fromText(patchText) as unknown as { diffs: [number, string][]; }[]; } catch { return `
${escapeHtml(patchText)}
`; } const hunks = patches.map((patch) => { const diffs = patch.diffs.map(([op, text]) => [op, text]) as [ number, string, ][]; dmp.diff_cleanupSemantic( diffs as unknown as Parameters[0], ); return renderDiffs(diffs); }); return hunks.join( `
`, ); }