ZDDC/browse/js/history.js
ZDDC 41d4e59899 fix(browse): refresh tree after workflow moves, guard double-submit, fix modal listener leaks
Workflow data-consistency cleanup across the transmittal modules.

Stale-tree / re-trigger hazard: Stage, Unstage, and Accept reported success
with "reload to see the move" and never refreshed, leaving the moved item at
its old location in the tree — inviting the user to re-fire the action on a
folder the server had already moved. They now refresh the current listing on
success. This also revealed that events.refreshListing was never exported,
so upload.js's comment-upload refresh (which guards on it) was silently a
no-op — exporting it fixes that path too.

Non-atomic stage: "New folder" does mkdir then a separate move; if the move
failed after the mkdir succeeded the user got a generic "move failed" with an
unexplained empty folder left behind. invokeStage now tracks whether it
created the folder and says so, and refreshes so the orphan is visible.

Double-submit: Accept / Plan Review / Stage / Unstage take a module-level
busy guard so a second menu click while a POST is in flight is ignored.

Modal listener leaks (verified): the Escape keydown handler in accept,
plan-review, and create-transmittal was only removed on the Escape path —
cancel / overlay-click / submit all leaked a live document listener bound to
a detached modal. Bound once and removed in close() (matching history.js).

history.js restore: split the PUT from the post-restore refetch so a refetch
error can no longer surface a misleading "Restore failed" after the restore
has already persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:59:23 -05:00

404 lines
17 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';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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, 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 };
})();