Compare commits

..

4 commits

Author SHA1 Message Date
e58e66a49c chore(embedded): cut v0.0.25-beta 2026-05-28 14:20:21 -05:00
9972e6773a feat(browse): markdown version-history viewer with diff + restore
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).

- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
  prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
  GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
  the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:00 -05:00
d00afa1ddc fix(server): carry history through the paths-tree merge
mergeOverlay (used to thread embedded defaults' paths: tree into chain
levels) didn't copy the new History *bool, so EffectiveHistory never saw
history: true on archive/<party>/working/ — the feature would have silently
never triggered. Add the field to the overlay and a HistoryAt defaults test
that exercises the real cascade (working/ + fenced homes true; sibling slots
false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:48:49 -05:00
6efe71e573 feat(server): edit-history versioning for working-folder markdown
A history: true .zddc subtree (enabled by default on archive/<party>/working/)
routes markdown PUTs through WriteTextWithHistory: each save snapshots the
content into a hidden, immutable .history/<stem>/ store (content-addressed
blobs + an append-only log.jsonl carrying server-stamped {ts, email, sha,
prev}) before writing the live file. The live file at its natural path stays
the source of truth; no symlinks, no audit in the body/filename.

Reads: GET <file>?history=1 lists versions (newest-first, current flagged);
GET <file>?history=<sha> returns that version's bytes (hex-id guard against
traversal). Listings carry a per-file History flag so the browse client knows
where to offer the affordance.

History is subtree-inheriting and ignores inherit:false ACL fences (versioning
is a write behavior, not a permission), so fenced per-user homes under working/
are covered too. No-op saves dedup; pre-existing files lazy-seed their origin
version. Records (.yaml) keep their existing in-body-audit history path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:51 -05:00
29 changed files with 2061 additions and 19 deletions

View file

@ -32,6 +32,7 @@ concat_files \
"css/base.css" \ "css/base.css" \
"css/tree.css" \ "css/tree.css" \
"css/preview-yaml.css" \ "css/preview-yaml.css" \
"css/history.css" \
> "$css_temp" > "$css_temp"
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
@ -47,6 +48,7 @@ concat_files \
"../shared/vendor/toastui-editor-all.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/diff.js" \
"../shared/zip-source.js" \ "../shared/zip-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
@ -71,6 +73,7 @@ concat_files \
"js/plan-review.js" \ "js/plan-review.js" \
"js/accept-transmittal.js" \ "js/accept-transmittal.js" \
"js/stage.js" \ "js/stage.js" \
"js/history.js" \
"js/create-transmittal.js" \ "js/create-transmittal.js" \
"js/events.js" \ "js/events.js" \
"js/app.js" \ "js/app.js" \

131
browse/css/history.css Normal file
View file

@ -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;
}

View file

@ -1061,6 +1061,24 @@
if (s) s.invokeUnstage(c.node); 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 }, { separator: true },
// ── View ── // ── View ──

396
browse/js/history.js Normal file
View file

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

View file

@ -60,6 +60,12 @@
// whatever the server enforces on the // whatever the server enforces on the
// actual PUT/DELETE still apply. // actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined, 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 <url>?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): // FS-API specific (null in server mode):
handle: null handle: null
}; };

View file

@ -75,6 +75,10 @@ export default defineConfig({
name: 'zddc', name: 'zddc',
testMatch: 'zddc.spec.js', testMatch: 'zddc.spec.js',
}, },
{
name: 'diff',
testMatch: 'diff.spec.js',
},
{ {
name: 'form-safety', name: 'form-safety',
testMatch: 'form-safety.spec.js', testMatch: 'form-safety.spec.js',

108
shared/diff.js Normal file
View file

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

View file

@ -2,8 +2,9 @@
<html lang="en"> <html lang="en">
<head><meta charset="utf-8"><title>ZDDC library test shim</title></head> <head><meta charset="utf-8"><title>ZDDC library test shim</title></head>
<body> <body>
<!-- Loads shared/zddc.js + shared/hash.js so Playwright tests can call window.zddc.* --> <!-- Loads shared/zddc.js + shared/hash.js + shared/diff.js so Playwright tests can call window.zddc.* -->
<script src="zddc.js"></script> <script src="zddc.js"></script>
<script src="hash.js"></script> <script src="hash.js"></script>
<script src="diff.js"></script>
</body> </body>
</html> </html>

71
tests/diff.spec.js Normal file
View file

@ -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);
});

