feat(browse): lost-update protection for editors + shared conflict dialog

Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.

- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
  (or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
  the PUT response (so save→edit→save adopts the new version and doesn't
  false-conflict); throws ConflictError (.status===412) on a precondition
  failure so callers branch cleanly. New saveCopy() parks a conflicting edit
  as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
  from the content GET (the listing JSON carries no per-file etag); threaded
  into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
  (reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
  Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
  deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
  adopt the returned etag on success, and on 412 open the dialog (Overwrite
  re-fetches the current etag then re-saves — re-conflicts on a third writer
  rather than blind-forcing; Reload clears dirty first so the renderInline
  guard skips its confirm). FS-Access mode sends no precondition (no
  concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
  on 412, new-etag returned, force omits the precondition, dialog renders the
  diff and each action resolves via its callback. Drives the fresh dist build
  over file:// with a stubbed fetch (the test binary embeds the committed
  browse.html, not dist, so a server-mode E2E would run stale code).

All browse + diff + conflict specs pass (18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-03 16:24:15 -05:00
parent d524966f00
commit 8edbb81958
8 changed files with 617 additions and 37 deletions

View file

@ -64,6 +64,7 @@ concat_files \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"js/util.js" \ "js/util.js" \
"js/conflict.js" \
"js/loader.js" \ "js/loader.js" \
"js/tree.js" \ "js/tree.js" \
"js/preview.js" \ "js/preview.js" \

203
browse/js/conflict.js Normal file
View file

@ -0,0 +1,203 @@
// conflict.js — shared conflict-resolution dialog for the browse tool.
//
// Surfaced when a save loses an optimistic-concurrency race: the file
// changed on the server since the user loaded it (the editor sends an
// If-Match precondition; the master replies 412). Rather than clobber the
// other writer, the editor opens this dialog showing a mine-vs-theirs diff
// and four choices.
//
// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview
// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That
// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox
// conflict UI, which would resolve `.zddc-outbox/<id>.conflict-<ts>/` entries
// against new server endpoints rather than the live file).
//
// Reuses the modal shell + diff markup conventions from history.js and the
// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
function toast(msg, level) {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, level || 'info');
}
}
// Render a line diff of base→mine into `pane` (theirs treated as the
// base, so additions are what this save would introduce). Mirrors the
// history.js diff view.
function renderDiff(pane, theirsText, mineText) {
pane.innerHTML = '';
var ops = (window.zddc && window.zddc.diff)
? window.zddc.diff.lines(theirsText, mineText)
: null;
var diff = document.createElement('div');
diff.className = 'md-diff';
if (!ops) {
diff.textContent = 'Diff unavailable (diff module not loaded).';
pane.appendChild(diff);
return;
}
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 g = document.createElement('span');
g.className = 'md-diff-gutter';
g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
var t = document.createElement('span');
t.className = 'md-diff-text';
t.textContent = op.text;
line.appendChild(g);
line.appendChild(t);
diff.appendChild(line);
});
if (unchanged) {
var same = document.createElement('div');
same.className = 'md-diff-line md-diff-eq';
same.textContent = '(no differences — your copy matches the server)';
diff.appendChild(same);
}
pane.appendChild(diff);
var s = window.zddc.diff.stats(ops);
var stat = document.createElement('p');
stat.className = 'md-history-hint';
stat.textContent = 'Your version vs. current server: +' + s.added + ' / ' + s.removed;
pane.appendChild(stat);
}
// open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'>
//
// opts:
// filename — display name (e.g. node.name)
// mineText — the user's current (unsaved) content, for the diff
// theirsText — current server content (string), OR…
// fetchTheirs — async () => string — lazy fetch of current server content
// onOverwrite — async () => void — re-save, forcing past the conflict
// onReload — async () => void — discard mine, reload from server
// onSaveCopy — async () => void — write mine to a sibling path (optional)
//
// The matching callback runs when its button is clicked; on success the
// dialog closes and resolves with the action name. On callback error the
// dialog stays open (a toast explains) so the user can pick another path.
// Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched.
function open(opts) {
opts = opts || {};
return new Promise(function (resolve) {
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 = 'Conflict — ' + (opts.filename || 'file');
var body = document.createElement('div');
body.className = 'md-history-body';
box.appendChild(title);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
var settled = false;
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', onKey);
}
function finish(result) {
if (settled) return;
settled = true;
close();
resolve(result);
}
function onKey(e) { if (e.key === 'Escape') finish('cancel'); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) finish('cancel');
});
var hint = document.createElement('p');
hint.className = 'md-history-hint';
hint.textContent = '"' + (opts.filename || 'This file')
+ '" was changed by someone else since you opened it. '
+ 'Pick how to resolve — nothing is saved until you choose.';
body.appendChild(hint);
var diffPane = document.createElement('div');
diffPane.textContent = 'Loading current server version…';
body.appendChild(diffPane);
var footer = document.createElement('div');
footer.className = 'md-history-footer';
body.appendChild(footer);
function makeBtn(label, primary) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (primary) b.className = 'btn-primary';
footer.appendChild(b);
return b;
}
var overwriteBtn = makeBtn('Overwrite (keep mine)');
var reloadBtn = makeBtn('Discard mine — reload theirs');
var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null;
var cancelBtn = makeBtn('Cancel', true);
function setBusy(busy) {
[overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) {
if (b) b.disabled = busy;
});
}
// Each action runs its callback; on success close+resolve, on
// error toast and re-enable so the user can try another path.
function wire(btn, fn, result) {
if (!btn) return;
btn.addEventListener('click', function () {
setBusy(true);
Promise.resolve()
.then(function () { return fn ? fn() : undefined; })
.then(function () { finish(result); })
.catch(function (e) {
toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error');
setBusy(false);
});
});
}
wire(overwriteBtn, opts.onOverwrite, 'overwrite');
wire(reloadBtn, opts.onReload, 'reload');
wire(copyBtn, opts.onSaveCopy, 'savecopy');
cancelBtn.addEventListener('click', function () { finish('cancel'); });
// Resolve the "theirs" text (eagerly provided or lazily fetched)
// then render the diff. A fetch failure leaves the actions usable
// — the diff is an aid, not a gate.
Promise.resolve()
.then(function () {
if (typeof opts.theirsText === 'string') return opts.theirsText;
if (opts.fetchTheirs) return opts.fetchTheirs();
return null;
})
.then(function (theirs) {
if (settled) return;
if (theirs == null) {
diffPane.textContent = 'Could not load the current server version for comparison.';
return;
}
renderDiff(diffPane, theirs, opts.mineText || '');
})
.catch(function (e) {
if (settled) return;
diffPane.textContent = 'Could not load the current server version: '
+ (e && e.message ? e.message : e);
});
});
}
window.app.modules.conflict = { open: open };
})();

