diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9438f0..34e8f08 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -684,7 +684,9 @@ There are **no hardcoded folder names** — the canonical project structure is d **Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive//{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working//, staging//, reviewing//}`. Six sibling top-level URLs are **virtual aggregators**, never on disk: - **Row rollups** (tables tool, `default_tool: tables`) — `/ssr`, `/mdl`, `/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity). -- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect. +- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. (A party name fails `ValidPartyName` only if it contains a character outside `[A-Za-z0-9.-]` — the resolver then declines to redirect and the path is treated physically; see `working/` below.) Sharing/bookmarks land on the canonical path after the redirect. + + `working/` is the one folder-nav aggregator that **also materialises on disk**: it doubles as a shared project-level drafting space holding **creator-owned working folders** at `/working//`. The slot dir is instantiated lazily by `EnsureCanonicalAncestors` the first time real content is created beneath it (it stays a plain dir — never auto-owned), and each `/` a user creates gets an *unfenced* auto-own `.zddc` (`history: true` inherits in, so markdown drafts there are versioned). Authorisation splits dir-vs-file at the root: project members may create folders (`project_team: rc` in the defaults), but a **bare file directly at the `working/` root is reserved for the `document_controller`** — regular users work inside a folder; the DC creates files at the root or promotes one up with a MOVE. Enforced in `serveFilePut`/`serveFileMove` via `isProjectWorkingRootFile` + `zddc.IsRoleMemberAt`, independent of the ACL verb (since mkdir and file-PUT both authorise as `ActionCreate`). The earlier per-user `working//` "personal workspace" idea was dropped as more complexity than it earned. `staging`/`reviewing` remain non-materialising — `EnsureCanonicalAncestors` still rejects physical writes under them. Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL. diff --git a/browse/build.sh b/browse/build.sh index 937c6de..b0a5a56 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -32,6 +32,7 @@ concat_files \ "css/base.css" \ "css/tree.css" \ "css/preview-yaml.css" \ + "css/history.css" \ > "$css_temp" # JS files: shared canonical helpers, then browse modules. @@ -47,6 +48,7 @@ concat_files \ "../shared/vendor/toastui-editor-all.min.js" \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ + "../shared/diff.js" \ "../shared/zip-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ @@ -71,6 +73,7 @@ concat_files \ "js/plan-review.js" \ "js/accept-transmittal.js" \ "js/stage.js" \ + "js/history.js" \ "js/create-transmittal.js" \ "js/events.js" \ "js/app.js" \ diff --git a/browse/css/history.css b/browse/css/history.css new file mode 100644 index 0000000..7e23771 --- /dev/null +++ b/browse/css/history.css @@ -0,0 +1,131 @@ +/* history.css — markdown edit-history modal (browse/js/history.js). */ + +.md-history-box { + background: var(--bg, #fff); + color: var(--fg, #111); + padding: 1.1rem 1.35rem; + border-radius: 6px; + min-width: 30rem; + max-width: 56rem; + width: 80vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + font-family: inherit; +} + +.md-history-title { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.md-history-body { + display: flex; + flex-direction: column; + min-height: 0; /* allow inner scroll regions to shrink */ + overflow: hidden; +} + +.md-history-hint { + margin: 0 0 0.6rem 0; + font-size: 0.82rem; + color: var(--muted, #666); +} + +.md-history-empty { + margin: 1rem 0; + font-size: 0.9rem; + color: var(--muted, #666); +} + +/* ── version list ── */ +.md-history-list { + overflow-y: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; +} + +.md-history-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border, #eee); + font-size: 0.88rem; +} + +.md-history-row:last-child { border-bottom: none; } +.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); } + +.md-history-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.md-history-time { font-variant-numeric: tabular-nums; } +.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; } +.md-history-size { color: var(--muted, #888); font-size: 0.8rem; } + +.md-history-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.4rem; + border-radius: 10px; + background: var(--accent, #3c82f6); + color: #fff; +} + +.md-history-actions { display: flex; gap: 0.35rem; } + +/* ── single-version view ── */ +.md-history-pre { + flex: 1 1 auto; + overflow: auto; + margin: 0; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + background: var(--code-bg, #f7f7f8); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── diff view ── */ +.md-diff { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; } +.md-diff-text { flex: 1 1 auto; } + +.md-diff-add { background: rgba(46, 160, 67, 0.16); } +.md-diff-add .md-diff-gutter { color: #2ea043; } +.md-diff-del { background: rgba(248, 81, 73, 0.16); } +.md-diff-del .md-diff-gutter { color: #f85149; } +.md-diff-eq { color: var(--muted, #777); } + +.md-diff-old { color: #f85149; } +.md-diff-new { color: #2ea043; } + +/* ── footer ── */ +.md-history-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.85rem; +} diff --git a/browse/js/events.js b/browse/js/events.js index f6ba823..3b1b52c 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -1061,6 +1061,24 @@ if (s) s.invokeUnstage(c.node); } }, + // ── Version history (history:true subtree, real files only) ── + // Server-mode only: the audit trail (who saved when) is + // server-stamped, so there's no offline equivalent. node.history + // is set by the listing when this file sits in a history-enabled + // cascade subtree (working/). + { + label: 'History…', + icon: '🕘', + visible: function (c) { + if (!serverMode) return false; + if (c.node.isDir || c.node.isZip || c.node.virtual) return false; + return !!c.node.history; + }, + action: function (c) { + var h = window.app.modules.history; + if (h) h.open(c.node); + } + }, { separator: true }, // ── View ── diff --git a/browse/js/history.js b/browse/js/history.js new file mode 100644 index 0000000..e4747b8 --- /dev/null +++ b/browse/js/history.js @@ -0,0 +1,396 @@ +// 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, sha, prev, bytes, current}] +// GET ?history= → that version's raw bytes +// 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, 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 = + '' + 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.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 = '