View file

@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// see RecognizeVirtualConvert). The .md source serves // see RecognizeVirtualConvert). The .md source serves
// normally here.) // normally here.)
// Record-history list: GET <record>.yaml?history=1 returns the // Edit-history: ACL already passed (parent-dir chain).
// list of prior revisions stored under <dir>/.history/<base>/. // - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
// ACL already passed (parent-dir chain). Non-record paths fall // revisions stored under <dir>/.history/<base>/ (audit in-body).
// through to the normal file serve. // - Text (markdown) under a history: true subtree:
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" { // ?history=1 lists versions; ?history=<sha> returns that version's
// bytes. Audit lives in <dir>/.history/<stem>/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) handler.ServeHistoryList(w, r, absPath)
return return
} }

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2329,6 +2329,138 @@ body {
max-width: 32rem; 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;
}
</style> </style>
</head> </head>
<body> <body>
@ -2344,7 +2476,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -4227,6 +4359,115 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
window.zddc.filter = { parse: parse, matches: matches }; 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 // shared/zip-source.js — present the contents of a .zip as a tree of
// File System Access API handles, so tools written against // File System Access API handles, so tools written against
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner, // 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 // whatever the server enforces on the
// actual PUT/DELETE still apply. // actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined, 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 <url>?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): // FS-API specific (null in server mode):
handle: null 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 <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
// GET <url>?history=<sha> → that version's raw bytes
// Restore re-PUTs a chosen version's bytes to <url>, which the server
// records as a new version (forward-only; never destructive).
//
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&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, sha) {
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.text();
}
// ── Modal shell ──────────────────────────────────────────────────────
// One overlay; its body is swapped between the list, a diff, and a
// single-version view. Returns { overlay, body, setTitle, close }.
function makeModal(titleText) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay md-history-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.className = 'md-history-box';
var title = document.createElement('h2');
title.className = 'md-history-title';
title.textContent = titleText;
var body = document.createElement('div');
body.className = 'md-history-body';
box.appendChild(title);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', onKey);
}
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) close();
});
return {
overlay: overlay,
body: body,
setTitle: function (t) { title.textContent = t; },
close: close
};
}
function footerBar() {
var f = document.createElement('div');
f.className = 'md-history-footer';
return f;
}
function button(label, opts) {
opts = opts || {};
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (opts.primary) b.className = 'btn-primary';
if (opts.disabled) b.disabled = true;
if (opts.onClick) b.addEventListener('click', opts.onClick);
return b;
}
// ── List view ──────────────────────────────────────────────────────
function renderList(modal, node, entries) {
modal.setTitle('History — ' + node.name);
var body = modal.body;
body.innerHTML = '';
if (!entries.length) {
var empty = document.createElement('p');
empty.className = 'md-history-empty';
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
body.appendChild(empty);
var f0 = footerBar();
f0.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f0);
return;
}
var hint = document.createElement('p');
hint.className = 'md-history-hint';
hint.textContent = 'Newest first. Select two versions to diff.';
body.appendChild(hint);
var list = document.createElement('div');
list.className = 'md-history-list';
var selected = []; // shas, in click order (max 2)
var diffBtn;
function syncDiffBtn() {
if (diffBtn) diffBtn.disabled = selected.length !== 2;
}
entries.forEach(function (ent) {
var row = document.createElement('div');
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'md-history-pick';
cb.addEventListener('change', function () {
if (cb.checked) {
selected.push(ent.sha);
// Keep at most two: drop the oldest selection.
if (selected.length > 2) {
var dropped = selected.shift();
var others = list.querySelectorAll('.md-history-pick');
others.forEach(function (o, i) {
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
});
}
} else {
selected = selected.filter(function (s) { return s !== ent.sha; });
}
syncDiffBtn();
});
var meta = document.createElement('div');
meta.className = 'md-history-meta';
meta.innerHTML =
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
(ent.current ? '<span class="md-history-badge">current</span>' : '');
var actions = document.createElement('div');
actions.className = 'md-history-actions';
actions.appendChild(button('View', {
onClick: function () { renderView(modal, node, ent, entries); }
}));
if (!ent.current && canRestore(node)) {
actions.appendChild(button('Restore', {
onClick: function () { restore(modal, node, ent); }
}));
}
row.appendChild(cb);
row.appendChild(meta);
row.appendChild(actions);
list.appendChild(row);
});
body.appendChild(list);
var f = footerBar();
diffBtn = button('Diff selected', {
primary: true, disabled: true,
onClick: function () {
if (selected.length !== 2) return;
// Order oldest→newest by the entries' position (newest
// first in the list), so the diff reads old → new.
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
renderDiff(modal, node, picks[0], picks[1], entries);
}
});
f.appendChild(diffBtn);
f.appendChild(button('Close', { onClick: modal.close }));
body.appendChild(f);
}
// ── Single-version view ──────────────────────────────────────────────
async function renderView(modal, node, ent, entries) {
modal.setTitle('Version — ' + fmtTime(ent.ts));
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var text;
try {
text = await fetchVersion(node, ent.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load this version: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var meta = document.createElement('p');
meta.className = 'md-history-hint';
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
body.appendChild(meta);
var pre = document.createElement('pre');
pre.className = 'md-history-pre';
pre.textContent = text;
body.appendChild(pre);
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
if (!ent.current && canRestore(node)) {
f.appendChild(button('Restore this version', {
primary: true, onClick: function () { restore(modal, node, ent); }
}));
}
body.appendChild(f);
}
// ── Diff view ─────────────────────────────────────────────────────────
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
modal.setTitle('Diff');
var body = modal.body;
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
var oldText, newText;
try {
oldText = await fetchVersion(node, oldEnt.sha);
newText = await fetchVersion(node, newEnt.sha);
} catch (e) {
body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load versions: ' + (e.message || e);
body.appendChild(err);
return;
}
body.innerHTML = '';
var hdr = document.createElement('p');
hdr.className = 'md-history-hint';
hdr.innerHTML =
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
' → ' +
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
body.appendChild(hdr);
var ops = (window.zddc && window.zddc.diff)
? window.zddc.diff.lines(oldText, newText)
: null;
var pane = document.createElement('div');
pane.className = 'md-diff';
if (!ops) {
pane.textContent = 'Diff unavailable (diff module not loaded).';
} else {
var unchanged = true;
ops.forEach(function (op) {
if (op.type !== 'eq') unchanged = false;
var line = document.createElement('div');
line.className = 'md-diff-line md-diff-' + op.type;
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
var g = document.createElement('span');
g.className = 'md-diff-gutter';
g.textContent = gutter;
var t = document.createElement('span');
t.className = 'md-diff-text';
t.textContent = op.text;
line.appendChild(g);
line.appendChild(t);
pane.appendChild(line);
});
if (unchanged) {
var same = document.createElement('div');
same.className = 'md-diff-line md-diff-eq';
same.textContent = '(no differences)';
pane.appendChild(same);
}
}
body.appendChild(pane);
if (window.zddc && window.zddc.diff && ops) {
var s = window.zddc.diff.stats(ops);
var statline = document.createElement('p');
statline.className = 'md-history-hint';
statline.textContent = '+' + s.added + ' / ' + s.removed;
body.appendChild(statline);
}
var f = footerBar();
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
body.appendChild(f);
}
// ── Restore ───────────────────────────────────────────────────────────
async function restore(modal, node, ent) {
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
return;
}
try {
var text = await fetchVersion(node, ent.sha);
var resp = await fetch(node.url, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'text/markdown' },
body: text
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
toast('Restored version from ' + fmtTime(ent.ts), 'success');
// Reflect the new head: refetch the list.
var entries = await fetchList(node);
renderList(modal, node, entries);
// If the file is open in the preview pane, reload it.
var preview = window.app && window.app.modules && window.app.modules.preview;
if (preview && typeof preview.showFilePreview === 'function') {
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
}
} catch (e) {
toast('Restore failed: ' + (e.message || e), 'error');
}
}
// ── Entry point ─────────────────────────────────────────────────────
async function open(node) {
if (!node || !node.url) {
toast('History is only available in server mode.', 'error');
return;
}
var modal = makeModal('History — ' + node.name);
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
try {
var entries = await fetchList(node);
renderList(modal, node, entries);
} catch (e) {
modal.body.innerHTML = '';
var err = document.createElement('p');
err.className = 'md-history-empty';
err.textContent = 'Could not load history: ' + (e.message || e);
modal.body.appendChild(err);
var f = footerBar();
f.appendChild(button('Close', { onClick: modal.close }));
modal.body.appendChild(f);
}
}
window.app.modules.history = { open: open };
})();
// create-transmittal.js — folder-creation plumbing for outgoing // create-transmittal.js — folder-creation plumbing for outgoing
// transmittals. // transmittals.
// //
@ -12585,6 +13229,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (s) s.invokeUnstage(c.node); 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 }, { separator: true },
// ── View ── // ── View ──

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:53 · 9972e67</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.24 archive=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
transmittal=v0.0.24 transmittal=v0.0.25-beta · 2026-05-28 19:19:53 · 9972e67
classifier=v0.0.24 classifier=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
landing=v0.0.24 landing=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
form=v0.0.24 form=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
tables=v0.0.24 tables=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
browse=v0.0.24 browse=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67

