@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
package/src/key-bindings.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { Keymap, type KeymapEvent, type KeymapHost } from "@opentui/keymap";
|
|
12
12
|
import { registerDefaultKeys } from "@opentui/keymap/addons";
|
|
13
|
+
import * as v from "valibot";
|
|
13
14
|
import type { KeyState } from "./keymap";
|
|
14
15
|
|
|
15
16
|
export interface ResolvableKey {
|
|
@@ -111,7 +112,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
111
112
|
};
|
|
112
113
|
|
|
113
114
|
/** Status-line glyphs for named keys. */
|
|
114
|
-
|
|
115
|
+
interface KeyGlyphs {
|
|
116
|
+
[key: string]: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const KEY_GLYPHS: KeyGlyphs = {
|
|
115
120
|
return: "enter",
|
|
116
121
|
enter: "enter",
|
|
117
122
|
escape: "esc",
|
|
@@ -186,7 +191,7 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
|
|
|
186
191
|
* keymap's canonical form is lowercase + the shift modifier. Non-letter
|
|
187
192
|
* single characters ("$", "0") already encode their shift in the character.
|
|
188
193
|
*/
|
|
189
|
-
function normalizedEvent(key: ResolvableKey)
|
|
194
|
+
function normalizedEvent(key: ResolvableKey) {
|
|
190
195
|
if (key.name.length === 1) {
|
|
191
196
|
if (/[a-zA-Z]/.test(key.name)) {
|
|
192
197
|
return {
|
|
@@ -220,6 +225,10 @@ const SPAN_COMMANDS: [key: string, command: string][] = [
|
|
|
220
225
|
["escape", "span_cancel"],
|
|
221
226
|
];
|
|
222
227
|
|
|
228
|
+
function isConditionMatcher(value: unknown): value is () => boolean {
|
|
229
|
+
return typeof value === "function";
|
|
230
|
+
}
|
|
231
|
+
|
|
223
232
|
export class KeyBindings {
|
|
224
233
|
private readonly host = new HeadlessKeymapHost();
|
|
225
234
|
private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
|
|
@@ -234,7 +243,7 @@ export class KeyBindings {
|
|
|
234
243
|
// and dispatch only see the layer that owns the current mode
|
|
235
244
|
this.keymap.registerLayerFields({
|
|
236
245
|
when: (value, fieldContext) => {
|
|
237
|
-
if (
|
|
246
|
+
if (isConditionMatcher(value)) fieldContext.activeWhen(value);
|
|
238
247
|
},
|
|
239
248
|
});
|
|
240
249
|
this.registerModeLayers();
|
|
@@ -373,10 +382,10 @@ export class KeyBindings {
|
|
|
373
382
|
/** First active key display for a command, mapped through the glyph table. */
|
|
374
383
|
private keyDisplayFor(command: string): string | null {
|
|
375
384
|
for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
385
|
+
const parsedCommand = v.safeParse(v.string(), activeKey.command);
|
|
386
|
+
const bindingCommand = parsedCommand.success
|
|
387
|
+
? parsedCommand.output
|
|
388
|
+
: activeKey.bindings?.[0]?.command;
|
|
380
389
|
|
|
381
390
|
if (bindingCommand !== command) continue;
|
|
382
391
|
|
package/src/keymap.test.ts
CHANGED
|
@@ -21,11 +21,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function key(
|
|
25
|
-
name: string,
|
|
26
|
-
shift = false,
|
|
27
|
-
meta = false,
|
|
28
|
-
): { name: string; shift: boolean; meta: boolean } {
|
|
24
|
+
function key(name: string, shift = false, meta = false) {
|
|
29
25
|
return { name, shift, meta };
|
|
30
26
|
}
|
|
31
27
|
|
|
@@ -228,7 +224,7 @@ describe("plan normal mode", () => {
|
|
|
228
224
|
});
|
|
229
225
|
}
|
|
230
226
|
|
|
231
|
-
test("resolved sessions guard the mutating
|
|
227
|
+
test("resolved sessions guard the mutating primitives", () => {
|
|
232
228
|
// Arrange
|
|
233
229
|
const resolvedState = state({ resolved: true });
|
|
234
230
|
|
|
@@ -390,9 +386,9 @@ describe("diff mode", () => {
|
|
|
390
386
|
["p", [{ type: "prevAnnotation" }]],
|
|
391
387
|
["backspace", [{ type: "removeAnnotation" }]],
|
|
392
388
|
["return", [{ type: "openSubmit" }]],
|
|
393
|
-
["v", [{ type: "status", message: "plan-only
|
|
389
|
+
["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
394
390
|
["x", [{ type: "rejectChange" }]],
|
|
395
|
-
["e", [{ type: "status", message: "plan-only
|
|
391
|
+
["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
396
392
|
["q", [{ type: "exit" }]],
|
|
397
393
|
];
|
|
398
394
|
|
|
@@ -518,7 +514,7 @@ describe("review panel controls", () => {
|
|
|
518
514
|
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
519
515
|
});
|
|
520
516
|
|
|
521
|
-
test("span mode still owns b as a span
|
|
517
|
+
test("span mode still owns b as a span primitive, not a panel cycle", () => {
|
|
522
518
|
// Assert
|
|
523
519
|
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
|
|
524
520
|
{ type: "spanKey", name: "b" },
|
|
@@ -537,11 +533,13 @@ describe("review panel controls", () => {
|
|
|
537
533
|
|
|
538
534
|
describe("duplicated-branch collapse", () => {
|
|
539
535
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
540
|
-
|
|
536
|
+
const patches: Partial<KeyState>[] = [
|
|
541
537
|
{},
|
|
542
538
|
{ annotationCount: 0, hasFocusedAnnotation: false },
|
|
543
539
|
{ resolved: true },
|
|
544
|
-
]
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
for (const patch of patches) {
|
|
545
543
|
for (const name of ["n", "p", "backspace", "return"]) {
|
|
546
544
|
const plan = reduceKey(state(patch), key(name));
|
|
547
545
|
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
package/src/keymap.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The keyboard grammar as a pure reducer: view state in, intents out.
|
|
3
3
|
* App builds a KeyState per key event, calls reduceKey, and dispatches the
|
|
4
|
-
* intents to controller
|
|
4
|
+
* intents to controller primitives and view-state setters - no branch bodies live
|
|
5
5
|
* in the key handler. Diff and plan reviews share one path for annotation
|
|
6
6
|
* navigation, deletion, and submit; the read-only rule is one gate here.
|
|
7
7
|
*/
|
|
@@ -49,7 +49,14 @@ export type Intent =
|
|
|
49
49
|
| { type: "optInAutoClose" }
|
|
50
50
|
| { type: "dismissCompletion" }
|
|
51
51
|
| { type: "cycleReviewPanel" }
|
|
52
|
-
| { type: "resizeReviewPanel"; direction: -1 | 1 }
|
|
52
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 }
|
|
53
|
+
| { type: "toggleTree" }
|
|
54
|
+
| { type: "treeMove"; direction: -1 | 1 }
|
|
55
|
+
| { type: "treeGo" }
|
|
56
|
+
| { type: "treeBranch" }
|
|
57
|
+
| { type: "treeLabel" }
|
|
58
|
+
| { type: "treeFork" }
|
|
59
|
+
| { type: "treeForkShare" };
|
|
53
60
|
|
|
54
61
|
export interface KeyInput {
|
|
55
62
|
name: string;
|
|
@@ -63,7 +70,7 @@ export interface KeyState {
|
|
|
63
70
|
keys: Record<string, string[]>;
|
|
64
71
|
readOnly: boolean;
|
|
65
72
|
/**
|
|
66
|
-
* Owner-only
|
|
73
|
+
* Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
|
|
67
74
|
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
68
75
|
* the gateway) or submit an agent verdict (there is no agent on a share).
|
|
69
76
|
*/
|
|
@@ -95,7 +102,7 @@ export interface KeyState {
|
|
|
95
102
|
cursorAnnotatable: boolean;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
/**
|
|
105
|
+
/** Primitives that write session state; an observer never reaches their handlers. */
|
|
99
106
|
const MUTATING_ACTIONS = new Set([
|
|
100
107
|
"comment",
|
|
101
108
|
"cut",
|
|
@@ -136,7 +143,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
136
143
|
|
|
137
144
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
138
145
|
|
|
139
|
-
// share is a session-level
|
|
146
|
+
// share is a session-level primitive: it works from any view, owner only
|
|
140
147
|
if (action === "share") {
|
|
141
148
|
if (state.canShare === false) return status("only the plan owner can share");
|
|
142
149
|
|
|
@@ -305,7 +312,7 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
305
312
|
|
|
306
313
|
if (shared) return shared;
|
|
307
314
|
if (action === "span" || action === "edit") {
|
|
308
|
-
return status("plan-only
|
|
315
|
+
return status("plan-only primitive - diff review uses c on a line");
|
|
309
316
|
}
|
|
310
317
|
|
|
311
318
|
return [];
|
|
@@ -356,7 +363,7 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
356
363
|
// the annotation and edit rewrites the card body in place
|
|
357
364
|
if (state.hasFocusedAnnotation)
|
|
358
365
|
return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
359
|
-
// editing the plan itself is the owner's
|
|
366
|
+
// editing the plan itself is the owner's primitive; a share viewer only annotates,
|
|
360
367
|
// and has no edit affordance, so the key is silent rather than a nag
|
|
361
368
|
if (state.canEditPlan === false) return [];
|
|
362
369
|
|
package/src/kitty-image.test.ts
CHANGED
|
@@ -54,13 +54,13 @@ describe("kitty image placement", () => {
|
|
|
54
54
|
describe("resolveTransmitMedium", () => {
|
|
55
55
|
test("defaults to base64; file transfer is opt-in and never over ssh", () => {
|
|
56
56
|
// Assert
|
|
57
|
-
expect(resolveTransmitMedium({}
|
|
58
|
-
expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" }
|
|
57
|
+
expect(resolveTransmitMedium({})).toBe("base64");
|
|
58
|
+
expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" })).toBe("file");
|
|
59
59
|
expect(
|
|
60
60
|
resolveTransmitMedium({
|
|
61
61
|
CUELOOP_KITTY_FILE: "1",
|
|
62
62
|
SSH_CONNECTION: "1.2.3.4 5 6 7",
|
|
63
|
-
}
|
|
63
|
+
}),
|
|
64
64
|
).toBe("base64");
|
|
65
65
|
});
|
|
66
66
|
});
|
package/src/prototype-browser.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function cssBoxToCell(
|
|
|
73
73
|
box: ElementBox,
|
|
74
74
|
image: { x: number; y: number; width: number; height: number },
|
|
75
75
|
viewport: PrototypeViewport,
|
|
76
|
-
)
|
|
76
|
+
) {
|
|
77
77
|
const scaleColumn = image.width / viewport.width;
|
|
78
78
|
const scaleRow = image.height / viewport.height;
|
|
79
79
|
|
|
@@ -174,13 +174,13 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
174
174
|
omitBackground: true,
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
return new Uint8Array(buffer
|
|
177
|
+
return new Uint8Array(buffer);
|
|
178
178
|
},
|
|
179
179
|
async elementAt(cssX, cssY) {
|
|
180
|
-
return
|
|
180
|
+
return page.evaluate(elementAtScript, cssX, cssY);
|
|
181
181
|
},
|
|
182
182
|
async scrollBy(deltaY) {
|
|
183
|
-
return
|
|
183
|
+
return page.evaluate(scrollByScript, deltaY);
|
|
184
184
|
},
|
|
185
185
|
async close() {
|
|
186
186
|
await page.close().catch(() => undefined);
|
|
@@ -189,7 +189,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
|
|
192
|
-
function elementAtScript(x: number, y: number)
|
|
192
|
+
function elementAtScript(x: number, y: number) {
|
|
193
193
|
const SEMANTIC = new Set([
|
|
194
194
|
"SECTION",
|
|
195
195
|
"ARTICLE",
|
|
@@ -232,7 +232,8 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
232
232
|
const parent: Element | null = current.parentElement;
|
|
233
233
|
|
|
234
234
|
if (parent) {
|
|
235
|
-
const
|
|
235
|
+
const currentTagName = current.tagName;
|
|
236
|
+
const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
|
|
236
237
|
|
|
237
238
|
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
238
239
|
}
|
package/src/serve.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface ServeOptions {
|
|
|
36
36
|
home?: string;
|
|
37
37
|
/** Silence the @opentui/ssh startup banner (tests). Default true. */
|
|
38
38
|
banner?: boolean;
|
|
39
|
-
onError?: (
|
|
39
|
+
onError?: (cause: unknown) => void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface ServeHandle {
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
cutBlock,
|
|
4
|
+
parseBlocks,
|
|
5
|
+
restoreBlock,
|
|
6
|
+
SCHEMA_VERSION,
|
|
7
|
+
type DiffFileContents,
|
|
8
|
+
type HunkRejection,
|
|
9
|
+
type ReviewSession,
|
|
10
|
+
} from "@cueloop/schema";
|
|
11
|
+
import { curateDiff } from "@cueloop/daemon/curate";
|
|
3
12
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
13
|
import { createReviewController } from "./session-controller";
|
|
5
14
|
|
|
@@ -37,26 +46,87 @@ function diffSession(files?: DiffFileContents[]): ReviewSession {
|
|
|
37
46
|
};
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
interface WorkingCopySink {
|
|
50
|
+
workingCopy?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const unimplemented = (member: string) => () =>
|
|
54
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
55
|
+
|
|
40
56
|
/** A fake client that records the working copy the controller writes. */
|
|
41
|
-
function fakeClient(
|
|
57
|
+
function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClient {
|
|
58
|
+
let session = initial;
|
|
59
|
+
|
|
42
60
|
return {
|
|
43
61
|
onEvent: () => () => {},
|
|
44
62
|
subscribe: async () => {},
|
|
45
63
|
sessionGet: async () => session,
|
|
46
64
|
sessionList: async () => [session],
|
|
65
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
66
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
47
67
|
sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
|
|
48
68
|
sink.workingCopy = content;
|
|
49
69
|
|
|
50
70
|
return { ...session, workingCopy: content };
|
|
51
71
|
}),
|
|
72
|
+
// the daemon's block primitives, stood in for with the same pure helpers it uses
|
|
73
|
+
sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
|
|
74
|
+
const working = session.workingCopy ?? session.artifact.content;
|
|
75
|
+
const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
|
|
76
|
+
|
|
77
|
+
sink.workingCopy = content;
|
|
78
|
+
session = { ...session, workingCopy: content };
|
|
79
|
+
|
|
80
|
+
return session;
|
|
81
|
+
}),
|
|
82
|
+
sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
|
|
83
|
+
const base = session.artifact.content;
|
|
84
|
+
const working = session.workingCopy ?? base;
|
|
85
|
+
const content = restoreBlock(
|
|
86
|
+
base,
|
|
87
|
+
working,
|
|
88
|
+
parseBlocks(base)[baseBlockIndex]!,
|
|
89
|
+
line ?? working.split("\n").length,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
sink.workingCopy = content;
|
|
93
|
+
session = { ...session, workingCopy: content };
|
|
94
|
+
if (content === undefined) delete session.workingCopy;
|
|
95
|
+
|
|
96
|
+
return session;
|
|
97
|
+
}),
|
|
98
|
+
// the daemon's curation, stood in for: the record carries the decisions and the patch they leave
|
|
99
|
+
sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
|
|
100
|
+
const content = rejections.length
|
|
101
|
+
? curateDiff(session.artifact.files!, rejections)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
sink.workingCopy = content;
|
|
105
|
+
session = { ...session, workingCopy: content };
|
|
106
|
+
if (rejections.length) session.curation = rejections;
|
|
107
|
+
else delete session.curation;
|
|
108
|
+
|
|
109
|
+
return session;
|
|
110
|
+
}),
|
|
111
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
112
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
113
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
114
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
115
|
+
sessionFork: unimplemented("sessionFork"),
|
|
116
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
117
|
+
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
118
|
+
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
119
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
120
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
121
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
52
122
|
close: () => {},
|
|
53
|
-
}
|
|
123
|
+
};
|
|
54
124
|
}
|
|
55
125
|
|
|
56
126
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
57
127
|
|
|
58
128
|
async function connected(session: ReviewSession) {
|
|
59
|
-
const sink:
|
|
129
|
+
const sink: WorkingCopySink = {};
|
|
60
130
|
const client = fakeClient(session, sink);
|
|
61
131
|
const controller = createReviewController({
|
|
62
132
|
sessionId: session.id,
|
|
@@ -196,6 +266,23 @@ describe("diff hunk curation", () => {
|
|
|
196
266
|
expect(controller.curationItems().length).toBe(1);
|
|
197
267
|
});
|
|
198
268
|
|
|
269
|
+
test("a second decision before the daemon answers builds on the first", async () => {
|
|
270
|
+
// Arrange
|
|
271
|
+
const { controller, client } = await connected(diffSession(FILES));
|
|
272
|
+
|
|
273
|
+
// Act - reject, then restore, with no answer in between
|
|
274
|
+
controller.toggleRejectChange(4);
|
|
275
|
+
controller.toggleRejectChange(4);
|
|
276
|
+
await tick();
|
|
277
|
+
|
|
278
|
+
// Assert - the second request restores; a stale snapshot would reject again
|
|
279
|
+
expect(client.sessionCurate).toHaveBeenCalledTimes(2);
|
|
280
|
+
expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
|
|
281
|
+
{ path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
|
|
282
|
+
]);
|
|
283
|
+
expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
|
|
284
|
+
});
|
|
285
|
+
|
|
199
286
|
test("curation is disabled without full file contents", async () => {
|
|
200
287
|
// Arrange - a legacy diff with no artifact.files
|
|
201
288
|
const { controller, sink } = await connected(diffSession(undefined));
|
|
@@ -257,4 +344,19 @@ describe("plan cut removals", () => {
|
|
|
257
344
|
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
258
345
|
expect(sink.workingCopy).toBeUndefined();
|
|
259
346
|
});
|
|
347
|
+
|
|
348
|
+
test("a second cut before the daemon answers indexes the already-cut copy", async () => {
|
|
349
|
+
// Arrange - pristine plan: title(0), first(1), second(2)
|
|
350
|
+
const { controller, client, sink } = await connected(planSession());
|
|
351
|
+
|
|
352
|
+
// Act - cut the first paragraph, then the second, with no answer in between
|
|
353
|
+
controller.cut(1);
|
|
354
|
+
controller.cut(2);
|
|
355
|
+
await tick();
|
|
356
|
+
|
|
357
|
+
// Assert - the second paragraph is block 1 once the first is gone
|
|
358
|
+
expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
|
|
359
|
+
expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
|
|
360
|
+
expect(sink.workingCopy).toBe("# Title\n");
|
|
361
|
+
});
|
|
260
362
|
});
|