View file

@ -278,8 +278,8 @@
// ── Save ──────────────────────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
function saveContent(node, content) { function saveContent(node, content, opts) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8'); return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts);
} }
var isZipMemberNode = util.isZipMemberNode; var isZipMemberNode = util.isZipMemberNode;
@ -310,11 +310,21 @@
} }
dispose(); dispose();
// Read content. // Read content + the server version token (etag/last-modified) so
var text; // the save can send an If-Match precondition and detect a concurrent
// edit instead of clobbering it. Falls back to getArrayBuffer (and a
// null token → no precondition) for callers/sources without it.
var text, loadedEtag = null, loadedLastModified = null;
try { try {
var buf = await ctx.getArrayBuffer(node); if (ctx.getContentWithVersion) {
text = new TextDecoder('utf-8', { fatal: false }).decode(buf); var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
loadedEtag = loaded.etag;
loadedLastModified = loaded.lastModified;
} else {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) { } catch (e) {
container.innerHTML = container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">' '<div class="preview-empty" style="color:var(--danger)">'
@ -553,7 +563,11 @@
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmTextarea, fmEl: fmTextarea,
ac: ac ac: ac,
// Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag.
etag: loadedEtag,
lastModified: loadedLastModified
}; };
currentInstance = instance; currentInstance = instance;
@ -687,21 +701,81 @@
fmTextarea.addEventListener('input', onFmChange); fmTextarea.addEventListener('input', onFmChange);
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next
// save's If-Match matches — no false conflict on save→edit→save),
// refresh the dirty baseline, clear dirty.
async function markSaved(content, res) {
if (currentInstance !== instance) return;
if (res && res.etag) instance.etag = res.etag;
instance.hash = await hashContent(content);
if (currentInstance !== instance) return;
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
}
// 412 → the file changed on the server since we loaded it. Open the
// shared conflict dialog rather than clobbering. Dirty stays set
// until the user resolves.
async function resolveConflict(content) {
var conflict = window.app.modules.conflict;
var prev = window.app.modules.preview;
if (!conflict || !prev) return; // no UI available — leave dirty
await conflict.open({
filename: node.name,
mineText: content,
fetchTheirs: function () {
return prev.getContentWithVersion(node).then(function (r) {
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
});
},
// Overwrite: re-fetch the CURRENT version and save against it
// (still 412s on a third concurrent writer rather than blind-
// forcing).
onOverwrite: function () {
return prev.getContentWithVersion(node).then(function (cur) {
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
}).then(function (res) { return markSaved(content, res); });
},
// Reload theirs: discard local edits. Clear dirty first so the
// renderInline dirty-guard skips its confirm; the fresh render
// re-captures content + a new ETag.
onReload: function () {
markDirty(false);
instance.dirty = false;
return prev.showFilePreview(node);
},
onSaveCopy: function () {
return util.saveCopy(node, content, 'text/markdown; charset=utf-8')
.then(function (name) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved your version as ' + name, 'success');
}
});
}
});
if (currentInstance === instance) statusEl.textContent = '';
}
async function save() { async function save() {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return; if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown()); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); var res = await saveContent(node, content, {
if (currentInstance !== instance) return; // switched away mid-save etag: instance.etag, lastModified: instance.lastModified
instance.hash = await hashContent(content); });
markDirty(false); await markSaved(content, res);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) { } catch (e) {
if (e && e.status === 412) {
if (currentInstance !== instance) return;
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
}
statusEl.textContent = 'Save failed: ' + (e.message || e); statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error'); window.zddc.toast('Save failed: ' + (e.message || e), 'error');

View file

@ -45,11 +45,11 @@
// ── Save (mirrors preview-markdown.js) ───────────────────────────────── // ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content) { function saveContent(node, content, opts) {
// Via the shared saveFile so local (FS-Access) saves escalate to // Via the shared saveFile so local (FS-Access) saves escalate to
// readwrite the same as the markdown editor — previously this path // readwrite the same as the markdown editor — previously this path
// skipped ensureWritable and failed on read-only-picked folders. // skipped ensureWritable and failed on read-only-picked folders.
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8'); return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts);
} }
var isZipMemberNode = util.isZipMemberNode; var isZipMemberNode = util.isZipMemberNode;
@ -350,6 +350,10 @@
var currentEditor = null; var currentEditor = null;
var currentDirty = false; var currentDirty = false;
var currentNodeRef = null; var currentNodeRef = null;
// Server version token for the loaded file — sent as If-Match on save
// and refreshed from each successful PUT's response ETag.
var currentEtag = null;
var currentLastModified = null;
function dispose() { function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once // CM doesn't have an explicit destroy(); GC handles it once
@ -358,6 +362,8 @@
currentEditor = null; currentEditor = null;
currentDirty = false; currentDirty = false;
currentNodeRef = null; currentNodeRef = null;
currentEtag = null;
currentLastModified = null;
} }
function isDirty() { function isDirty() {
@ -377,10 +383,17 @@
} }
dispose(); dispose();
var text; var text, loadedEtag = null, loadedLastModified = null;
try { try {
var buf = await ctx.getArrayBuffer(node); if (ctx.getContentWithVersion) {
text = new TextDecoder('utf-8', { fatal: false }).decode(buf); var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
loadedEtag = loaded.etag;
loadedLastModified = loaded.lastModified;
} else {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) { } catch (e) {
container.innerHTML = container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">' '<div class="preview-empty" style="color:var(--danger)">'
@ -483,6 +496,8 @@
currentEditor = editor; currentEditor = editor;
currentNodeRef = node; currentNodeRef = node;
currentDirty = false; currentDirty = false;
currentEtag = loadedEtag;
currentLastModified = loadedLastModified;
if (!writable) { if (!writable) {
saveBtn.disabled = true; saveBtn.disabled = true;
@ -511,6 +526,56 @@
markDirty(h !== initialHash); markDirty(h !== initialHash);
}); });
// Adopt the new server ETag + refresh the dirty baseline after a
// successful write so save→edit→save doesn't false-conflict.
async function markSaved(content, res) {
if (currentEditor !== editor) return;
if (res && res.etag) currentEtag = res.etag;
initialHash = await hashContent(content);
if (currentEditor !== editor) return;
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
}
// 412 → file changed on the server since load. Open the shared
// conflict dialog instead of clobbering.
async function resolveConflict(content) {
var conflict = window.app.modules.conflict;
var prev = window.app.modules.preview;
if (!conflict || !prev) return;
await conflict.open({
filename: node.name,
mineText: content,
fetchTheirs: function () {
return prev.getContentWithVersion(node).then(function (r) {
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
});
},
onOverwrite: function () {
return prev.getContentWithVersion(node).then(function (cur) {
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
}).then(function (res) { return markSaved(content, res); });
},
onReload: function () {
markDirty(false);
currentDirty = false;
return prev.showFilePreview(node);
},
onSaveCopy: function () {
return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
.then(function (name) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved your version as ' + name, 'success');
}
});
}
});
if (currentEditor === editor) statusEl.textContent = '';
}
async function save() { async function save() {
if (saveBtn.disabled) return; if (saveBtn.disabled) return;
// Re-check authority at click time, not via the mount-time // Re-check authority at click time, not via the mount-time
@ -520,14 +585,17 @@
var content = editor.getValue(); var content = editor.getValue();
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
await saveContent(node, content); var res = await saveContent(node, content, {
initialHash = await hashContent(content); etag: currentEtag, lastModified: currentLastModified
markDirty(false); });
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); await markSaved(content, res);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) { } catch (e) {
if (e && e.status === 412) {
if (currentEditor !== editor) return;
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
}
statusEl.textContent = 'Save failed: ' + (e.message || e); statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error'); window.zddc.toast('Save failed: ' + (e.message || e), 'error');

View file

@ -56,6 +56,30 @@
throw new Error('no source for file'); throw new Error('no source for file');
} }
// Like getArrayBuffer, but also returns the server version token
// ({etag, lastModified}) captured from the content GET. The editors use
// it to send an If-Match precondition on save so a concurrent edit is
// rejected (412) instead of silently clobbered. FS-Access mode has no
// server version — etag/lastModified are null and the precondition is a
// clean no-op (a single locally-picked file has no concurrency).
async function getContentWithVersion(node) {
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var buf = await resp.arrayBuffer();
return {
buf: buf,
etag: resp.headers.get('ETag') || null,
lastModified: resp.headers.get('Last-Modified') || null
};
}
if (node.handle) {
var f = await node.handle.getFile();
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
}
throw new Error('no source for file');
}
async function getBlobUrl(node) { async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>" // Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type // URLs) load straight from the server — preserves Content-Type
@ -180,7 +204,7 @@
window.app.modules.markdown && window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') { typeof window.app.modules.markdown.render === 'function') {
try { try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer }); await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) { } catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e)); renderError(container, 'Markdown render failed: ' + (e.message || e));
} }
@ -193,7 +217,7 @@
var yamlMod = window.app.modules.yamledit; var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) { if (yamlMod && yamlMod.handles(node)) {
try { try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer }); await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) { } catch (e) {
renderError(container, 'YAML render failed: ' + (e.message || e)); renderError(container, 'YAML render failed: ' + (e.message || e));
} }
@ -443,6 +467,9 @@
// Tear down any live editor + blank the pane (rescope / popstate). // Tear down any live editor + blank the pane (rescope / popstate).
clearPreview: clearPreview, clearPreview: clearPreview,
// Expose for the markdown plugin so it can read file bytes. // Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer getArrayBuffer: getArrayBuffer,
// Like getArrayBuffer but also returns the {etag, lastModified}
// version token — the editors use it for optimistic-concurrency saves.
getContentWithVersion: getContentWithVersion
}; };
})(); })();

