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>
396 lines
16 KiB
JavaScript
396 lines
16 KiB
JavaScript
// history.js — markdown edit-history viewer for the browse tool.
|
||
//
|
||
// Surfaced by events.js as a "History…" right-click item on files in a
|
||
// history:true cascade subtree (working/). Server mode only — the audit
|
||
// trail (who saved when) is stamped server-side, so there's no offline
|
||
// equivalent.
|
||
//
|
||
// Talks to the zddc-server history endpoints on the file's own URL:
|
||
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
|
||
// GET <url>?history=<sha> → that version's raw bytes
|
||
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
||
// records as a new version (forward-only; never destructive).
|
||
//
|
||
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function toast(msg, kind) {
|
||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||
window.zddc.toast(msg, kind || 'info');
|
||
}
|
||
}
|
||
|
||
// Append ?history=<v> (or &history=) to a file URL.
|
||
function histURL(baseURL, v) {
|
||
var sep = baseURL.indexOf('?') === -1 ? '?' : '&';
|
||
return baseURL + sep + 'history=' + encodeURIComponent(v);
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
var d = new Date(ts);
|
||
if (isNaN(d.getTime())) return ts || '';
|
||
return d.toLocaleString();
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (n == null) return '';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
// Can the principal write (restore) to this file? Mirrors the
|
||
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
||
// backend (no cascade signal) → allow the attempt; otherwise check w.
|
||
function canRestore(node) {
|
||
if (!node || !node.url) return false;
|
||
if (!window.zddc || !window.zddc.cap) return true;
|
||
if (typeof node.verbs !== 'string') return true;
|
||
return window.zddc.cap.has(node, 'w');
|
||
}
|
||
|
||
async function fetchList(node) {
|
||
var resp = await fetch(histURL(node.url, '1'), {
|
||
headers: { 'Accept': 'application/json' },
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
var data = await resp.json();
|
||
return Array.isArray(data) ? data : [];
|
||
}
|
||
|
||
async function fetchVersion(node, sha) {
|
||
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
return await resp.text();
|
||
}
|
||
|
||
// ── Modal shell ──────────────────────────────────────────────────────
|
||
// One overlay; its body is swapped between the list, a diff, and a
|
||
// single-version view. Returns { overlay, body, setTitle, close }.
|
||
function makeModal(titleText) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay md-history-overlay';
|
||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
||
|
||
var box = document.createElement('div');
|
||
box.className = 'md-history-box';
|
||
|
||
var title = document.createElement('h2');
|
||
title.className = 'md-history-title';
|
||
title.textContent = titleText;
|
||
|
||
var body = document.createElement('div');
|
||
body.className = 'md-history-body';
|
||
|
||
box.appendChild(title);
|
||
box.appendChild(body);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
|
||
function close() {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||
document.addEventListener('keydown', onKey);
|
||
overlay.addEventListener('mousedown', function (e) {
|
||
if (e.target === overlay) close();
|
||
});
|
||
|
||
return {
|
||
overlay: overlay,
|
||
body: body,
|
||
setTitle: function (t) { title.textContent = t; },
|
||
close: close
|
||
};
|
||
}
|
||
|
||
function footerBar() {
|
||
var f = document.createElement('div');
|
||
f.className = 'md-history-footer';
|
||
return f;
|
||
}
|
||
|
||
function button(label, opts) {
|
||
opts = opts || {};
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.textContent = label;
|
||
if (opts.primary) b.className = 'btn-primary';
|
||
if (opts.disabled) b.disabled = true;
|
||
if (opts.onClick) b.addEventListener('click', opts.onClick);
|
||
return b;
|
||
}
|
||
|
||
// ── List view ──────────────────────────────────────────────────────
|
||
function renderList(modal, node, entries) {
|
||
modal.setTitle('History — ' + node.name);
|
||
var body = modal.body;
|
||
body.innerHTML = '';
|
||
|
||
if (!entries.length) {
|
||
var empty = document.createElement('p');
|
||
empty.className = 'md-history-empty';
|
||
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
|
||
body.appendChild(empty);
|
||
var f0 = footerBar();
|
||
f0.appendChild(button('Close', { onClick: modal.close }));
|
||
body.appendChild(f0);
|
||
return;
|
||
}
|
||
|
||
var hint = document.createElement('p');
|
||
hint.className = 'md-history-hint';
|
||
hint.textContent = 'Newest first. Select two versions to diff.';
|
||
body.appendChild(hint);
|
||
|
||
var list = document.createElement('div');
|
||
list.className = 'md-history-list';
|
||
var selected = []; // shas, in click order (max 2)
|
||
|
||
var diffBtn;
|
||
function syncDiffBtn() {
|
||
if (diffBtn) diffBtn.disabled = selected.length !== 2;
|
||
}
|
||
|
||
entries.forEach(function (ent) {
|
||
var row = document.createElement('div');
|
||
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
|
||
|
||
var cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.className = 'md-history-pick';
|
||
cb.addEventListener('change', function () {
|
||
if (cb.checked) {
|
||
selected.push(ent.sha);
|
||
// Keep at most two: drop the oldest selection.
|
||
if (selected.length > 2) {
|
||
var dropped = selected.shift();
|
||
var others = list.querySelectorAll('.md-history-pick');
|
||
others.forEach(function (o, i) {
|
||
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
|
||
});
|
||
}
|
||
} else {
|
||
selected = selected.filter(function (s) { return s !== ent.sha; });
|
||
}
|
||
syncDiffBtn();
|
||
});
|
||
|
||
var meta = document.createElement('div');
|
||
meta.className = 'md-history-meta';
|
||
meta.innerHTML =
|
||
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
|
||
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
|
||
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
|
||
(ent.current ? '<span class="md-history-badge">current</span>' : '');
|
||
|
||
var actions = document.createElement('div');
|
||
actions.className = 'md-history-actions';
|
||
actions.appendChild(button('View', {
|
||
onClick: function () { renderView(modal, node, ent, entries); }
|
||
}));
|
||
if (!ent.current && canRestore(node)) {
|
||
actions.appendChild(button('Restore', {
|
||
onClick: function () { restore(modal, node, ent); }
|
||
}));
|
||
}
|
||
|
||
row.appendChild(cb);
|
||
row.appendChild(meta);
|
||
row.appendChild(actions);
|
||
list.appendChild(row);
|
||
});
|
||
|
||
body.appendChild(list);
|
||
|
||
var f = footerBar();
|
||
diffBtn = button('Diff selected', {
|
||
primary: true, disabled: true,
|
||
onClick: function () {
|
||
if (selected.length !== 2) return;
|
||
// Order oldest→newest by the entries' position (newest
|
||
// first in the list), so the diff reads old → new.
|
||
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
|
||
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
||
renderDiff(modal, node, picks[0], picks[1], entries);
|
||
}
|
||
});
|
||
f.appendChild(diffBtn);
|
||
f.appendChild(button('Close', { onClick: modal.close }));
|
||
body.appendChild(f);
|
||
}
|
||
|
||
// ── Single-version view ──────────────────────────────────────────────
|
||
async function renderView(modal, node, ent, entries) {
|
||
modal.setTitle('Version — ' + fmtTime(ent.ts));
|
||
var body = modal.body;
|
||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||
var text;
|
||
try {
|
||
text = await fetchVersion(node, ent.sha);
|
||
} catch (e) {
|
||
body.innerHTML = '';
|
||
var err = document.createElement('p');
|
||
err.className = 'md-history-empty';
|
||
err.textContent = 'Could not load this version: ' + (e.message || e);
|
||
body.appendChild(err);
|
||
return;
|
||
}
|
||
body.innerHTML = '';
|
||
var meta = document.createElement('p');
|
||
meta.className = 'md-history-hint';
|
||
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
|
||
body.appendChild(meta);
|
||
|
||
var pre = document.createElement('pre');
|
||
pre.className = 'md-history-pre';
|
||
pre.textContent = text;
|
||
body.appendChild(pre);
|
||
|
||
var f = footerBar();
|
||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
||
if (!ent.current && canRestore(node)) {
|
||
f.appendChild(button('Restore this version', {
|
||
primary: true, onClick: function () { restore(modal, node, ent); }
|
||
}));
|
||
}
|
||
body.appendChild(f);
|
||
}
|
||
|
||
// ── Diff view ─────────────────────────────────────────────────────────
|
||
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
|
||
modal.setTitle('Diff');
|
||
var body = modal.body;
|
||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||
var oldText, newText;
|
||
try {
|
||
oldText = await fetchVersion(node, oldEnt.sha);
|
||
newText = await fetchVersion(node, newEnt.sha);
|
||
} catch (e) {
|
||
body.innerHTML = '';
|
||
var err = document.createElement('p');
|
||
err.className = 'md-history-empty';
|
||
err.textContent = 'Could not load versions: ' + (e.message || e);
|
||
body.appendChild(err);
|
||
return;
|
||
}
|
||
body.innerHTML = '';
|
||
|
||
var hdr = document.createElement('p');
|
||
hdr.className = 'md-history-hint';
|
||
hdr.innerHTML =
|
||
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
|
||
' → ' +
|
||
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
|
||
body.appendChild(hdr);
|
||
|
||
var ops = (window.zddc && window.zddc.diff)
|
||
? window.zddc.diff.lines(oldText, newText)
|
||
: null;
|
||
|
||
var pane = document.createElement('div');
|
||
pane.className = 'md-diff';
|
||
if (!ops) {
|
||
pane.textContent = 'Diff unavailable (diff module not loaded).';
|
||
} else {
|
||
var unchanged = true;
|
||
ops.forEach(function (op) {
|
||
if (op.type !== 'eq') unchanged = false;
|
||
var line = document.createElement('div');
|
||
line.className = 'md-diff-line md-diff-' + op.type;
|
||
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
|
||
var g = document.createElement('span');
|
||
g.className = 'md-diff-gutter';
|
||
g.textContent = gutter;
|
||
var t = document.createElement('span');
|
||
t.className = 'md-diff-text';
|
||
t.textContent = op.text;
|
||
line.appendChild(g);
|
||
line.appendChild(t);
|
||
pane.appendChild(line);
|
||
});
|
||
if (unchanged) {
|
||
var same = document.createElement('div');
|
||
same.className = 'md-diff-line md-diff-eq';
|
||
same.textContent = '(no differences)';
|
||
pane.appendChild(same);
|
||
}
|
||
}
|
||
body.appendChild(pane);
|
||
|
||
if (window.zddc && window.zddc.diff && ops) {
|
||
var s = window.zddc.diff.stats(ops);
|
||
var statline = document.createElement('p');
|
||
statline.className = 'md-history-hint';
|
||
statline.textContent = '+' + s.added + ' / −' + s.removed;
|
||
body.appendChild(statline);
|
||
}
|
||
|
||
var f = footerBar();
|
||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
||
body.appendChild(f);
|
||
}
|
||
|
||
// ── Restore ───────────────────────────────────────────────────────────
|
||
async function restore(modal, node, ent) {
|
||
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
||
return;
|
||
}
|
||
try {
|
||
var text = await fetchVersion(node, ent.sha);
|
||
var resp = await fetch(node.url, {
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'text/markdown' },
|
||
body: text
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
||
// Reflect the new head: refetch the list.
|
||
var entries = await fetchList(node);
|
||
renderList(modal, node, entries);
|
||
// If the file is open in the preview pane, reload it.
|
||
var preview = window.app && window.app.modules && window.app.modules.preview;
|
||
if (preview && typeof preview.showFilePreview === 'function') {
|
||
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
|
||
}
|
||
} catch (e) {
|
||
toast('Restore failed: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
// ── Entry point ─────────────────────────────────────────────────────
|
||
async function open(node) {
|
||
if (!node || !node.url) {
|
||
toast('History is only available in server mode.', 'error');
|
||
return;
|
||
}
|
||
var modal = makeModal('History — ' + node.name);
|
||
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
||
try {
|
||
var entries = await fetchList(node);
|
||
renderList(modal, node, entries);
|
||
} catch (e) {
|
||
modal.body.innerHTML = '';
|
||
var err = document.createElement('p');
|
||
err.className = 'md-history-empty';
|
||
err.textContent = 'Could not load history: ' + (e.message || e);
|
||
modal.body.appendChild(err);
|
||
var f = footerBar();
|
||
f.appendChild(button('Close', { onClick: modal.close }));
|
||
modal.body.appendChild(f);
|
||
}
|
||
}
|
||
|
||
window.app.modules.history = { open: open };
|
||
})();
|