Loading…

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

Loading…

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

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 }; +})(); diff --git a/browse/js/loader.js b/browse/js/loader.js index 18d7045..5b58589 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -60,6 +60,12 @@ // whatever the server enforces on the // actual PUT/DELETE still apply. verbs: typeof e.verbs === 'string' ? e.verbs : undefined, + // Server-computed: true when this file lives in a history:true + // cascade subtree, so every save is versioned and + // GET ?history lists prior versions. Drives the "History…" + // context-menu affordance (server mode only — offline has no + // authenticated identity to attribute saves to). + history: !!e.history, // FS-API specific (null in server mode): handle: null }; diff --git a/playwright.config.js b/playwright.config.js index 8199f31..e52dc4e 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -75,6 +75,10 @@ export default defineConfig({ name: 'zddc', testMatch: 'zddc.spec.js', }, + { + name: 'diff', + testMatch: 'diff.spec.js', + }, { name: 'form-safety', testMatch: 'form-safety.spec.js', diff --git a/shared/diff.js b/shared/diff.js new file mode 100644 index 0000000..bb0a7ed --- /dev/null +++ b/shared/diff.js @@ -0,0 +1,108 @@ +/* + * 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 }; +})(); diff --git a/shared/zddc-test.html b/shared/zddc-test.html index bdd6c5f..721495d 100644 --- a/shared/zddc-test.html +++ b/shared/zddc-test.html @@ -2,8 +2,9 @@ ZDDC library test shim - + + diff --git a/tests/diff.spec.js b/tests/diff.spec.js new file mode 100644 index 0000000..6eb013b --- /dev/null +++ b/tests/diff.spec.js @@ -0,0 +1,71 @@ +/** + * Tests for shared/diff.js — the dependency-free text diff used by the + * browse tool's markdown version-history viewer. + * + * Runs against the same shim as zddc.spec.js (shared/zddc-test.html, + * which loads shared/diff.js and exposes window.zddc.diff). + */ + +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const SHIM_PATH = 'file://' + path.resolve('shared/zddc-test.html'); + +async function diff(page, fn, ...args) { + return page.evaluate( + ([fn, args]) => window.zddc.diff[fn](...args), + [fn, args] + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto(SHIM_PATH, { waitUntil: 'load' }); +}); + +test('diff module is attached to window.zddc', async ({ page }) => { + const present = await page.evaluate(() => + !!(window.zddc && window.zddc.diff && + typeof window.zddc.diff.lines === 'function' && + typeof window.zddc.diff.words === 'function')); + expect(present).toBe(true); +}); + +test('identical text produces only eq ops', async ({ page }) => { + const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nb\nc'); + expect(ops.every(o => o.type === 'eq')).toBe(true); +}); + +test('a changed middle line shows del then add', async ({ page }) => { + const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nB\nc'); + const compact = ops.map(o => `${o.type}:${o.text}`).join('|'); + expect(compact).toContain('eq:a'); + expect(compact).toContain('del:b'); + expect(compact).toContain('add:B'); + expect(compact).toContain('eq:c'); +}); + +test('stats count added and removed lines', async ({ page }) => { + const addStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb', 'a\nx\nb')); + expect(addStats).toEqual({ added: 1, removed: 0 }); + + const delStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb\nc', 'a\nc')); + expect(delStats).toEqual({ added: 0, removed: 1 }); +}); + +test('pure insertion at end', async ({ page }) => { + const ops = await diff(page, 'lines', 'one\ntwo', 'one\ntwo\nthree'); + const added = ops.filter(o => o.type === 'add').map(o => o.text); + expect(added).toEqual(['three']); + expect(ops.filter(o => o.type === 'del')).toHaveLength(0); +}); + +test('word diff aligns on word boundaries, preserving spaces', async ({ page }) => { + const ops = await diff(page, 'words', 'the quick fox', 'the slow fox'); + const changed = ops.filter(o => o.type !== 'eq').map(o => `${o.type}:${o.text}`); + expect(changed).toEqual(['del:quick', 'add:slow']); +}); + +test('empty inputs do not throw', async ({ page }) => { + const ops = await diff(page, 'lines', '', ''); + expect(Array.isArray(ops)).toBe(true); +}); diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 84389c6..a99ac87 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // see RecognizeVirtualConvert). The .md source serves // normally here.) - // Record-history list: GET .yaml?history=1 returns the - // list of prior revisions stored under /.history//. - // ACL already passed (parent-dir chain). Non-record paths fall - // through to the normal file serve. - if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" { + // Edit-history: ACL already passed (parent-dir chain). + // - Records (.yaml rows): GET .yaml?history=1 lists prior + // revisions stored under /.history// (audit in-body). + // - Text (markdown) under a history: true subtree: + // ?history=1 lists versions; ?history= returns that version's + // bytes. Audit lives in /.history//log.jsonl. + // Non-history paths fall through to the normal file serve. + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") { + version := r.URL.Query().Get("history") + if handler.IsTextHistoryCandidate(absPath) { + if chain.EffectiveHistory() { + handler.ServeTextHistory(w, r, absPath, version) + } else { + http.NotFound(w, r) + } + return + } handler.ServeHistoryList(w, r, absPath) return } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index dcd067d..5c6a92f 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -298,9 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { return req.WithContext(handler.WithEmail(req.Context(), email)) } - // PUT a new file via dispatch. + // PUT a new file via dispatch. Files live in a sub-folder under + // working/ (creator-owned); bare files at the working/ root are + // document-controller-only (see TestFileAPI_WorkingRootFileDocControllerOnly). body := []byte("note body") - req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com") + req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com") rec := httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusCreated { @@ -308,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // GET it back. - req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK || rec.Body.String() != string(body) { @@ -316,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // MOVE it. - req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com") req.Header.Set("X-ZDDC-Op", "move") - req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md") + req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusOK { @@ -326,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { } // DELETE it. - req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com") + req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com") rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNoContent { @@ -1063,4 +1065,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { } }) } - diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index c39a872..4f2e492 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.24 + v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 237e74b..3954d45 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -2329,6 +2329,138 @@ body { max-width: 32rem; } +/* history.css — markdown edit-history modal (browse/js/history.js). */ + +.md-history-box { + background: var(--bg, #fff); + color: var(--fg, #111); + padding: 1.1rem 1.35rem; + border-radius: 6px; + min-width: 30rem; + max-width: 56rem; + width: 80vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + font-family: inherit; +} + +.md-history-title { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.md-history-body { + display: flex; + flex-direction: column; + min-height: 0; /* allow inner scroll regions to shrink */ + overflow: hidden; +} + +.md-history-hint { + margin: 0 0 0.6rem 0; + font-size: 0.82rem; + color: var(--muted, #666); +} + +.md-history-empty { + margin: 1rem 0; + font-size: 0.9rem; + color: var(--muted, #666); +} + +/* ── version list ── */ +.md-history-list { + overflow-y: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; +} + +.md-history-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border, #eee); + font-size: 0.88rem; +} + +.md-history-row:last-child { border-bottom: none; } +.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); } + +.md-history-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.md-history-time { font-variant-numeric: tabular-nums; } +.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; } +.md-history-size { color: var(--muted, #888); font-size: 0.8rem; } + +.md-history-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.4rem; + border-radius: 10px; + background: var(--accent, #3c82f6); + color: #fff; +} + +.md-history-actions { display: flex; gap: 0.35rem; } + +/* ── single-version view ── */ +.md-history-pre { + flex: 1 1 auto; + overflow: auto; + margin: 0; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + background: var(--code-bg, #f7f7f8); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── diff view ── */ +.md-diff { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; } +.md-diff-text { flex: 1 1 auto; } + +.md-diff-add { background: rgba(46, 160, 67, 0.16); } +.md-diff-add .md-diff-gutter { color: #2ea043; } +.md-diff-del { background: rgba(248, 81, 73, 0.16); } +.md-diff-del .md-diff-gutter { color: #f85149; } +.md-diff-eq { color: var(--muted, #777); } + +.md-diff-old { color: #f85149; } +.md-diff-new { color: #2ea043; } + +/* ── footer ── */ +.md-history-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.85rem; +} + @@ -2344,7 +2476,7 @@ body {
ZDDC Browse - v0.0.24 + v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
@@ -4227,6 +4359,115 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.zddc.filter = { parse: parse, matches: matches }; })(); +/* + * 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 }; +})(); + // shared/zip-source.js — present the contents of a .zip as a tree of // File System Access API handles, so tools written against // FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner, @@ -6790,6 +7031,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // whatever the server enforces on the // actual PUT/DELETE still apply. verbs: typeof e.verbs === 'string' ? e.verbs : undefined, + // Server-computed: true when this file lives in a history:true + // cascade subtree, so every save is versioned and + // GET ?history lists prior versions. Drives the "History…" + // context-menu affordance (server mode only — offline has no + // authenticated identity to attribute saves to). + history: !!e.history, // FS-API specific (null in server mode): handle: null }; @@ -11375,6 +11622,403 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }; })(); +// 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, sha, prev, bytes, current}] +// GET ?history= → that version's raw bytes +// 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, 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 = + '' + 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.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 = '

Loading…

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

Loading…

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

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 }; +})(); + // create-transmittal.js — folder-creation plumbing for outgoing // transmittals. // @@ -12585,6 +13229,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (s) s.invokeUnstage(c.node); } }, + // ── Version history (history:true subtree, real files only) ── + // Server-mode only: the audit trail (who saved when) is + // server-stamped, so there's no offline equivalent. node.history + // is set by the listing when this file sits in a history-enabled + // cascade subtree (working/). + { + label: 'History…', + icon: '🕘', + visible: function (c) { + if (!serverMode) return false; + if (c.node.isDir || c.node.isZip || c.node.virtual) return false; + return !!c.node.history; + }, + action: function (c) { + var h = window.app.modules.history; + if (h) h.open(c.node); + } + }, { separator: true }, // ── View ── diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 4d93f58..dee7e66 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1793,7 +1793,7 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.24 + v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index 415b012..67f59a6 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -1536,7 +1536,7 @@ body {
ZDDC - v0.0.24 + v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index dddf42a..99300db 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2635,7 +2635,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.24 + v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
JavaScript not available