@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +36 -14
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
package/src/keymap.ts
CHANGED
|
@@ -19,12 +19,20 @@ export type Intent =
|
|
|
19
19
|
| { type: "confirmDialog" }
|
|
20
20
|
| { type: "startSpan" }
|
|
21
21
|
| { type: "spanKey"; name: string }
|
|
22
|
-
| { type: "
|
|
22
|
+
| { type: "spanCut" }
|
|
23
|
+
| { type: "openSpanActions" }
|
|
24
|
+
| { type: "moveSpanAction"; direction: -1 | 1 }
|
|
25
|
+
| { type: "pickSpanAction"; index?: number }
|
|
26
|
+
| { type: "closeSpanActions" }
|
|
27
|
+
| { type: "openCompose"; kind: "comment"; from: "cursor" | "span" }
|
|
23
28
|
| { type: "openSubmit" }
|
|
24
29
|
| { type: "share" }
|
|
25
30
|
| { type: "cut" }
|
|
26
31
|
| { type: "edit" }
|
|
27
32
|
| { type: "editCard" }
|
|
33
|
+
| { type: "rejectHunk" }
|
|
34
|
+
| { type: "rejectChange" }
|
|
35
|
+
| { type: "restoreCuration" }
|
|
28
36
|
| { type: "nextAnnotation" }
|
|
29
37
|
| { type: "prevAnnotation" }
|
|
30
38
|
| { type: "walkStart" }
|
|
@@ -64,7 +72,16 @@ export interface KeyState {
|
|
|
64
72
|
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
65
73
|
canShare?: boolean;
|
|
66
74
|
/** Layer that owns keys before the grammar runs. */
|
|
67
|
-
overlay:
|
|
75
|
+
overlay:
|
|
76
|
+
| "none"
|
|
77
|
+
| "walk"
|
|
78
|
+
| "compose"
|
|
79
|
+
| "submit"
|
|
80
|
+
| "confirm"
|
|
81
|
+
| "prompt"
|
|
82
|
+
| "spanActions"
|
|
83
|
+
| "completion-prompt"
|
|
84
|
+
| "completion-counting";
|
|
68
85
|
view: "inbox" | "plan" | "diff";
|
|
69
86
|
/** Plan-only span selection sub-mode. */
|
|
70
87
|
spanMode: boolean;
|
|
@@ -79,9 +96,19 @@ export interface KeyState {
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
82
|
-
const MUTATING_ACTIONS = new Set([
|
|
99
|
+
const MUTATING_ACTIONS = new Set([
|
|
100
|
+
"comment",
|
|
101
|
+
"cut",
|
|
102
|
+
"edit",
|
|
103
|
+
"delete_annotation",
|
|
104
|
+
"submit",
|
|
105
|
+
"walk",
|
|
106
|
+
"share",
|
|
107
|
+
"reject_hunk",
|
|
108
|
+
"restore_curation",
|
|
109
|
+
]);
|
|
83
110
|
|
|
84
|
-
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
111
|
+
const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
|
|
85
112
|
|
|
86
113
|
function status(message: string): Intent[] {
|
|
87
114
|
return [{ type: "status", message }];
|
|
@@ -126,6 +153,15 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
126
153
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
127
154
|
return [];
|
|
128
155
|
}
|
|
156
|
+
// the quick-actions list owns its keys: j/k move, ⏎ picks the highlighted
|
|
157
|
+
// action (inserting its preset comment), escape returns to the span toolbar
|
|
158
|
+
if (state.overlay === "spanActions") {
|
|
159
|
+
if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
|
|
160
|
+
if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
|
|
161
|
+
if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
|
|
162
|
+
if (name === "escape") return [{ type: "closeSpanActions" }];
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
129
165
|
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
130
166
|
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
131
167
|
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
@@ -148,8 +184,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
148
184
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
149
185
|
if (action === "quit") return [{ type: "exit" }];
|
|
150
186
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
151
|
-
// (span-mode c
|
|
152
|
-
const mutating =
|
|
187
|
+
// (span-mode c, x, and a are hardwired keys, so they gate by name as well)
|
|
188
|
+
const mutating =
|
|
189
|
+
MUTATING_ACTIONS.has(action ?? "") ||
|
|
190
|
+
(state.spanMode && (name === "c" || name === "x" || name === "a"));
|
|
153
191
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
154
192
|
|
|
155
193
|
// share is a session-level verb: it works from any view, owner only
|
|
@@ -161,7 +199,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
161
199
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
162
200
|
// span mode owns its single-letter keys (b slides the span back) before the
|
|
163
201
|
// review-panel controls claim them
|
|
164
|
-
if (state.spanMode) return spanGrammar(name);
|
|
202
|
+
if (state.spanMode) return spanGrammar(state, name);
|
|
165
203
|
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
166
204
|
// are view state, so the read-only gate above lets them through
|
|
167
205
|
const reviewPanel = reviewPanelGrammar(action);
|
|
@@ -200,19 +238,40 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
200
238
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
201
239
|
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
202
240
|
}
|
|
241
|
+
// cut rejects the change under the cursor, reject_hunk the whole hunk; both
|
|
242
|
+
// write the working copy, so they gate on owner like a plan edit.
|
|
243
|
+
if (action === "cut" || action === "reject_hunk") {
|
|
244
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
245
|
+
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
246
|
+
return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
|
|
247
|
+
}
|
|
248
|
+
// restore un-does a curated-out rejection from the rail; same owner gate as reject
|
|
249
|
+
if (action === "restore_curation") {
|
|
250
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
251
|
+
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
252
|
+
return [{ type: "restoreCuration" }];
|
|
253
|
+
}
|
|
203
254
|
const shared = annotationCluster(state, action);
|
|
204
255
|
if (shared) return shared;
|
|
205
|
-
if (action === "span" || action === "
|
|
256
|
+
if (action === "span" || action === "edit") {
|
|
206
257
|
return status("plan-only verb - diff review uses c on a line");
|
|
207
258
|
}
|
|
208
259
|
return [];
|
|
209
260
|
}
|
|
210
261
|
|
|
211
|
-
function spanGrammar(name: string): Intent[] {
|
|
262
|
+
function spanGrammar(state: KeyState, name: string): Intent[] {
|
|
212
263
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
213
|
-
if (SPAN_KEYS.
|
|
214
|
-
|
|
215
|
-
|
|
264
|
+
if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
|
|
265
|
+
// c comments, x cuts, a opens quick-actions - all mutate, so a resolved
|
|
266
|
+
// review is read-only, like the plan grammar
|
|
267
|
+
if (name === "c" || name === "x" || name === "a") {
|
|
268
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
269
|
+
if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
|
|
270
|
+
if (name === "a") return [{ type: "openSpanActions" }];
|
|
271
|
+
// partial-span cut is not in the working-copy model, so cut removes the
|
|
272
|
+
// whole block the span sits in; owner-only, like plan cut
|
|
273
|
+
if (state.canEditPlan === false) return [];
|
|
274
|
+
return [{ type: "spanCut" }];
|
|
216
275
|
}
|
|
217
276
|
return [];
|
|
218
277
|
}
|
|
@@ -223,16 +282,23 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
223
282
|
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
224
283
|
if (name === "escape") return [{ type: "deselect" }];
|
|
225
284
|
if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
|
|
226
|
-
if (action === "comment"
|
|
285
|
+
if (action === "comment") {
|
|
227
286
|
if (state.resolved) return status("review submitted - read-only");
|
|
228
287
|
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
229
|
-
return [{ type: "openCompose", kind:
|
|
288
|
+
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
289
|
+
}
|
|
290
|
+
// restore un-does a rail removal (a cut block); a plan edit, so owner-only
|
|
291
|
+
if (action === "restore_curation") {
|
|
292
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
293
|
+
if (state.canEditPlan === false) return [];
|
|
294
|
+
return [{ type: "restoreCuration" }];
|
|
230
295
|
}
|
|
231
296
|
if (action === "cut" || action === "edit") {
|
|
232
297
|
if (state.resolved) return status("review submitted - read-only");
|
|
233
298
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
234
299
|
// the annotation and edit rewrites the card body in place
|
|
235
|
-
if (state.hasFocusedAnnotation)
|
|
300
|
+
if (state.hasFocusedAnnotation)
|
|
301
|
+
return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
236
302
|
// editing the plan itself is the owner's verb; a share viewer only annotates,
|
|
237
303
|
// and has no edit affordance, so the key is silent rather than a nag
|
|
238
304
|
if (state.canEditPlan === false) return [];
|
|
@@ -265,7 +331,8 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
265
331
|
}
|
|
266
332
|
if (action === "submit") {
|
|
267
333
|
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
268
|
-
if (state.canSubmitVerdict === false)
|
|
334
|
+
if (state.canSubmitVerdict === false)
|
|
335
|
+
return status("shared view - your notes save as you go; q to leave");
|
|
269
336
|
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
270
337
|
}
|
|
271
338
|
return null;
|
package/src/review-panel.test.ts
CHANGED
|
@@ -68,7 +68,9 @@ describe("widthFromMouseColumn", () => {
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("the resize step widens and narrows within the clamp", () => {
|
|
71
|
-
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
|
|
71
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
|
|
72
|
+
REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP,
|
|
73
|
+
);
|
|
72
74
|
expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
|
|
73
75
|
});
|
|
74
76
|
});
|
package/src/review-panel.ts
CHANGED
|
@@ -26,7 +26,11 @@ export const REVIEW_COMPACT_WIDTH = 6;
|
|
|
26
26
|
export const REVIEW_RESIZE_STEP = 2;
|
|
27
27
|
|
|
28
28
|
/** Round a desired width to a whole column and clamp it to the bounds. */
|
|
29
|
-
export function clampWidth(
|
|
29
|
+
export function clampWidth(
|
|
30
|
+
desiredWidth: number,
|
|
31
|
+
min = REVIEW_MIN_WIDTH,
|
|
32
|
+
max = REVIEW_MAX_WIDTH,
|
|
33
|
+
): number {
|
|
30
34
|
return Math.max(min, Math.min(max, Math.round(desiredWidth)));
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -36,7 +40,10 @@ export function clampWidth(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = R
|
|
|
36
40
|
* (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
|
|
37
41
|
*/
|
|
38
42
|
export function maxWidthForTerminal(terminalWidth: number): number {
|
|
39
|
-
return Math.max(
|
|
43
|
+
return Math.max(
|
|
44
|
+
REVIEW_MIN_WIDTH,
|
|
45
|
+
Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1),
|
|
46
|
+
);
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
|
package/src/serve.test.ts
CHANGED
|
@@ -38,7 +38,11 @@ afterEach(async () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
-
function sshCapture(
|
|
41
|
+
function sshCapture(
|
|
42
|
+
port: number,
|
|
43
|
+
until: (frame: string) => boolean,
|
|
44
|
+
timeoutMs = 8000,
|
|
45
|
+
): Promise<string> {
|
|
42
46
|
return new Promise((resolve, reject) => {
|
|
43
47
|
const conn = new Client();
|
|
44
48
|
let out = "";
|
|
@@ -48,7 +52,8 @@ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs =
|
|
|
48
52
|
done = true;
|
|
49
53
|
clearTimeout(timer);
|
|
50
54
|
conn.end();
|
|
51
|
-
|
|
55
|
+
if (err) reject(err);
|
|
56
|
+
else resolve(out);
|
|
52
57
|
};
|
|
53
58
|
const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
|
|
54
59
|
conn
|
|
@@ -80,7 +85,10 @@ describe("cueloop serve", () => {
|
|
|
80
85
|
|
|
81
86
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
82
87
|
// Act
|
|
83
|
-
const bytes = await sshCapture(
|
|
88
|
+
const bytes = await sshCapture(
|
|
89
|
+
handle.port,
|
|
90
|
+
(frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
|
|
91
|
+
);
|
|
84
92
|
|
|
85
93
|
// Assert
|
|
86
94
|
expect(bytes).toContain("cueloop");
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
+
import { createReviewController } from "./session-controller";
|
|
5
|
+
|
|
6
|
+
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
7
|
+
index 111..222 100644
|
|
8
|
+
--- a/src/store.ts
|
|
9
|
+
+++ b/src/store.ts
|
|
10
|
+
@@ -1,4 +1,4 @@
|
|
11
|
+
export class Store {
|
|
12
|
+
- private items = [];
|
|
13
|
+
+ private items = new Map();
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const FILES: DiffFileContents[] = [
|
|
18
|
+
{
|
|
19
|
+
path: "src/store.ts",
|
|
20
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
21
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
22
|
+
status: "modified",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function diffSession(files?: DiffFileContents[]): ReviewSession {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: SCHEMA_VERSION,
|
|
29
|
+
id: "ses_diff",
|
|
30
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
+
artifact: { type: "diff", content: PATCH, meta: {}, files },
|
|
32
|
+
revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
33
|
+
annotations: [],
|
|
34
|
+
verdict: null,
|
|
35
|
+
status: "pending",
|
|
36
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fake client that records the working copy the controller writes. */
|
|
41
|
+
function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
|
|
42
|
+
return {
|
|
43
|
+
onEvent: () => () => {},
|
|
44
|
+
subscribe: async () => {},
|
|
45
|
+
sessionGet: async () => session,
|
|
46
|
+
sessionList: async () => [session],
|
|
47
|
+
sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
|
|
48
|
+
sink.workingCopy = content;
|
|
49
|
+
return { ...session, workingCopy: content };
|
|
50
|
+
}),
|
|
51
|
+
close: () => {},
|
|
52
|
+
} as unknown as SessionClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
|
+
|
|
57
|
+
async function connected(session: ReviewSession) {
|
|
58
|
+
const sink: { workingCopy?: string } = {};
|
|
59
|
+
const client = fakeClient(session, sink);
|
|
60
|
+
const controller = createReviewController({
|
|
61
|
+
sessionId: session.id,
|
|
62
|
+
openClient: async () => client,
|
|
63
|
+
});
|
|
64
|
+
controller.connect();
|
|
65
|
+
await tick();
|
|
66
|
+
return { controller, client, sink };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
|
|
70
|
+
describe("diff hunk curation", () => {
|
|
71
|
+
test("rejecting the only change writes an empty curated working copy", async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
74
|
+
|
|
75
|
+
// Act - reject the change on the added line
|
|
76
|
+
controller.toggleRejectChange(4);
|
|
77
|
+
await tick();
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(controller.getSnapshot().status).toContain("change rejected");
|
|
81
|
+
expect(sink.workingCopy).toBe("");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("both the deletion and addition rows of the change dim", async () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
const { controller } = await connected(diffSession(FILES));
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
controller.toggleRejectChange(4);
|
|
90
|
+
await tick();
|
|
91
|
+
|
|
92
|
+
// Assert - the change spans the del row and the add row
|
|
93
|
+
expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("toggling the same change twice restores the full diff", async () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
99
|
+
|
|
100
|
+
// Act - reject then restore
|
|
101
|
+
controller.toggleRejectChange(4);
|
|
102
|
+
await tick();
|
|
103
|
+
controller.toggleRejectChange(4);
|
|
104
|
+
await tick();
|
|
105
|
+
|
|
106
|
+
// Assert - no decisions left, so the working copy reverts to undefined
|
|
107
|
+
expect(controller.getSnapshot().status).toContain("change restored");
|
|
108
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
109
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("whole-hunk reject drops the hunk from the working copy", async () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
115
|
+
|
|
116
|
+
// Act - reject the whole hunk from any of its rows
|
|
117
|
+
controller.toggleRejectHunk(3);
|
|
118
|
+
await tick();
|
|
119
|
+
|
|
120
|
+
// Assert - the single hunk is the whole diff, so nothing remains
|
|
121
|
+
expect(controller.getSnapshot().status).toContain("hunk rejected");
|
|
122
|
+
expect(sink.workingCopy).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a rejected change becomes a removal card with a source, id, and preview", async () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const { controller } = await connected(diffSession(FILES));
|
|
128
|
+
|
|
129
|
+
// Act
|
|
130
|
+
controller.toggleRejectChange(4);
|
|
131
|
+
await tick();
|
|
132
|
+
|
|
133
|
+
// Assert - one item: diff source, revealing the change's row, both lines previewed
|
|
134
|
+
const items = controller.curationItems();
|
|
135
|
+
expect(items.length).toBe(1);
|
|
136
|
+
expect(items[0]!.source).toBe("diff");
|
|
137
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
|
|
138
|
+
expect(items[0]!.revealIndex).toBe(3);
|
|
139
|
+
expect(items[0]!.preview).toEqual([
|
|
140
|
+
"- private items = [];",
|
|
141
|
+
"+ private items = new Map();",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const { controller } = await connected(diffSession(FILES));
|
|
148
|
+
|
|
149
|
+
// Act
|
|
150
|
+
controller.toggleRejectHunk(3);
|
|
151
|
+
await tick();
|
|
152
|
+
|
|
153
|
+
// Assert - the whole hunk, id diff:path#hunk#hunk
|
|
154
|
+
const items = controller.curationItems();
|
|
155
|
+
expect(items.length).toBe(1);
|
|
156
|
+
expect(items[0]!.source).toBe("diff");
|
|
157
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("restoreCuration drops the diff removal and reverts the working copy", async () => {
|
|
161
|
+
// Arrange - one change rejected
|
|
162
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
163
|
+
controller.toggleRejectChange(4);
|
|
164
|
+
await tick();
|
|
165
|
+
const [item] = controller.curationItems();
|
|
166
|
+
|
|
167
|
+
// Act
|
|
168
|
+
controller.restoreCuration(item!.id);
|
|
169
|
+
await tick();
|
|
170
|
+
|
|
171
|
+
// Assert - the list empties and the working copy reverts to the full diff
|
|
172
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
173
|
+
expect(controller.curationItems().length).toBe(0);
|
|
174
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
175
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("restoreCuration ignores an unknown id", async () => {
|
|
179
|
+
// Arrange - one change rejected
|
|
180
|
+
const { controller } = await connected(diffSession(FILES));
|
|
181
|
+
controller.toggleRejectChange(4);
|
|
182
|
+
await tick();
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
controller.restoreCuration("diff:nope#0#0");
|
|
186
|
+
await tick();
|
|
187
|
+
|
|
188
|
+
// Assert - the item stays
|
|
189
|
+
expect(controller.curationItems().length).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("curation is disabled without full file contents", async () => {
|
|
193
|
+
// Arrange - a legacy diff with no artifact.files
|
|
194
|
+
const { controller, sink } = await connected(diffSession(undefined));
|
|
195
|
+
|
|
196
|
+
// Act
|
|
197
|
+
controller.toggleRejectChange(4);
|
|
198
|
+
await tick();
|
|
199
|
+
|
|
200
|
+
// Assert
|
|
201
|
+
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
202
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
|
|
207
|
+
// the working copy with the "Second paragraph." block cut out
|
|
208
|
+
const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
|
|
209
|
+
|
|
210
|
+
function planSession(workingCopy?: string): ReviewSession {
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: SCHEMA_VERSION,
|
|
213
|
+
id: "ses_plan",
|
|
214
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
215
|
+
artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
|
|
216
|
+
revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
217
|
+
annotations: [],
|
|
218
|
+
verdict: null,
|
|
219
|
+
status: "pending",
|
|
220
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
221
|
+
workingCopy,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("plan cut removals", () => {
|
|
226
|
+
test("a cut block becomes a plan removal card previewing its content", async () => {
|
|
227
|
+
// Arrange - a plan whose working copy dropped the second paragraph
|
|
228
|
+
const { controller } = await connected(planSession(PLAN_CUT));
|
|
229
|
+
|
|
230
|
+
// Act
|
|
231
|
+
const items = controller.curationItems();
|
|
232
|
+
|
|
233
|
+
// Assert - one plan-source removal, keyed on the base line range, content previewed
|
|
234
|
+
expect(items.length).toBe(1);
|
|
235
|
+
expect(items[0]!.source).toBe("plan");
|
|
236
|
+
expect(items[0]!.id).toBe("plan:4-4");
|
|
237
|
+
expect(items[0]!.preview).toEqual(["Second paragraph."]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
|
|
241
|
+
// Arrange
|
|
242
|
+
const { controller, sink } = await connected(planSession(PLAN_CUT));
|
|
243
|
+
const [item] = controller.curationItems();
|
|
244
|
+
|
|
245
|
+
// Act
|
|
246
|
+
controller.restoreCuration(item!.id);
|
|
247
|
+
await tick();
|
|
248
|
+
|
|
249
|
+
// Assert - restoring the only cut round-trips to the submitted revision
|
|
250
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
251
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|