Skip to content

Commit 1d0966a

Browse files
author
DavidQ
committed
Add actionable merge conflict summary for blocked preview state - PR 11.251
1 parent 3393b92 commit 1d0966a

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# PR_11_251 — Actionable Merge Conflict Summary
2+
3+
## Summary
4+
Added a compact actionable conflict summary for Session Merge previews with conflicts, rendered above the raw preview JSON. Confirm/Apply remain disabled for conflict previews, and raw JSON preview remains available.
5+
6+
## Files Changed
7+
- `tools/workspace-v2/index.html`
8+
- `tools/workspace-v2/index.js`
9+
- `tests/runtime/V2MergeConflictSummary.test.mjs`
10+
11+
## Implementation Details
12+
1. Conflict summary UI
13+
- Added merge conflict summary node above raw preview JSON:
14+
- `#workspaceV2MergeConflictSummary`
15+
- Kept raw JSON preview in:
16+
- `#workspaceV2MergeOutput`
17+
18+
2. Conflict summary rendering behavior
19+
- Added:
20+
- `renderMergeConflictSummary()`
21+
- `conflictValuePreview(value)`
22+
- Summary shows:
23+
- `Conflict preview only. Apply is blocked until conflicts are resolved.`
24+
- total conflict count
25+
- each conflict path/key
26+
- source value preview
27+
- target value preview
28+
- Summary auto-hides when no conflicts exist.
29+
30+
3. State integration
31+
- Conflict summary refresh is wired into merge selection state updates.
32+
- Stale preview clear path also clears/hides conflict summary.
33+
- No merge semantics or conflict-resolution behavior was changed.
34+
35+
## Validation Commands Run
36+
```powershell
37+
node --check tools/workspace-v2/index.js
38+
node --check tests/runtime/V2MergeConflictSummary.test.mjs
39+
node tests/runtime/V2MergeConflictSummary.test.mjs
40+
```
41+
42+
## Validation Results
43+
- `node --check tools/workspace-v2/index.js` -> PASS
44+
- `node --check tests/runtime/V2MergeConflictSummary.test.mjs` -> PASS
45+
- `node tests/runtime/V2MergeConflictSummary.test.mjs` -> PASS
46+
- output: `tmp/v2-merge-conflict-summary-results.json`
47+
- failures: `[]`
48+
49+
## Verified
50+
- conflict preview renders conflict summary -> PASS
51+
- summary includes conflict count + paths + source/target value previews -> PASS
52+
- raw JSON preview remains visible below summary -> PASS
53+
- Confirm/Apply disabled while conflicts exist -> PASS
54+
- conflict-free preview does not render conflict summary -> PASS
55+
- conflict-free preview can still enable Confirm Preview -> PASS
56+
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const htmlPath = path.join(repoRoot, "tools", "workspace-v2", "index.html");
11+
const jsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
12+
const resultsPath = path.join(repoRoot, "tmp", "v2-merge-conflict-summary-results.json");
13+
14+
function checkSyntax(filePath) {
15+
try {
16+
execFileSync(process.execPath, ["--check", filePath], {
17+
cwd: repoRoot,
18+
stdio: ["ignore", "pipe", "pipe"]
19+
});
20+
return { ok: true, error: "" };
21+
} catch (error) {
22+
return { ok: false, error: (error?.stderr || error?.stdout || error?.message || "").toString().trim() };
23+
}
24+
}
25+
26+
function truncatePreview(value, maxLength) {
27+
const text = typeof value === "string" ? value : String(value);
28+
if (text.length <= maxLength) return text;
29+
return `${text.slice(0, maxLength)} ...truncated (${text.length - maxLength} more chars)`;
30+
}
31+
32+
function conflictValuePreview(value) {
33+
if (value === undefined) return "undefined";
34+
const json = JSON.stringify(value);
35+
return truncatePreview(json === undefined ? String(value) : json, 140);
36+
}
37+
38+
function buildConflictSummary(conflicts) {
39+
const entries = Object.entries(conflicts || {});
40+
if (entries.length === 0) return "";
41+
const lines = [
42+
"Conflict preview only. Apply is blocked until conflicts are resolved.",
43+
`Total conflicts: ${entries.length}`
44+
];
45+
entries.sort((a, b) => a[0].localeCompare(b[0])).forEach(([path, values]) => {
46+
lines.push(`- ${path}`);
47+
lines.push(` source: ${conflictValuePreview(values && Object.prototype.hasOwnProperty.call(values, "a") ? values.a : undefined)}`);
48+
lines.push(` target: ${conflictValuePreview(values && Object.prototype.hasOwnProperty.call(values, "b") ? values.b : undefined)}`);
49+
});
50+
return lines.join("\n");
51+
}
52+
53+
function mergeUiState(conflictCount, fresh, confirmed) {
54+
const hasConflicts = conflictCount > 0;
55+
const confirmDisabled = !(fresh && !confirmed && !hasConflicts);
56+
const applyDisabled = !(fresh && confirmed && !hasConflicts);
57+
return { confirmDisabled, applyDisabled };
58+
}
59+
60+
export function run() {
61+
const failures = [];
62+
const htmlExists = fs.existsSync(htmlPath);
63+
const jsExists = fs.existsSync(jsPath);
64+
const html = htmlExists ? fs.readFileSync(htmlPath, "utf8") : "";
65+
const js = jsExists ? fs.readFileSync(jsPath, "utf8") : "";
66+
const jsSyntax = checkSyntax(jsPath);
67+
const testSyntax = checkSyntax(path.join(repoRoot, "tests", "runtime", "V2MergeConflictSummary.test.mjs"));
68+
69+
if (!htmlExists) failures.push("Missing tools/workspace-v2/index.html.");
70+
if (!jsExists) failures.push("Missing tools/workspace-v2/index.js.");
71+
if (!jsSyntax.ok) failures.push("tools/workspace-v2/index.js failed syntax check.");
72+
if (!testSyntax.ok) failures.push("tests/runtime/V2MergeConflictSummary.test.mjs failed syntax check.");
73+
74+
if (!html.includes("id=\"workspaceV2MergeConflictSummary\"")) failures.push("Missing merge conflict summary node.");
75+
if (!html.includes("id=\"workspaceV2MergeOutput\"")) failures.push("Missing raw merge JSON preview node.");
76+
if (!js.includes("renderMergeConflictSummary()")) failures.push("Missing renderMergeConflictSummary implementation.");
77+
if (!js.includes("Conflict preview only. Apply is blocked until conflicts are resolved.")) failures.push("Missing required conflict inline text.");
78+
if (!js.includes("Total conflicts:")) failures.push("Missing conflict count summary rendering.");
79+
if (!js.includes("source: ${this.conflictValuePreview")) failures.push("Missing source value preview rendering.");
80+
if (!js.includes("target: ${this.conflictValuePreview")) failures.push("Missing target value preview rendering.");
81+
82+
const conflicts = {
83+
"payload.layers[1].tiles[3]": { a: 4, b: 9 },
84+
"payload.palette.primary": { a: "#ff0000", b: "#00ff00" }
85+
};
86+
const summary = buildConflictSummary(conflicts);
87+
if (!summary.includes("Total conflicts: 2")) failures.push("Conflict summary did not include total conflict count.");
88+
if (!summary.includes("- payload.layers[1].tiles[3]")) failures.push("Conflict summary missing path listing.");
89+
if (!summary.includes("source: 4")) failures.push("Conflict summary missing source value preview.");
90+
if (!summary.includes("target: 9")) failures.push("Conflict summary missing target value preview.");
91+
if (!summary.includes("- payload.palette.primary")) failures.push("Conflict summary missing second path.");
92+
93+
const conflictState = mergeUiState(2, true, false);
94+
if (!conflictState.confirmDisabled || !conflictState.applyDisabled) {
95+
failures.push("Confirm/Apply should remain disabled for conflict preview.");
96+
}
97+
98+
const conflictFreeState = mergeUiState(0, true, false);
99+
if (conflictFreeState.confirmDisabled) {
100+
failures.push("Conflict-free fresh preview should enable Confirm.");
101+
}
102+
103+
const noConflictSummary = buildConflictSummary({});
104+
if (noConflictSummary !== "") {
105+
failures.push("Conflict-free preview should not render conflict summary.");
106+
}
107+
108+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
109+
fs.writeFileSync(resultsPath, `${JSON.stringify({
110+
generatedAt: new Date().toISOString(),
111+
failures,
112+
checks: { htmlExists, jsExists, jsSyntax, testSyntax },
113+
sampleSummary: summary,
114+
states: { conflictState, conflictFreeState }
115+
}, null, 2)}\n`, "utf8");
116+
117+
console.log(`v2 merge-conflict-summary results: ${resultsPath}`);
118+
assert.equal(failures.length, 0, `V2 merge-conflict-summary failures: ${failures.join(" | ")}`);
119+
return { failures };
120+
}
121+
122+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
123+
try {
124+
const summary = run();
125+
console.log(JSON.stringify(summary, null, 2));
126+
} catch (error) {
127+
console.error(error);
128+
process.exitCode = 1;
129+
}
130+
}
131+

