Skip to content

Commit f811366

Browse files
author
DavidQ
committed
Sync saved session row actions with diff and merge selection state - PR 11.248
1 parent 4dae430 commit f811366

3 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# PR_11_248 — Sync Row Actions With Selection State
2+
3+
## Summary
4+
Wired saved-session row actions (`Use in Library`, `Load`) to also sync Session A/B selections for both Diff and Merge selectors, with A-then-B fill behavior, no overwrite when both set, and no same-session duplication.
5+
6+
## Files Changed
7+
- `tools/workspace-v2/index.js`
8+
- `tests/runtime/V2SelectionSyncRowActions.test.mjs`
9+
10+
## Implementation Details
11+
- Added shared selection-sync helpers:
12+
- `syncSelectionSlotsFromContextId(leftSelectNode, rightSelectNode, candidates, contextId)`
13+
- `syncDiffAndMergeSelectionSlotsFromContextId(contextId)`
14+
- Behavior applied to both Diff and Merge selectors:
15+
- fill `Session A` first if empty
16+
- else fill `Session B` if empty
17+
- do not overwrite if both selected
18+
- block same-session duplication across A/B
19+
- Hooked row actions:
20+
- `useSavedSessionIdInLibraryInput(sessionId)` now syncs A/B after textbox fill.
21+
- `loadSavedSessionById(sessionId)` now syncs A/B after load.
22+
- Immediately re-runs state wiring:
23+
- `updateDiffSelectionFeedbackAndState()`
24+
- `updateMergeSelectionFeedbackAndState()`
25+
26+
## Validation Commands Run
27+
```powershell
28+
node --check tools/workspace-v2/index.js
29+
node --check tests/runtime/V2SelectionSyncRowActions.test.mjs
30+
node tests/runtime/V2SelectionSyncRowActions.test.mjs
31+
```
32+
33+
## Validation Results
34+
- `node --check tools/workspace-v2/index.js` -> PASS
35+
- `node --check tests/runtime/V2SelectionSyncRowActions.test.mjs` -> PASS
36+
- `node tests/runtime/V2SelectionSyncRowActions.test.mjs` -> PASS
37+
- output: `tmp/v2-selection-sync-row-actions-results.json`
38+
- failures: `[]`
39+
40+
## Verified Behaviors
41+
- Use in Library fills textbox and updates A/B -> PASS
42+
- Load updates A/B -> PASS
43+
- First click fills A, second fills B -> PASS
44+
- Third click does not overwrite existing A/B -> PASS
45+
- Same session is not duplicated across A/B -> PASS
46+
- Diff/Merge enable-state updates immediately -> PASS
47+
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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 jsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
11+
const resultsPath = path.join(repoRoot, "tmp", "v2-selection-sync-row-actions-results.json");
12+
13+
function readText(filePath) {
14+
return fs.readFileSync(filePath, "utf8");
15+
}
16+
17+
function checkSyntax(filePath) {
18+
try {
19+
execFileSync(process.execPath, ["--check", filePath], {
20+
cwd: repoRoot,
21+
stdio: ["ignore", "pipe", "pipe"]
22+
});
23+
return { ok: true, error: "" };
24+
} catch (error) {
25+
return { ok: false, error: (error?.stderr || error?.stdout || error?.message || "").toString().trim() };
26+
}
27+
}
28+
29+
function findById(candidates, id) {
30+
if (!Array.isArray(candidates)) return null;
31+
return candidates.find((entry) => entry.id === id) || null;
32+
}
33+
34+
function findByContext(candidates, contextId) {
35+
if (!Array.isArray(candidates)) return null;
36+
return candidates.find((entry) => entry.contextId === contextId) || null;
37+
}
38+
39+
function syncSlots(state, contextId) {
40+
const selected = findByContext(state.candidates, contextId);
41+
if (!selected) return false;
42+
const leftEntry = findById(state.candidates, state.left);
43+
const rightEntry = findById(state.candidates, state.right);
44+
if (!leftEntry) {
45+
if (rightEntry && rightEntry.id === selected.id) return false;
46+
state.left = selected.id;
47+
return true;
48+
}
49+
if (!rightEntry) {
50+
if (leftEntry.id === selected.id) return false;
51+
state.right = selected.id;
52+
return true;
53+
}
54+
return false;
55+
}
56+
57+
function computeEnabled(state) {
58+
const leftEntry = findById(state.candidates, state.left);
59+
const rightEntry = findById(state.candidates, state.right);
60+
return Boolean(leftEntry && rightEntry && leftEntry.id !== rightEntry.id);
61+
}
62+
63+
export function run() {
64+
const failures = [];
65+
const jsExists = fs.existsSync(jsPath);
66+
const js = jsExists ? readText(jsPath) : "";
67+
const jsSyntax = checkSyntax(jsPath);
68+
const testSyntax = checkSyntax(path.join(repoRoot, "tests", "runtime", "V2SelectionSyncRowActions.test.mjs"));
69+
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/V2SelectionSyncRowActions.test.mjs failed syntax check.");
73+
74+
const requiredTokens = [
75+
"syncSelectionSlotsFromContextId(leftSelectNode, rightSelectNode, candidates, contextId)",
76+
"syncDiffAndMergeSelectionSlotsFromContextId(contextId)",
77+
"this.syncDiffAndMergeSelectionSlotsFromContextId(sessionId.trim());",
78+
"this.updateDiffSelectionFeedbackAndState();",
79+
"this.updateMergeSelectionFeedbackAndState();"
80+
];
81+
requiredTokens.forEach((token) => {
82+
if (!js.includes(token)) {
83+
failures.push(`Missing required selection-sync token: ${token}`);
84+
}
85+
});
86+
87+
const candidates = [
88+
{ id: "library:s1", contextId: "s1" },
89+
{ id: "library:s2", contextId: "s2" },
90+
{ id: "library:s3", contextId: "s3" }
91+
];
92+
93+
const state = { candidates, left: "", right: "" };
94+
const firstFill = syncSlots(state, "s1");
95+
if (!firstFill || state.left !== "library:s1" || state.right !== "") {
96+
failures.push("First row action should fill Session A when empty.");
97+
}
98+
99+
const secondFill = syncSlots(state, "s2");
100+
if (!secondFill || state.left !== "library:s1" || state.right !== "library:s2") {
101+
failures.push("Second row action should fill Session B when A already selected.");
102+
}
103+
104+
const thirdNoOverwrite = syncSlots(state, "s3");
105+
if (thirdNoOverwrite || state.left !== "library:s1" || state.right !== "library:s2") {
106+
failures.push("Third row action should not overwrite when A/B already selected.");
107+
}
108+
109+
const dupBlocked = syncSlots(state, "s1");
110+
if (dupBlocked || state.left !== "library:s1" || state.right !== "library:s2") {
111+
failures.push("Same session must not be duplicated across A/B.");
112+
}
113+
114+
const enabledAfterTwo = computeEnabled({ candidates, left: "library:s1", right: "library:s2" });
115+
const disabledWithOne = computeEnabled({ candidates, left: "library:s1", right: "" });
116+
if (!enabledAfterTwo) failures.push("Diff/Merge should be enabled after valid A/B selections.");
117+
if (disabledWithOne) failures.push("Diff/Merge should remain disabled with one selection.");
118+
119+
const useInLibraryWiresSync = js.includes("useSavedSessionIdInLibraryInput(sessionId)") && js.includes("this.syncDiffAndMergeSelectionSlotsFromContextId(sessionId.trim());");
120+
const loadWiresSync = js.includes("loadSavedSessionById(sessionId)") && js.includes("this.syncDiffAndMergeSelectionSlotsFromContextId(sessionId.trim());");
121+
if (!useInLibraryWiresSync) failures.push("Use in Library does not wire selection sync.");
122+
if (!loadWiresSync) failures.push("Load row action does not wire selection sync.");
123+
124+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
125+
fs.writeFileSync(resultsPath, `${JSON.stringify({
126+
generatedAt: new Date().toISOString(),
127+
failures,
128+
checks: {
129+
jsExists,
130+
jsSyntax,
131+
testSyntax
132+
},
133+
scenarios: {
134+
state,
135+
firstFill,
136+
secondFill,
137+
thirdNoOverwrite,
138+
dupBlocked,
139+
enabledAfterTwo,
140+
disabledWithOne
141+
}
142+
}, null, 2)}\n`, "utf8");
143+
144+
console.log(`v2 selection-sync row-actions results: ${resultsPath}`);
145+
assert.equal(failures.length, 0, `V2 selection-sync row-actions failures: ${failures.join(" | ")}`);
146+
return { failures };
147+
}
148+
149+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
150+
try {
151+
const summary = run();
152+
console.log(JSON.stringify(summary, null, 2));
153+
} catch (error) {
154+
console.error(error);
155+
process.exitCode = 1;
156+
}
157+
}