View file

@ -90,33 +90,100 @@
return false; return false;
} }
// Write content back to a file's source. Local (FS-Access) folders are // Thrown by saveFile when the server rejects a write with 412
// Precondition Failed — the file changed under us since we loaded it.
// Callers branch on `.status === 412` to open the conflict UI instead
// of treating it as a generic save failure.
function ConflictError(message) {
var e = new Error(message || 'Conflict: file changed on server');
e.name = 'ConflictError';
e.status = 412;
return e;
}
// Write content back to a file's source, returning { etag } (the new
// server ETag, or null in FS-Access mode). Local (FS-Access) folders are
// picked read-only, so the first write escalates to readwrite via // picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the // upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files. // session). contentType sets the PUT Content-Type for server files.
// Throws when the source has no write target. //
async function saveFile(node, content, contentType) { // opts (server mode only):
// etag — send as `If-Match` so the master 412s if the file
// changed since we observed this version (optimistic
// concurrency; preferred — exact).
// lastModified — fallback precondition sent as `If-Unmodified-Since`
// (raw HTTP-date string) when no etag is available.
// force — skip the precondition entirely (deliberate overwrite).
//
// Throws ConflictError (.status===412) on a precondition failure, a
// plain Error('HTTP <status>') on any other non-2xx, or "no write
// target" when the source is read-only.
async function saveFile(node, content, contentType, opts) {
opts = opts || {};
if (node.handle && typeof node.handle.createWritable === 'function') { if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable(); if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable(); var writable = await node.handle.createWritable();
await writable.write(content); await writable.write(content);
await writable.close(); await writable.close();
return; return { etag: null };
} }
if (node.url && window.app.state.source === 'server') { if (node.url && window.app.state.source === 'server') {
var headers = { 'Content-Type': contentType };
if (!opts.force) {
if (opts.etag) headers['If-Match'] = opts.etag;
else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
}
var resp = await fetch(node.url, { var resp = await fetch(node.url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': contentType }, headers: headers,
body: content, body: content,
credentials: 'same-origin' credentials: 'same-origin'
}); });
if (resp.status === 412) throw ConflictError();
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
return; return { etag: resp.headers.get('ETag') || null };
} }
throw new Error('No write target for this file (read-only source).'); throw new Error('No write target for this file (read-only source).');
} }
// Write `content` to a NEW sibling of `node` named
// `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
// conflicting edit can be parked without losing either version. Probes
// for a free name (numeric-suffix bump, capped) so a same-second retry
// doesn't clobber a prior copy. Returns the created filename. The PUT
// uses no precondition — it's a brand-new path.
async function saveCopy(node, content, contentType) {
if (!(node.url && window.app.state.source === 'server')) {
throw new Error('Save a copy is only available for server files.');
}
var split = window.zddc.splitExtension(node.name);
var stem = split.name || node.name;
var ext = split.extension;
var d = new Date();
var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
+ '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
var base = stem + '-conflict-' + stamp;
var slash = node.url.lastIndexOf('/');
var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
var name = '', candidateUrl = '';
for (var i = 0; i < 20; i++) {
name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
candidateUrl = dirUrl + encodeURIComponent(name);
var head;
try {
head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
} catch (_e) {
break; // network unknown — attempt the write rather than spin
}
if (head.status === 404) break; // free slot
if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway
if (i === 19) throw new Error('Could not find a free filename for the copy.');
}
await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
return name;
}
window.app.modules.util = { window.app.modules.util = {
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
hashContent: hashContent, hashContent: hashContent,
@ -126,6 +193,8 @@
fetchAccessEmails: fetchAccessEmails, fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize, fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode, isZipMemberNode: isZipMemberNode,
saveFile: saveFile saveFile: saveFile,
saveCopy: saveCopy,
ConflictError: ConflictError
}; };
})(); })();

