// 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 ?history=1 → JSON [{ts, by, id, bytes, current}] // GET ?history= → that version's raw bytes (id = snapshot filename) // Restore re-PUTs a chosen version's bytes to , 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, '"'); } function toast(msg, kind) { if (window.zddc && typeof window.zddc.toast === 'function') { window.zddc.toast(msg, kind || 'info'); } } // Append ?history= (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, 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 = '' + escapeHtml(fmtTime(ent.ts)) + '' + '' + escapeHtml(ent.by || '—') + '' + '' + escapeHtml(fmtBytes(ent.bytes)) + '' + (ent.current ? 'current' : ''); 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 = '

Loading…

'; 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 = '

Loading…

'; 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 = '' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '' + ' → ' + '' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + ''; 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 = '

Loading…

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