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>
203 lines
9.1 KiB
JavaScript
203 lines
9.1 KiB
JavaScript
// 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 };
|
||
})();
|