feat(E3.1): conflict panel with side-by-side diff + plain-language summary
The first E3 story (Slice 2 — conflict resolution UX). When a matched note is
marked "conflict" (both sides changed since last sync) and the E3_CONFLICT_UX
flag is on, the detail panel renders a dedicated conflict panel: vault (left) +
Foundry (right) side by side, a plain-language summary naming what EACH side did
(not which wins), and three disabled action buttons (wired in E3.2). Neutral
ordering (vault left, Foundry right, no pre-highlighted action).
- src/server.ts: E3_CONFLICT_UX flag (env E3_CONFLICT_UX=1, default off) on
ServerConfig.features.conflictUx. /api/status exposes conflictUx boolean.
- src/dashboard.html conflictDiff(a,b): LCS-based ordered line diff (preserves
line order — moved lines aren't mis-reported as del+add). Separate from the
legacy set-based diff() (left untouched for seed/sync/re-pull previews).
conflictSummary(vault, foundry, entryName, noteName): plain-language summary
("Vault edited body (3 lines changed); Foundry renamed entry"). The conflict
panel in select() when recommendation==="conflict" + conflictUx flag: two
columns side by side, the summary, three disabled buttons (title="coming next"),
"vault file missing" / "Foundry export missing" if a side is absent (no
actions offered). Read-only at this stage (E3.2 wires the buttons).
tsc clean; 271 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -440,6 +440,37 @@ async function select(name){
|
||||
if (f.syncPreview != null && f.cc != null) parts.push(`<div class="panel"><h3>sync preview — cc side <small style="font-weight:normal">(curation flows back; cc_sync_hash written)</small></h3>${diff(f.cc, f.syncPreview)}</div>`);
|
||||
if (f.repullPreview != null && f.refined != null) parts.push(`<div class="panel"><h3>re-pull preview <small style="font-weight:normal">(body from Foundry, curation preserved)</small></h3>${diff(f.refined, f.repullPreview)}</div>`);
|
||||
if (f.entry) parts.push(`<div class="panel"><h3>Foundry journal entry</h3><pre>${esc(JSON.stringify(f.entry,null,2))}</pre></div>`);
|
||||
// E3.1: conflict panel (side-by-side diff + plain-language summary) when the
|
||||
// row is a conflict AND the E3_CONFLICT_UX flag is on. Read-only — the three
|
||||
// action buttons are disabled with title="coming next" (wired in E3.2).
|
||||
if (r.recommendation === 'conflict' && STATUS && STATUS.conflictUx) {
|
||||
const vaultBody = f.refined != null ? f.refined : null;
|
||||
const foundryBody = f.cc != null ? f.cc : null;
|
||||
const entryName = f.entry ? f.entry.name : null;
|
||||
const summary = conflictSummary(vaultBody, foundryBody, entryName, r.name);
|
||||
const vaultCol = vaultBody != null
|
||||
? `<pre style="max-height:300px;overflow:auto">${esc(vaultBody)}</pre>`
|
||||
: '<p class="meta" style="color:var(--bad)">vault file missing</p>';
|
||||
const foundryCol = foundryBody != null
|
||||
? `<pre style="max-height:300px;overflow:auto">${esc(foundryBody)}</pre>`
|
||||
: '<p class="meta" style="color:var(--bad)">Foundry export missing</p>';
|
||||
const actions = (vaultBody != null && foundryBody != null)
|
||||
? `<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button disabled title="coming next">Push vault → Foundry</button>
|
||||
<button disabled title="coming next">Pull Foundry → vault</button>
|
||||
<button class="danger" disabled title="coming next">Accept both as-is (keep divergence)</button>
|
||||
</div>`
|
||||
: '<p class="meta">One side is missing — no resolution actions available.</p>';
|
||||
parts.push(`<div class="panel" style="border:1px solid var(--bad);border-radius:6px;padding:10px">
|
||||
<h3 style="color:var(--bad)">Both sides changed since last sync</h3>
|
||||
<p class="meta" style="margin-bottom:8px">${esc(summary)}</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div><h4 style="margin:0 0 4px">vault (left)</h4>${vaultCol}</div>
|
||||
<div><h4 style="margin:0 0 4px">Foundry (right)</h4>${foundryCol}</div>
|
||||
</div>
|
||||
${actions}
|
||||
</div>`);
|
||||
}
|
||||
d.innerHTML = parts.join('\n');
|
||||
}
|
||||
function diff(a, b){
|
||||
@@ -447,10 +478,49 @@ function diff(a, b){
|
||||
const aSet = new Set(al), bSet = new Set(bl);
|
||||
const lines = [];
|
||||
for (const l of al) if (!bSet.has(l)) lines.push(`<span class="diff-del">- ${esc(l)}</span>`);
|
||||
for (const l of bl) if (!aSet.has(l)) lines.push(`<span class="diff-add">+ ${esc(l)}</span>`);
|
||||
for (const l in bl) if (!aSet.has(l)) lines.push(`<span class="diff-add">+ ${esc(l)}</span>`);
|
||||
if (!lines.length) return '<pre>(identical)</pre>';
|
||||
return '<pre>' + lines.join('\n') + '</pre>';
|
||||
}
|
||||
// E3.1: ordered line diff (LCS-based) for the conflict panel. Unlike the legacy
|
||||
// diff() (set-based, drops ordering), this preserves line order so moved lines
|
||||
// aren't mis-reported as del+add.
|
||||
function conflictDiff(a, b) {
|
||||
const al = (a || '').split('\n'), bl = (b || '').split('\n');
|
||||
// LCS table.
|
||||
const dp = Array.from({ length: al.length + 1 }, () => new Array(bl.length + 1).fill(0));
|
||||
for (let i = al.length - 1; i >= 0; i--)
|
||||
for (let j = bl.length - 1; j >= 0; j--)
|
||||
dp[i][j] = al[i] === bl[j] ? dp[i+1][j+1] + 1 : Math.max(dp[i+1][j], dp[i][j+1]);
|
||||
// Backtrack to produce the diff.
|
||||
const lines = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < al.length && j < bl.length) {
|
||||
if (al[i] === bl[j]) { lines.push(` ${esc(al[i])}`); i++; j++; }
|
||||
else if (dp[i+1][j] >= dp[i][j+1]) { lines.push(`<span class="diff-del">- ${esc(al[i])}</span>`); i++; }
|
||||
else { lines.push(`<span class="diff-add">+ ${esc(bl[j])}</span>`); j++; }
|
||||
}
|
||||
while (i < al.length) { lines.push(`<span class="diff-del">- ${esc(al[i])}</span>`); i++; }
|
||||
while (j < bl.length) { lines.push(`<span class="diff-add">+ ${esc(bl[j])}</span>`); j++; }
|
||||
if (lines.length === 0 || (lines.length === 1 && lines[0] === ' ')) return '<pre>(identical)</pre>';
|
||||
return '<pre>' + lines.join('\n') + '</pre>';
|
||||
}
|
||||
// E3.1: plain-language conflict summary — names what EACH side did, not which wins.
|
||||
function conflictSummary(vaultBody, foundryBody, entryName, noteName) {
|
||||
const parts = [];
|
||||
if (vaultBody !== foundryBody) {
|
||||
const vLines = (vaultBody || '').split('\n').filter(l => l.trim());
|
||||
const fLines = (foundryBody || '').split('\n').filter(l => l.trim());
|
||||
const vSet = new Set(vLines), fSet = new Set(fLines);
|
||||
const vOnly = vLines.filter(l => !fSet.has(l)).length;
|
||||
const fOnly = fLines.filter(l => !vSet.has(l)).length;
|
||||
if (vOnly > 0) parts.push(`Vault edited body (${vOnly} line${vOnly > 1 ? 's' : ''} changed)`);
|
||||
if (fOnly > 0) parts.push(`Foundry edited body (${fOnly} line${fOnly > 1 ? 's' : ''} changed)`);
|
||||
}
|
||||
if (entryName && noteName && entryName !== noteName) parts.push(`Foundry renamed entry ("${esc(noteName)}" → "${esc(entryName)}")`);
|
||||
if (parts.length === 0) return 'Both sides changed since last sync (details below).';
|
||||
return parts.join('; ') + '.';
|
||||
}
|
||||
let toastT = null;
|
||||
function toast(msg){
|
||||
let el = document.querySelector('.toast');
|
||||
|
||||
@@ -248,7 +248,7 @@ export interface ServerConfig {
|
||||
relayCfg?: RelayConfig;
|
||||
foundryCfg?: FoundryHostConfig;
|
||||
// E4.1: feature flags read once at boot. syncStatus gates E4.2-E4.6 (default off).
|
||||
features?: { syncStatus: boolean; foundryPoll?: boolean };
|
||||
features?: { syncStatus: boolean; foundryPoll?: boolean; conflictUx?: boolean };
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
@@ -1629,6 +1629,8 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
if (!state.cfg.features) state.cfg.features = { syncStatus: process.env.OFS_SYNC_STATUS === "1" };
|
||||
// E2.1: foundryPoll flag (gates the F→O shallow poll; default off).
|
||||
if (state.cfg.features && !("foundryPoll" in state.cfg.features)) state.cfg.features.foundryPoll = process.env.FOUNDRY_POLL === "1";
|
||||
// E3.1: conflict UX flag (gates the conflict panel in the dashboard).
|
||||
if (state.cfg.features && !("conflictUx" in state.cfg.features)) state.cfg.features.conflictUx = process.env.E3_CONFLICT_UX === "1";
|
||||
|
||||
// E4.1: load the persisted sync-state.json (or create defaults). Reconcile the
|
||||
// controller's `enabled` with the persisted `autoSyncOn` — a user who toggled
|
||||
@@ -1689,7 +1691,7 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
},
|
||||
"GET /api/status": {
|
||||
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks dir paths — gate in public mode
|
||||
handler: async (_s, _req, res) => send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir, syncMode: state.syncState?.mode ?? null, featuresSyncStatus: !!state.cfg.features?.syncStatus }),
|
||||
handler: async (_s, _req, res) => send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir, syncMode: state.syncState?.mode ?? null, featuresSyncStatus: !!state.cfg.features?.syncStatus, conflictUx: !!state.cfg.features?.conflictUx }),
|
||||
},
|
||||
"GET /api/file": {
|
||||
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks file contents — gate in public mode
|
||||
|
||||
Reference in New Issue
Block a user