// 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/.conflict-/` 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 }; })();