tools/workspace-v2/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ <h2>Session Merge</h2>
112112
</div>
113113
<p id="workspaceV2MergeEnableState">Select two different sessions to enable Preview Merge.</p>
114114
<p id="workspaceV2MergeEmptyState">Need at least two valid sessions to merge.</p>
115+
<pre id="workspaceV2MergeConflictSummary" hidden>No merge conflicts.</pre>
115116
<pre id="workspaceV2MergeOutput" style="max-height: 18rem; overflow: auto; position: relative;">No merge computed.</pre>
116117
</section>
117118

tools/workspace-v2/index.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class WorkspaceV2SessionProducer {
5151
this.applyMergeButton = document.getElementById("workspaceV2ApplyMergeButton");
5252
this.mergeEnableStateNode = document.getElementById("workspaceV2MergeEnableState");
5353
this.mergeEmptyState = document.getElementById("workspaceV2MergeEmptyState");
54+
this.mergeConflictSummaryNode = document.getElementById("workspaceV2MergeConflictSummary");
5455
this.mergeOutputNode = document.getElementById("workspaceV2MergeOutput");
5556
this.refreshErrorLogsButton = document.getElementById("workspaceV2RefreshErrorLogsButton");
5657
this.clearErrorLogsButton = document.getElementById("workspaceV2ClearErrorLogsButton");
@@ -951,6 +952,45 @@ class WorkspaceV2SessionProducer {
951952
}
952953
this.confirmMergeButton.disabled = !previewReadyForConfirm;
953954
this.applyMergeButton.disabled = !previewReadyForApply;
955+
this.renderMergeConflictSummary();
956+
}
957+
958+
conflictValuePreview(value) {
959+
if (value === undefined) {
960+
return "undefined";
961+
}
962+
const json = JSON.stringify(value);
963+
return this.truncatePreview(json === undefined ? String(value) : json, 140);
964+
}
965+
966+
renderMergeConflictSummary() {
967+
if (!this.mergeConflictSummaryNode) {
968+
return;
969+
}
970+
if (!this.pendingMergePreview || !this.pendingMergePreview.conflicts || typeof this.pendingMergePreview.conflicts !== "object") {
971+
this.mergeConflictSummaryNode.hidden = true;
972+
this.mergeConflictSummaryNode.textContent = "";
973+
return;
974+
}
975+
const conflictEntries = Object.entries(this.pendingMergePreview.conflicts);
976+
if (conflictEntries.length === 0) {
977+
this.mergeConflictSummaryNode.hidden = true;
978+
this.mergeConflictSummaryNode.textContent = "";
979+
return;
980+
}
981+
const lines = [
982+
"Conflict preview only. Apply is blocked until conflicts are resolved.",
983+
`Total conflicts: ${conflictEntries.length}`
984+
];
985+
conflictEntries
986+
.sort((left, right) => left[0].localeCompare(right[0]))
987+
.forEach(([path, values]) => {
988+
lines.push(`- ${path}`);
989+
lines.push(` source: ${this.conflictValuePreview(values && Object.prototype.hasOwnProperty.call(values, "a") ? values.a : undefined)}`);
990+
lines.push(` target: ${this.conflictValuePreview(values && Object.prototype.hasOwnProperty.call(values, "b") ? values.b : undefined)}`);
991+
});
992+
this.mergeConflictSummaryNode.textContent = lines.join("\n");
993+
this.mergeConflictSummaryNode.hidden = false;
954994
}
955995

956996
renderSessionDiffInputs() {
@@ -1063,6 +1103,7 @@ class WorkspaceV2SessionProducer {
10631103
}
10641104
this.statusNode.textContent = "Merge preview cleared because source or target session changed. Run Preview Merge (Dry Run) again.";
10651105
this.mergeOutputNode.textContent = "No merge preview available.";
1106+
this.renderMergeConflictSummary();
10661107
return;
10671108
}
10681109
if (this.mergeCandidates.length < 2) {

0 commit comments

Comments
 (0)