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:
parent
d524966f00
commit
8edbb81958
8 changed files with 617 additions and 37 deletions
|
|
@ -64,6 +64,7 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"js/util.js" \
|
||||
"js/conflict.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
|
|
|
|||
203
browse/js/conflict.js
Normal file
203
browse/js/conflict.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -278,8 +278,8 @@
|
|||
|
||||
// ── Save ────────────────────────────────────────────────────────────────
|
||||
|
||||
function saveContent(node, content) {
|
||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
|
||||
function saveContent(node, content, opts) {
|
||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts);
|
||||
}
|
||||
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
|
|
@ -310,11 +310,21 @@
|
|||
}
|
||||
dispose();
|
||||
|
||||
// Read content.
|
||||
var text;
|
||||
// Read content + the server version token (etag/last-modified) so
|
||||
// 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 {
|
||||
if (ctx.getContentWithVersion) {
|
||||
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) {
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty" style="color:var(--danger)">'
|
||||
|
|
@ -553,7 +563,11 @@
|
|||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
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;
|
||||
|
||||
|
|
@ -687,21 +701,81 @@
|
|||
fmTextarea.addEventListener('input', onFmChange);
|
||||
|
||||
// ── 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() {
|
||||
if (currentInstance !== instance) return;
|
||||
if (!instance.dirty || !canSave(node)) return;
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
if (currentInstance !== instance) return; // switched away mid-save
|
||||
instance.hash = await hashContent(content);
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
var res = await saveContent(node, content, {
|
||||
etag: instance.etag, lastModified: instance.lastModified
|
||||
});
|
||||
await markSaved(content, res);
|
||||
} 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);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@
|
|||
|
||||
// ── 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
|
||||
// readwrite the same as the markdown editor — previously this path
|
||||
// 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;
|
||||
|
|
@ -350,6 +350,10 @@
|
|||
var currentEditor = null;
|
||||
var currentDirty = false;
|
||||
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() {
|
||||
// CM doesn't have an explicit destroy(); GC handles it once
|
||||
|
|
@ -358,6 +362,8 @@
|
|||
currentEditor = null;
|
||||
currentDirty = false;
|
||||
currentNodeRef = null;
|
||||
currentEtag = null;
|
||||
currentLastModified = null;
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
|
|
@ -377,10 +383,17 @@
|
|||
}
|
||||
dispose();
|
||||
|
||||
var text;
|
||||
var text, loadedEtag = null, loadedLastModified = null;
|
||||
try {
|
||||
if (ctx.getContentWithVersion) {
|
||||
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) {
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty" style="color:var(--danger)">'
|
||||
|
|
@ -483,6 +496,8 @@
|
|||
currentEditor = editor;
|
||||
currentNodeRef = node;
|
||||
currentDirty = false;
|
||||
currentEtag = loadedEtag;
|
||||
currentLastModified = loadedLastModified;
|
||||
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
|
|
@ -511,6 +526,56 @@
|
|||
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() {
|
||||
if (saveBtn.disabled) return;
|
||||
// Re-check authority at click time, not via the mount-time
|
||||
|
|
@ -520,14 +585,17 @@
|
|||
var content = editor.getValue();
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
initialHash = await hashContent(content);
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
var res = await saveContent(node, content, {
|
||||
etag: currentEtag, lastModified: currentLastModified
|
||||
});
|
||||
await markSaved(content, res);
|
||||
} 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);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
|
|
|
|||
|
|
@ -56,6 +56,30 @@
|
|||
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) {
|
||||
// Server-served files (including zip members at "<…>.zip/<member>"
|
||||
// URLs) load straight from the server — preserves Content-Type
|
||||
|
|
@ -180,7 +204,7 @@
|
|||
window.app.modules.markdown &&
|
||||
typeof window.app.modules.markdown.render === 'function') {
|
||||
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) {
|
||||
renderError(container, 'Markdown render failed: ' + (e.message || e));
|
||||
}
|
||||
|
|
@ -193,7 +217,7 @@
|
|||
var yamlMod = window.app.modules.yamledit;
|
||||
if (yamlMod && yamlMod.handles(node)) {
|
||||
try {
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||
} catch (e) {
|
||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
||||
}
|
||||
|
|
@ -443,6 +467,9 @@
|
|||
// Tear down any live editor + blank the pane (rescope / popstate).
|
||||
clearPreview: clearPreview,
|
||||
// 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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -90,33 +90,100 @@
|
|||
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
|
||||
// upload.ensureWritable (one permission prompt, then granted for the
|
||||
// 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') {
|
||||
var up = window.app.modules.upload;
|
||||
if (up && up.ensureWritable) await up.ensureWritable();
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
return { etag: null };
|
||||
}
|
||||
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, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
headers: headers,
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 412) throw ConflictError();
|
||||
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).');
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
escapeHtml: escapeHtml,
|
||||
hashContent: hashContent,
|
||||
|
|
@ -126,6 +193,8 @@
|
|||
fetchAccessEmails: fetchAccessEmails,
|
||||
fmtSize: fmtSize,
|
||||
isZipMemberNode: isZipMemberNode,
|
||||
saveFile: saveFile
|
||||
saveFile: saveFile,
|
||||
saveCopy: saveCopy,
|
||||
ConflictError: ConflictError
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export default defineConfig({
|
|||
name: 'browse',
|
||||
testMatch: 'browse.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'conflict',
|
||||
testMatch: 'conflict.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'zddc-source',
|
||||
testMatch: 'zddc-source.spec.js',
|
||||
|
|
|
|||
134
tests/conflict.spec.js
Normal file
134
tests/conflict.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue