ZDDC/browse/js/conflict.js
2026-06-11 13:32:31 -05:00

203 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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