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" \
|
"../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
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 ────────────────────────────────────────────────────────────────
|
// ── 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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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
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