ZDDC/browse/js/history.js
2026-06-11 13:32:31 -05:00

395 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, id, bytes, current}]
// GET <url>?history=<id> → that version's raw bytes (id = snapshot filename)
// 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';
var escapeHtml = window.app.modules.util.escapeHtml;
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();
}
var fmtBytes = window.app.modules.util.fmtSize;
// 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, id) {
var resp = await fetch(histURL(node.url, id), { 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.id);
// 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].id === dropped) o.checked = false;
});
}
} else {
selected = selected.filter(function (s) { return s !== ent.id; });
}
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.id) !== -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.id);
} 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.id);
newText = await fetchVersion(node, newEnt.id);
} 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;
}
// The restore itself (the PUT) is the operation that can "fail".
// Keep it in its own try so a later error while refreshing the UI
// can't surface a misleading "Restore failed" after the restore has
// already been persisted.
try {
var text = await fetchVersion(node, ent.id);
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);
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
return;
}
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Best-effort UI refresh — the restore already succeeded, so a
// failure here is logged but never reported as a restore failure.
try {
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') {
preview.showFilePreview(node);
}
} catch (_e) { /* refresh is best-effort; restore is done */ }
}
// ── 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 };
})();