tools/workspace-v2/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ class WorkspaceV2SessionProducer {
501501
return;
502502
}
503503
this.sessionNameNode.value = sessionId.trim();
504+
this.syncDiffAndMergeSelectionSlotsFromContextId(sessionId.trim());
504505
this.setLibraryStatus(`Saved session ID ready for Library actions: ${sessionId.trim()}`);
505506
}
506507

@@ -511,6 +512,7 @@ class WorkspaceV2SessionProducer {
511512
}
512513
this.sessionNameNode.value = sessionId.trim();
513514
this.loadNamedSession();
515+
this.syncDiffAndMergeSelectionSlotsFromContextId(sessionId.trim());
514516
this.renderSessionLibrary();
515517
}
516518

@@ -820,6 +822,46 @@ class WorkspaceV2SessionProducer {
820822
return entries.find((entry) => entry.id === selectedId) || null;
821823
}
822824

825+
syncSelectionSlotsFromContextId(leftSelectNode, rightSelectNode, candidates, contextId) {
826+
if (
827+
!leftSelectNode ||
828+
!rightSelectNode ||
829+
!Array.isArray(candidates) ||
830+
typeof contextId !== "string" ||
831+
!contextId.trim()
832+
) {
833+
return false;
834+
}
835+
const selectedEntry = this.findSessionEntryByContextId(candidates, contextId.trim());
836+
if (!selectedEntry) {
837+
return false;
838+
}
839+
const leftEntry = this.findSessionEntryById(candidates, leftSelectNode.value);
840+
const rightEntry = this.findSessionEntryById(candidates, rightSelectNode.value);
841+
if (!leftEntry) {
842+
if (rightEntry && rightEntry.id === selectedEntry.id) {
843+
return false;
844+
}
845+
leftSelectNode.value = selectedEntry.id;
846+
return true;
847+
}
848+
if (!rightEntry) {
849+
if (leftEntry.id === selectedEntry.id) {
850+
return false;
851+
}
852+
rightSelectNode.value = selectedEntry.id;
853+
return true;
854+
}
855+
return false;
856+
}
857+
858+
syncDiffAndMergeSelectionSlotsFromContextId(contextId) {
859+
this.syncSelectionSlotsFromContextId(this.diffLeftSelect, this.diffRightSelect, this.diffCandidates, contextId);
860+
this.syncSelectionSlotsFromContextId(this.mergeLeftSelect, this.mergeRightSelect, this.mergeCandidates, contextId);
861+
this.updateDiffSelectionFeedbackAndState();
862+
this.updateMergeSelectionFeedbackAndState();
863+
}
864+
823865
formatSelectionLabel(entry) {
824866
if (!entry || typeof entry !== "object") {
825867
return "No session selected";

0 commit comments

Comments
 (0)