ZDDC/shared/diff.js
ZDDC 9972e6773a feat(browse): markdown version-history viewer with diff + restore
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).

- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
  prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
  GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
  the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:00 -05:00

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