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(
``,
);
}