View file

@ -102,6 +102,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
principal := zddc.Principal{Email: userEmail, Elevated: elevated} principal := zddc.Principal{Email: userEmail, Elevated: elevated}
parentActiveAdmin := elevated && userEmail != "" && parentActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(parentChain, userEmail) zddc.IsAdminForChain(parentChain, userEmail)
// Edit-history is a subtree behavior; resolved once for this dir and
// flagged on each eligible (markdown) file so the browse client knows
// where to offer the History/diff affordances.
historyEnabled := parentChain.EffectiveHistory()
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
@ -189,6 +193,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
writableBit = zddc.VerbA writableBit = zddc.VerbA
} }
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md")
result = append(result, fi) result = append(result, fi)
} }

View file

@ -432,6 +432,16 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
finalBody = res.FinalBody finalBody = res.FinalBody
stamped = true stamped = true
} else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
// History-enabled text (markdown) files: snapshot every save
// into <dir>/.history/<stem>/ with a server-stamped audit line,
// then write the live file. The live file at its natural path
// remains the source of truth.
if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else { } else {
if err := zddc.WriteAtomic(abs, body); err != nil { if err := zddc.WriteAtomic(abs, body); err != nil {
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)

View file

@ -802,3 +802,243 @@ func atoiSafe(s string) int {
} }
return n return n
} }
// ─── Markdown / text edit-history ────────────────────────────────────────
//
// History-enabled text files (a `history: true` .zddc subtree — see
// zddc.PolicyChain.EffectiveHistory) keep every saved version under
// <dir>/.history/<stem>/. Unlike records, text files can't carry audit
// fields in-body, so authorship + ordering live in a sidecar log:
//
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
// .history/<stem>/log.jsonl one MdHistoryEntry per save, in order
//
// Blobs are content-addressed (so reverting to an earlier exact state
// reuses its blob); the live file's content always equals the last log
// entry's sha. Email + timestamp are stamped server-side from the
// authenticated principal — never client-supplied, mirroring the record
// path's anti-forgery stance.
const mdHistoryLogName = "log.jsonl"
// MdHistoryEntry is one saved version of a history-tracked text file.
type MdHistoryEntry struct {
Ts string `json:"ts"` // RFC3339Nano UTC of the save
By string `json:"by"` // authenticated principal email ("" if pre-history)
Sha string `json:"sha"` // content hash = version id = blob stem
Prev string `json:"prev,omitempty"` // prior version's sha
Bytes int `json:"bytes"` // size of this version
Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted
}
// mdVersionIDRe validates a client-supplied version id so it can't
// escape the history dir. Version ids are lowercase hex content hashes.
var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`)
// IsTextHistoryCandidate reports whether abs is a text file eligible for
// edit-history versioning. Scoped to markdown for now (the browse editor
// surface); widen here to add .txt etc.
func IsTextHistoryCandidate(abs string) bool {
return strings.EqualFold(filepath.Ext(abs), ".md")
}
func mdHistoryDir(abs string) string {
return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs)))
}
// WriteTextWithHistory snapshots prior and new content into
// .history/<stem>/, appends an audit line, then writes the live file
// atomically. No-op saves (content identical to the current head) don't
// create a version. History is written BEFORE the live file so a crash
// can't lose a version the live write would have superseded.
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
histDir := mdHistoryDir(abs)
logPath := filepath.Join(histDir, mdHistoryLogName)
ext := filepath.Ext(abs)
// Prior live content (nil on create).
var prior []byte
priorSha := ""
priorMtime := ""
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
if data, rerr := os.ReadFile(abs); rerr == nil {
prior = data
priorSha = fileETag(prior)
priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano)
}
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
newSha := fileETag(body)
if principalEmail == "" {
principalEmail = "anonymous"
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if err := os.MkdirAll(histDir, 0o755); err != nil {
return fmt.Errorf("mkdir history: %w", err)
}
writeBlob := func(sha string, data []byte) error {
blob := filepath.Join(histDir, sha+ext)
if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) {
return zddc.WriteAtomic(blob, data)
}
return nil
}
logExisted := false
if _, err := os.Stat(logPath); err == nil {
logExisted = true
}
var entries []MdHistoryEntry
if logExisted {
existing, err := readMdLog(logPath)
if err != nil {
return err
}
entries = existing
}
// Lazy-seed: a file that pre-existed history enablement has prior
// bytes but no log. Capture that state as the origin version so the
// chain isn't missing its start. Authorship is unknown ("").
if prior != nil && !logExisted {
if err := writeBlob(priorSha, prior); err != nil {
return err
}
ts := priorMtime
if ts == "" {
ts = now
}
entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)})
}
lastSha := ""
if len(entries) > 0 {
lastSha = entries[len(entries)-1].Sha
}
// Record this save unless it's a no-op (identical to the head).
changed := newSha != lastSha
if changed {
if err := writeBlob(newSha, body); err != nil {
return err
}
entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)})
}
// Rewrite the log atomically (CIFS-safe; avoids partial-append
// corruption) only when something actually changed.
if changed || (prior != nil && !logExisted) {
if err := writeMdLog(logPath, entries); err != nil {
return err
}
}
return zddc.WriteAtomic(abs, body)
}
func readMdLog(path string) ([]MdHistoryEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []MdHistoryEntry
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var e MdHistoryEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue // skip a malformed line rather than fail the whole read
}
out = append(out, e)
}
return out, nil
}
func writeMdLog(path string, entries []MdHistoryEntry) error {
var sb strings.Builder
for _, e := range entries {
e.Current = false // never persist the derived flag
b, err := json.Marshal(e)
if err != nil {
return err
}
sb.Write(b)
sb.WriteByte('\n')
}
return zddc.WriteAtomic(path, []byte(sb.String()))
}
// ListMdHistory returns the saved versions of abs, newest first, with
// Current set on the version whose content matches the live file.
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName)
entries, err := readMdLog(logPath)
if err != nil {
return nil, err
}
liveSha := ""
if data, err := os.ReadFile(abs); err == nil {
liveSha = fileETag(data)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts })
// Mark the newest entry whose content matches the live file as the
// current version. A revert reuses an earlier blob, so the same sha
// can appear twice — only the most recent save is "current".
for i := range entries {
if entries[i].Sha == liveSha {
entries[i].Current = true
break
}
}
return entries, nil
}
// ServeTextHistory dispatches GET <file>?history=... for history-enabled
// text files: `?history=1` (or empty / `list`) returns the version list
// as JSON; `?history=<sha>` returns that version's raw bytes. ACL on the
// live file has already been checked by the caller.
func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) {
if !IsTextHistoryCandidate(abs) {
http.NotFound(w, r)
return
}
if version == "" || version == "1" || version == "list" {
entries, err := ListMdHistory(abs)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "history-list")
_ = json.NewEncoder(w).Encode(entries)
return
}
if !mdVersionIDRe.MatchString(version) {
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
return
}
blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs))
data, err := os.ReadFile(blob)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "history-version")
w.Header().Set("X-ZDDC-History-Version", version)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}

View file

@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func countBlobs(t *testing.T, histDir string) int {
t.Helper()
ents, err := os.ReadDir(histDir)
if err != nil {
t.Fatalf("read history dir: %v", err)
}
n := 0
for _, e := range ents {
if strings.HasSuffix(e.Name(), ".md") {
n++
}
}
return n
}
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "notes.md")
histDir := filepath.Join(dir, ".history", "notes")
sha1 := fileETag([]byte("v1"))
sha2 := fileETag([]byte("v2"))
// ── create ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want v1", b)
}
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 1 {
t.Fatalf("after create: want 1 entry, got %d", len(entries))
}
if entries[0].By != "alice@x.com" {
t.Errorf("by = %q, want alice@x.com", entries[0].By)
}
if entries[0].Sha != sha1 {
t.Errorf("sha = %q, want %q", entries[0].Sha, sha1)
}
if !entries[0].Current {
t.Errorf("v1 should be current")
}
if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil {
t.Errorf("v1 blob missing: %v", err)
}
// ── update ──
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v2" {
t.Fatalf("live = %q, want v2", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 2 {
t.Fatalf("after update: want 2 entries, got %d", len(entries))
}
// newest first
if entries[0].Sha != sha2 || !entries[0].Current {
t.Errorf("head = %+v, want v2 current", entries[0])
}
if entries[0].Prev != sha1 {
t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1)
}
if entries[1].Sha != sha1 || entries[1].Current {
t.Errorf("tail = %+v, want v1 non-current", entries[1])
}
if entries[1].By != "alice@x.com" {
t.Errorf("v1 author lost: %q", entries[1].By)
}
// ── no-op save (identical content) → dedup, no new entry ──
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
entries, _ = ListMdHistory(abs)
if len(entries) != 2 {
t.Fatalf("dedup failed: want 2 entries, got %d", len(entries))
}
// ── restore v1 content → new log entry, blob reused ──
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
if b, _ := os.ReadFile(abs); string(b) != "v1" {
t.Fatalf("live = %q, want restored v1", b)
}
entries, _ = ListMdHistory(abs)
if len(entries) != 3 {
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
}
if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" {
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
}
// Only the newest matching entry is current, even though the oldest
// entry has the same sha.
if entries[2].Current {
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
}
// Content-addressed: only two distinct blobs (v1, v2) despite 3 saves.
if n := countBlobs(t, histDir); n != 2 {
t.Errorf("distinct blobs = %d, want 2", n)
}
}
func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "doc.md")
// Simulate a file that existed before history was enabled.
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
entries, err := ListMdHistory(abs)
mustNoErr(t, err)
if len(entries) != 2 {
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
}
// newest = the edit; oldest = the seeded legacy version (author unknown)
if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) {
t.Errorf("head = %+v, want edit by dave", entries[0])
}
if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) {
t.Errorf("seed = %+v, want legacy with empty author", entries[1])
}
}
func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "x.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
entries, _ := ListMdHistory(abs)
if len(entries) != 1 || entries[0].By != "anonymous" {
t.Fatalf("empty author should record anonymous, got %+v", entries)
}
}
func TestServeTextHistory_ListAndVersion(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "page.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
time.Sleep(2 * time.Millisecond)
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
sha1 := fileETag([]byte("one"))
// ── list ──
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, "1")
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code)
}
var got []MdHistoryEntry
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("list body not JSON: %v", err)
}
if len(got) != 2 || got[0].By != "b@x.com" {
t.Fatalf("list = %+v, want 2 newest-first", got)
}
// ── specific version content ──
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
rec = httptest.NewRecorder()
ServeTextHistory(rec, req, abs, sha1)
if rec.Code != http.StatusOK {
t.Fatalf("version status = %d", rec.Code)
}
if rec.Body.String() != "one" {
t.Errorf("version body = %q, want %q", rec.Body.String(), "one")
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Errorf("content-type = %q", ct)
}
}
func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "p.md")
mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com"))
// Drop a secret in the parent so a successful traversal would be visible.
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} {
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, bad)
if rec.Code == http.StatusOK {
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "TOPSECRET") {
t.Fatalf("traversal leaked secret for input %q", bad)
}
}
// Non-markdown path → 404 (history not applicable).
yamlAbs := filepath.Join(dir, "rec.yaml")
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
rec := httptest.NewRecorder()
ServeTextHistory(rec, req, yamlAbs, "1")
if rec.Code != http.StatusNotFound {
t.Errorf("non-md status = %d, want 404", rec.Code)
}
}

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.24</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -86,4 +86,12 @@ type FileInfo struct {
// as "no permissions known" and fall back to a server round-trip // as "no permissions known" and fall back to a server round-trip
// (or just disable affordances) rather than assuming any grant. // (or just disable affordances) rather than assuming any grant.
Verbs string `json:"verbs,omitempty"` Verbs string `json:"verbs,omitempty"`
// History is true when this entry is a text file in a history: true
// cascade subtree — i.e. every save is versioned into the sibling
// .history/ store and GET <url>?history=1 lists prior versions. The
// browse client uses it to show "History…" / diff affordances only
// where they apply. False/absent for directories, virtual entries,
// and files outside a history-enabled subtree.
History bool `json:"history,omitempty"`
} }

View file

@ -47,6 +47,25 @@ func (chain PolicyChain) VisibleStart(toIdx int) int {
return 0 return 0
} }
// EffectiveHistory reports whether edit-history versioning is enabled
// for writes at this chain's directory. Unlike DropTarget (leaf-only),
// history is a subtree behavior: the closest-to-leaf explicit setting
// wins and applies to all descendants. It deliberately IGNORES
// inherit:false ACL fences — versioning is a write behavior, not a
// permission, so a fenced per-user home under a history-enabled
// working/ still records history. Falls back to the embedded defaults.
func (chain PolicyChain) EffectiveHistory() bool {
for i := len(chain.Levels) - 1; i >= 0; i-- {
if v := chain.Levels[i].History; v != nil {
return *v
}
}
if v := chain.Embedded.History; v != nil {
return *v
}
return false
}
// policyCache caches effective policies keyed by dirPath. // policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain. // Values are PolicyChain.
var policyCache sync.Map var policyCache sync.Map
@ -370,6 +389,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("auto_own_fenced", zf.AutoOwnFenced != nil) add("auto_own_fenced", zf.AutoOwnFenced != nil)
add("virtual", zf.Virtual != nil) add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil) add("drop_target", zf.DropTarget != nil)
add("history", zf.History != nil)
add("worm", zf.Worm != nil) add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0) add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "") add("received_path", zf.ReceivedPath != "")

View file

@ -475,6 +475,13 @@ paths:
# homes below. # homes below.
auto_own: true auto_own: true
drop_target: true drop_target: true
# Edit-history: every markdown save under working/ (incl.
# the fenced per-user homes — history inherits through
# fences) is versioned into a sibling .history/ store with
# a server-stamped audit line (who + when). The live file
# stays the source of truth; GET <file>?history lists prior
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
history: true
paths: paths:
"*": # per-user home dir, fenced "*": # per-user home dir, fenced
default_tool: browse default_tool: browse

View file

@ -263,6 +263,24 @@ type ZddcFile struct {
// not its descendants. Defaults (nil): no drop zone. // not its descendants. Defaults (nil): no drop zone.
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
// History enables server-managed edit-history versioning for text
// (markdown) writes in this subtree. When true, each save of a
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots
// the content into <dir>/.history/<stem>/ and appends a server-stamped
// audit line (timestamp + authenticated email) to that folder's
// log.jsonl, then writes the live file. The live file at its natural
// path stays the source of truth; the .history/ store is immutable and
// auto-hidden (dot-prefixed).
//
// Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a
// `history: true` at an ancestor (e.g. archive/<party>/working/)
// applies to every descendant, and it deliberately inherits through
// inherit:false ACL fences (per-user homes under working/ still record
// history) — versioning is a write behavior, not a permission. A
// deeper level may set `history: false` to opt a subtree out. Resolved
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
// Worm marks this directory (and its descendants) as // Worm marks this directory (and its descendants) as
// write-once-read-many. A non-nil Worm list — even an empty one — // write-once-read-many. A non-nil Worm list — even an empty one —
// puts the path into a WORM zone with these effects, applied AFTER // puts the path into a WORM zone with these effects, applied AFTER

View file

@ -0,0 +1,58 @@
package zddc
import "testing"
func bptr(b bool) *bool { return &b }
func TestEffectiveHistory(t *testing.T) {
tests := []struct {
name string
chain PolicyChain
want bool
}{
{
name: "no levels, no embedded default",
chain: PolicyChain{},
want: false,
},
{
name: "embedded default true, no explicit level",
chain: PolicyChain{Levels: []ZddcFile{{}, {}}, Embedded: ZddcFile{History: bptr(true)}},
want: true,
},
{
name: "ancestor true inherits down to leaf (nil)",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {}, {}}},
want: true,
},
{
name: "leaf false overrides ancestor true",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {History: bptr(false)}}},
want: false,
},
{
name: "leaf true overrides embedded false",
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}}, Embedded: ZddcFile{History: bptr(false)}},
want: true,
},
{
// History must inherit THROUGH an inherit:false ACL fence —
// versioning is a write behavior, not a permission. The fenced
// middle level sets no History, so the ancestor's true wins.
name: "inherits through an inherit:false fence",
chain: PolicyChain{Levels: []ZddcFile{
{History: bptr(true)},
{ACL: ACLRules{Inherit: bptr(false)}},
{},
}},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.chain.EffectiveHistory(); got != tc.want {
t.Errorf("EffectiveHistory() = %v, want %v", got, tc.want)
}
})
}
}

View file

@ -147,6 +147,18 @@ func VirtualAt(fsRoot, dirPath string) bool {
return false return false
} }
// HistoryAt reports whether edit-history versioning is enabled for
// writes in dirPath. Subtree-inheriting (see
// PolicyChain.EffectiveHistory) — a `history: true` at an ancestor
// applies here even through inherit:false fences.
func HistoryAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
return chain.EffectiveHistory()
}
// IsDeclaredPath reports whether dirPath is mentioned in the // IsDeclaredPath reports whether dirPath is mentioned in the
// cascade — either by an on-disk .zddc at that level OR by any // cascade — either by an on-disk .zddc at that level OR by any
// ancestor's paths: tree (including the embedded defaults). // ancestor's paths: tree (including the embedded defaults).

View file

@ -48,6 +48,34 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
} }
} }
// TestHistoryAt_Defaults — the embedded convention enables edit-history
// versioning on archive/<party>/working/ and (because history is
// subtree-inheriting and ignores the auto_own_fenced homes' inherit:false)
// on the per-user homes and any depth beneath them. Sibling slots do not
// get history.
func TestHistoryAt_Defaults(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want bool
}{
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
{filepath.Join(root, "Project-X", "archive"), false},
}
for _, tc := range cases {
if got := HistoryAt(root, tc.path); got != tc.want {
t.Errorf("HistoryAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
}
}
}
// TestDirToolAt — the trailing-slash form floors at "browse" for // TestDirToolAt — the trailing-slash form floors at "browse" for
// every path (the embedded convention sets dir_tool nowhere), and an // every path (the embedded convention sets dir_tool nowhere), and an
// on-disk .zddc can override it for a subtree. // on-disk .zddc can override it for a subtree.

View file

@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.DropTarget != nil { if top.DropTarget != nil {
out.DropTarget = top.DropTarget out.DropTarget = top.DropTarget
} }
if top.History != nil {
out.History = top.History
}
// Worm: presence (non-nil, even empty) marks the WORM zone. // Worm: presence (non-nil, even empty) marks the WORM zone.
// Concat-dedupe across levels (a deeper .zddc adds controllers); // Concat-dedupe across levels (a deeper .zddc adds controllers);
// preserve a non-nil empty slice so `worm: []` survives the // preserve a non-nil empty slice so `worm: []` survives the