View file

@ -55,6 +55,10 @@ export default defineConfig({
name: 'browse', name: 'browse',
testMatch: 'browse.spec.js', testMatch: 'browse.spec.js',
}, },
{
name: 'conflict',
testMatch: 'conflict.spec.js',
},
{ {
name: 'zddc-source', name: 'zddc-source',
testMatch: 'zddc-source.spec.js', testMatch: 'zddc-source.spec.js',

134
tests/conflict.spec.js Normal file
View file

@ -0,0 +1,134 @@
// conflict.spec.js — optimistic-concurrency save (If-Match → 412) + the
// shared conflict-resolution dialog in the browse tool.
//
// These drive the client modules directly against a stubbed fetch rather
// than a real master: the test zddc-server embeds the COMMITTED
// internal/apps/embedded/browse.html, not browse/dist/browse.html, so a
// server-mode E2E would run stale code. Loading the fresh dist build over
// file:// and stubbing fetch exercises exactly the code under test. Full
// server-mode behavior (the master's checkIfMatch → 412) is covered
// manually / on the bitnest dev server.
import { test, expect } from '@playwright/test';
import * as path from 'path';
const HTML_PATH = path.resolve('browse/dist/browse.html');
test.describe('Conflict / optimistic concurrency', () => {
test.beforeEach(async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
// init.js + util.js + conflict.js run synchronously on load.
await page.waitForFunction(
() => window.app && window.app.modules
&& window.app.modules.util && window.app.modules.conflict);
});
test('saveFile sends If-Match and throws ConflictError on 412', async ({ page }) => {
const result = await page.evaluate(async () => {
const calls = [];
window.fetch = async (url, opts) => {
calls.push({ url, opts });
return { status: 412, ok: false, headers: { get: () => null } };
};
window.app.state.source = 'server';
const node = { url: '/doc.md', name: 'doc.md' };
let status = null, name = null;
try {
await window.app.modules.util.saveFile(
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
} catch (e) {
status = e.status;
name = e.name;
}
return {
sentIfMatch: calls[0] && calls[0].opts.headers['If-Match'],
method: calls[0] && calls[0].opts.method,
status, name
};
});
expect(result.method).toBe('PUT');
expect(result.sentIfMatch).toBe('"v1"');
expect(result.status).toBe(412);
expect(result.name).toBe('ConflictError');
});
test('saveFile returns the new ETag on success (re-edit loop)', async ({ page }) => {
const result = await page.evaluate(async () => {
window.fetch = async () => ({
status: 200, ok: true,
headers: { get: (h) => (h === 'ETag' ? '"v2"' : null) }
});
window.app.state.source = 'server';
const node = { url: '/doc.md', name: 'doc.md' };
const res = await window.app.modules.util.saveFile(
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
return res;
});
expect(result.etag).toBe('"v2"');
});
test('saveFile omits the precondition when force is set', async ({ page }) => {
const sent = await page.evaluate(async () => {
let headers = null;
window.fetch = async (url, opts) => {
headers = opts.headers;
return { status: 200, ok: true, headers: { get: () => null } };
};
window.app.state.source = 'server';
await window.app.modules.util.saveFile(
{ url: '/doc.md', name: 'doc.md' }, 'hi', 'text/markdown',
{ etag: '"v1"', force: true });
return { hasIfMatch: 'If-Match' in headers };
});
expect(sent.hasIfMatch).toBe(false);
});
test('conflict dialog renders a diff and Overwrite resolves via the callback', async ({ page }) => {
// Kick off the dialog; stash the resolution + a flag the callback sets.
await page.evaluate(() => {
window.__conflict = { resolved: null, overwrote: false };
window.app.modules.conflict.open({
filename: 'doc.md',
mineText: 'line one\nMINE EDIT\nline three\n',
theirsText: 'line one\nTHEIR EDIT\nline three\n',
onOverwrite: async () => { window.__conflict.overwrote = true; },
onReload: async () => {},
onSaveCopy: async () => {}
}).then((r) => { window.__conflict.resolved = r; });
});
// Modal + diff present.
const overlay = page.locator('.md-history-overlay');
await expect(overlay).toBeVisible();
await expect(overlay.locator('.md-diff-add').first()).toBeVisible();
await expect(overlay.locator('.md-diff-del').first()).toBeVisible();
// Click "Overwrite (keep mine)".
await overlay.getByRole('button', { name: 'Overwrite (keep mine)' }).click();
await page.waitForFunction(() => window.__conflict.resolved !== null);
const outcome = await page.evaluate(() => window.__conflict);
expect(outcome.resolved).toBe('overwrite');
expect(outcome.overwrote).toBe(true);
await expect(overlay).toBeHidden();
});
test('conflict dialog Cancel resolves "cancel" and runs no callback', async ({ page }) => {
await page.evaluate(() => {
window.__c2 = { resolved: null, ran: false };
window.app.modules.conflict.open({
filename: 'doc.md',
mineText: 'a\n',
theirsText: 'b\n',
onOverwrite: async () => { window.__c2.ran = true; },
onReload: async () => { window.__c2.ran = true; }
}).then((r) => { window.__c2.resolved = r; });
});
const overlay = page.locator('.md-history-overlay');
await expect(overlay).toBeVisible();
await overlay.getByRole('button', { name: 'Cancel' }).click();
await page.waitForFunction(() => window.__c2.resolved !== null);
const outcome = await page.evaluate(() => window.__c2);
expect(outcome.resolved).toBe('cancel');
expect(outcome.ran).toBe(false);
});
});