108 lines
4.4 KiB
JavaScript
108 lines
4.4 KiB
JavaScript
/*
|
|
* shared/diff.js — a small, dependency-free text diff.
|
|
*
|
|
* Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which
|
|
* creates the window.zddc object). Used by the browse tool's markdown
|
|
* version-history viewer to show what changed between any two saved
|
|
* versions; kept in shared/ so other tools can reuse it.
|
|
*
|
|
* API:
|
|
* window.zddc.diff.lines(oldStr, newStr)
|
|
* → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS)
|
|
* window.zddc.diff.words(oldStr, newStr)
|
|
* → [{ type: 'eq'|'del'|'add', text }] token-level diff for one
|
|
* changed line (whitespace-preserving), for intra-line highlights
|
|
* window.zddc.diff.stats(ops) → { added, removed }
|
|
*
|
|
* The line diff trims the common prefix/suffix before running the O(n*m)
|
|
* LCS dynamic program, so a small edit in a large file stays cheap. A
|
|
* safety cap falls back to "replace whole block" when the changed middle
|
|
* is pathologically large, so the UI never freezes.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback
|
|
|
|
function splitLines(s) {
|
|
return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n');
|
|
}
|
|
|
|
// LCS diff of two arrays of strings → ordered [{type, text}] ops.
|
|
function lcsDiff(a, b) {
|
|
var n = a.length, m = b.length;
|
|
if (n === 0 && m === 0) return [];
|
|
if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; });
|
|
if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; });
|
|
|
|
if (n * m > LCS_CELL_CAP) {
|
|
// Too large to diff finely without risking a UI stall: treat
|
|
// the whole block as a wholesale replacement.
|
|
var out = a.map(function (t) { return { type: 'del', text: t }; });
|
|
return out.concat(b.map(function (t) { return { type: 'add', text: t }; }));
|
|
}
|
|
|
|
// dp[i][j] = LCS length of a[i:] and b[j:].
|
|
var dp = new Array(n + 1);
|
|
for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
|
|
for (var ii = n - 1; ii >= 0; ii--) {
|
|
for (var jj = m - 1; jj >= 0; jj--) {
|
|
if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1;
|
|
else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]);
|
|
}
|
|
}
|
|
|
|
var ops = [], i = 0, j = 0;
|
|
while (i < n && j < m) {
|
|
if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; }
|
|
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; }
|
|
else { ops.push({ type: 'add', text: b[j] }); j++; }
|
|
}
|
|
while (i < n) ops.push({ type: 'del', text: a[i++] });
|
|
while (j < m) ops.push({ type: 'add', text: b[j++] });
|
|
return ops;
|
|
}
|
|
|
|
function diffLines(oldStr, newStr) {
|
|
var a = splitLines(oldStr), b = splitLines(newStr);
|
|
var ops = [];
|
|
|
|
// Common prefix.
|
|
var start = 0;
|
|
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
// Common suffix (not overlapping the prefix).
|
|
var endA = a.length, endB = b.length;
|
|
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
|
|
for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] });
|
|
var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB));
|
|
for (var k = 0; k < mid.length; k++) ops.push(mid[k]);
|
|
for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] });
|
|
return ops;
|
|
}
|
|
|
|
// Whitespace-preserving tokenization: words and the runs of
|
|
// whitespace between them are separate tokens, so a re-diff lines up
|
|
// on word boundaries while keeping the original spacing renderable.
|
|
function tokenize(s) {
|
|
return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; });
|
|
}
|
|
|
|
function diffWords(oldStr, newStr) {
|
|
return lcsDiff(tokenize(oldStr), tokenize(newStr));
|
|
}
|
|
|
|
function stats(ops) {
|
|
var added = 0, removed = 0;
|
|
for (var i = 0; i < ops.length; i++) {
|
|
if (ops[i].type === 'add') added++;
|
|
else if (ops[i].type === 'del') removed++;
|
|
}
|
|
return { added: added, removed: removed };
|
|
}
|
|
|
|
if (!window.zddc) {
|
|
throw new Error('shared/diff.js: window.zddc must be loaded first');
|
|
}
|
|
window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats };
|
|
})();
|