@cueloop/client 0.1.0-alpha.62 → 0.1.0-alpha.64
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.toast.test.tsx +18 -15
- package/src/App.tsx +14 -3
- package/src/app-view-model.ts +3 -13
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/PlanSheet.tsx +1 -4
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewRail.tsx +3 -1
- package/src/components/plan-sheet-run-style.ts +8 -10
- 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/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/intent-dispatch.test.ts +100 -53
- package/src/intent-dispatch.ts +12 -10
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +5 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +22 -12
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +18 -3
- package/src/session-controller.share.test.ts +28 -18
- package/src/session-controller.ts +49 -27
- package/src/share.ts +3 -1
- package/src/test-support.ts +5 -1
- package/src/theme-presets.ts +9 -2
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +6 -7
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
|
|
|
@@ -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/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
|
}
|
|
@@ -242,15 +243,24 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
242
243
|
|
|
243
244
|
return parts.join(" > ");
|
|
244
245
|
};
|
|
246
|
+
// Prefer the nearest focusable target before falling back to its component container.
|
|
247
|
+
const focusableTarget = (start: Element | null): Element | null => {
|
|
248
|
+
for (
|
|
249
|
+
let element: Element | null = start;
|
|
250
|
+
element && element !== document.body;
|
|
251
|
+
element = element.parentElement
|
|
252
|
+
) {
|
|
253
|
+
if (element instanceof HTMLElement && element.tabIndex >= 0) {
|
|
254
|
+
return element;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
};
|
|
245
260
|
const hit = document.elementFromPoint(x, y);
|
|
246
261
|
|
|
247
262
|
if (!(hit instanceof Element)) return null;
|
|
248
|
-
|
|
249
|
-
// grid is the target, not the grid. Everything else climbs to its component.
|
|
250
|
-
const control = hit.closest(
|
|
251
|
-
"button, a, [role='button'], input, select, textarea, label, summary",
|
|
252
|
-
);
|
|
253
|
-
const node = control ?? componentRoot(hit);
|
|
263
|
+
const node = focusableTarget(hit) ?? componentRoot(hit);
|
|
254
264
|
const selector = selectorFor(node);
|
|
255
265
|
|
|
256
266
|
if (!selector) return null;
|
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 {
|
|
@@ -37,26 +37,41 @@ function diffSession(files?: DiffFileContents[]): ReviewSession {
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
interface WorkingCopySink {
|
|
41
|
+
workingCopy?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const unimplemented = (member: string) => () =>
|
|
45
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
46
|
+
|
|
40
47
|
/** A fake client that records the working copy the controller writes. */
|
|
41
|
-
function fakeClient(session: ReviewSession, sink:
|
|
48
|
+
function fakeClient(session: ReviewSession, sink: WorkingCopySink): SessionClient {
|
|
42
49
|
return {
|
|
43
50
|
onEvent: () => () => {},
|
|
44
51
|
subscribe: async () => {},
|
|
45
52
|
sessionGet: async () => session,
|
|
46
53
|
sessionList: async () => [session],
|
|
54
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
55
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
47
56
|
sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
|
|
48
57
|
sink.workingCopy = content;
|
|
49
58
|
|
|
50
59
|
return { ...session, workingCopy: content };
|
|
51
60
|
}),
|
|
61
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
62
|
+
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
63
|
+
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
64
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
65
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
66
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
52
67
|
close: () => {},
|
|
53
|
-
}
|
|
68
|
+
};
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
57
72
|
|
|
58
73
|
async function connected(session: ReviewSession) {
|
|
59
|
-
const sink:
|
|
74
|
+
const sink: WorkingCopySink = {};
|
|
60
75
|
const client = fakeClient(session, sink);
|
|
61
76
|
const controller = createReviewController({
|
|
62
77
|
sessionId: session.id,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
2
|
import { ManualClock } from "@opentui/core/testing";
|
|
3
3
|
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
4
4
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
|
+
import { createReviewController, SHARE_POLL_MS, type ShareTransport } from "./session-controller";
|
|
5
6
|
|
|
6
|
-
// Stub the ssh transport so no subprocess spawns; the controller under test
|
|
7
|
-
// imports these from "./share".
|
|
8
7
|
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
9
8
|
let remote: ReviewSession;
|
|
10
9
|
const pullShare = mock(async () => remote);
|
|
@@ -12,16 +11,13 @@ const pushShare = mock(
|
|
|
12
11
|
async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
|
|
13
12
|
);
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
publishShare,
|
|
17
|
-
pullShare,
|
|
18
|
-
pushShare,
|
|
19
|
-
|
|
20
|
-
collaboratorAnnotations: (session
|
|
21
|
-
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
|
|
14
|
+
const shareTransport: ShareTransport = {
|
|
15
|
+
publish: publishShare,
|
|
16
|
+
pull: pullShare,
|
|
17
|
+
push: pushShare,
|
|
18
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
19
|
+
collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
|
|
20
|
+
};
|
|
25
21
|
|
|
26
22
|
function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
27
23
|
return {
|
|
@@ -49,13 +45,23 @@ function annotation(id: string, author: string): Annotation {
|
|
|
49
45
|
};
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
|
|
48
|
+
interface FakeSessionClient extends SessionClient {
|
|
49
|
+
sessionAnnotate: Mock<SessionClient["sessionAnnotate"]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const unimplemented = (member: string) => () =>
|
|
53
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
54
|
+
|
|
55
|
+
function fakeClient(session: ReviewSession): FakeSessionClient {
|
|
53
56
|
return {
|
|
54
57
|
onEvent: () => () => {},
|
|
55
58
|
subscribe: async () => {},
|
|
56
59
|
sessionGet: async () => session,
|
|
57
60
|
sessionList: async () => [session],
|
|
58
|
-
sessionAnnotate: mock(async () => session),
|
|
61
|
+
sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
|
|
62
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
63
|
+
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
64
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
59
65
|
sessionSetShareId: mock(
|
|
60
66
|
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
61
67
|
),
|
|
@@ -67,8 +73,11 @@ function fakeClient(session: ReviewSession): SessionClient {
|
|
|
67
73
|
|
|
68
74
|
return session;
|
|
69
75
|
}),
|
|
76
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
77
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
78
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
70
79
|
close: () => {},
|
|
71
|
-
}
|
|
80
|
+
};
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -76,12 +85,13 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
76
85
|
async function connectedController(
|
|
77
86
|
session: ReviewSession,
|
|
78
87
|
clock?: ManualClock,
|
|
79
|
-
): Promise<{ controller: ReturnType<typeof createReviewController>; client:
|
|
88
|
+
): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
|
|
80
89
|
const client = fakeClient(session);
|
|
81
90
|
const controller = createReviewController({
|
|
82
91
|
sessionId: session.id,
|
|
83
92
|
openClient: async () => client,
|
|
84
93
|
clock,
|
|
94
|
+
shareTransport,
|
|
85
95
|
});
|
|
86
96
|
|
|
87
97
|
controller.connect();
|
|
@@ -205,7 +215,7 @@ describe("mirror on annotate", () => {
|
|
|
205
215
|
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
206
216
|
);
|
|
207
217
|
|
|
208
|
-
|
|
218
|
+
client.sessionAnnotate.mockImplementationOnce(async () => {
|
|
209
219
|
throw new Error("session is resolved");
|
|
210
220
|
});
|
|
211
221
|
|
|
@@ -131,6 +131,22 @@ export interface ControllerSnapshot {
|
|
|
131
131
|
walk: { index: number } | null;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export interface ShareTransport {
|
|
135
|
+
publish: typeof publishShare;
|
|
136
|
+
pull: typeof pullShare;
|
|
137
|
+
push: typeof pushShare;
|
|
138
|
+
parseShareId: typeof shareIdFromLine;
|
|
139
|
+
collaboratorAnnotations: typeof collaboratorAnnotations;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
|
|
143
|
+
publish: publishShare,
|
|
144
|
+
pull: pullShare,
|
|
145
|
+
push: pushShare,
|
|
146
|
+
parseShareId: shareIdFromLine,
|
|
147
|
+
collaboratorAnnotations,
|
|
148
|
+
};
|
|
149
|
+
|
|
134
150
|
export interface ReviewControllerOptions {
|
|
135
151
|
home?: string;
|
|
136
152
|
sessionId?: string;
|
|
@@ -145,6 +161,7 @@ export interface ReviewControllerOptions {
|
|
|
145
161
|
* renders a decrypted share instead.
|
|
146
162
|
*/
|
|
147
163
|
openClient?: () => Promise<SessionClient>;
|
|
164
|
+
shareTransport?: ShareTransport;
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
export interface ReviewController {
|
|
@@ -235,6 +252,13 @@ export function createReviewController(options: ReviewControllerOptions): Review
|
|
|
235
252
|
return new Controller(options);
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
interface DerivedSessionProjection {
|
|
256
|
+
display: DisplayBlock[];
|
|
257
|
+
rows: DiffRow[];
|
|
258
|
+
files: WalkFile[];
|
|
259
|
+
models: Map<string, FileDiffMetadata>;
|
|
260
|
+
}
|
|
261
|
+
|
|
238
262
|
class Controller implements ReviewController {
|
|
239
263
|
readonly readOnly: boolean;
|
|
240
264
|
private client: SessionClient | null = null;
|
|
@@ -254,18 +278,13 @@ class Controller implements ReviewController {
|
|
|
254
278
|
private editor: string | undefined;
|
|
255
279
|
private exporters: BundledExporter[] = [];
|
|
256
280
|
private readonly clock: Clock;
|
|
281
|
+
private readonly shareTransport: ShareTransport;
|
|
257
282
|
private countdown: TimerHandle | undefined;
|
|
258
283
|
private sharePoll: TimerHandle | undefined;
|
|
259
284
|
private shareRun: object | null = null;
|
|
260
285
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
261
286
|
private derivedFor: ReviewSession | null = null;
|
|
262
|
-
private derived: {
|
|
263
|
-
display: DisplayBlock[];
|
|
264
|
-
rows: DiffRow[];
|
|
265
|
-
files: WalkFile[];
|
|
266
|
-
/** Per-path parsed diff models, for hunk curation; empty when files absent. */
|
|
267
|
-
models: Map<string, FileDiffMetadata>;
|
|
268
|
-
} = {
|
|
287
|
+
private derived: DerivedSessionProjection = {
|
|
269
288
|
display: [],
|
|
270
289
|
rows: [],
|
|
271
290
|
files: [],
|
|
@@ -277,6 +296,7 @@ class Controller implements ReviewController {
|
|
|
277
296
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
278
297
|
this.readOnly = options.readOnly ?? false;
|
|
279
298
|
this.clock = options.clock ?? new SystemClock();
|
|
299
|
+
this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -402,16 +422,16 @@ class Controller implements ReviewController {
|
|
|
402
422
|
private async refreshSession(id: string): Promise<void> {
|
|
403
423
|
try {
|
|
404
424
|
if (this.client) this.update({ session: await this.client.sessionGet(id) });
|
|
405
|
-
} catch (
|
|
406
|
-
if (!this.closed) this.setStatus(
|
|
425
|
+
} catch (cause) {
|
|
426
|
+
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
407
427
|
}
|
|
408
428
|
}
|
|
409
429
|
|
|
410
430
|
private async refreshInbox(): Promise<void> {
|
|
411
431
|
try {
|
|
412
432
|
if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
|
|
413
|
-
} catch (
|
|
414
|
-
if (!this.closed) this.setStatus(
|
|
433
|
+
} catch (cause) {
|
|
434
|
+
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
415
435
|
}
|
|
416
436
|
}
|
|
417
437
|
|
|
@@ -419,8 +439,8 @@ class Controller implements ReviewController {
|
|
|
419
439
|
private apply(mutation: Promise<ReviewSession>): void {
|
|
420
440
|
mutation
|
|
421
441
|
.then((session) => this.update({ session }))
|
|
422
|
-
.catch((
|
|
423
|
-
this.setStatus(String(
|
|
442
|
+
.catch((cause: unknown) =>
|
|
443
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause)),
|
|
424
444
|
);
|
|
425
445
|
}
|
|
426
446
|
|
|
@@ -438,8 +458,8 @@ class Controller implements ReviewController {
|
|
|
438
458
|
this.client
|
|
439
459
|
?.sessionDelete(id)
|
|
440
460
|
.then(() => this.setStatus("plan deleted"))
|
|
441
|
-
.catch((
|
|
442
|
-
this.setStatus(`delete failed: ${
|
|
461
|
+
.catch((cause: unknown) =>
|
|
462
|
+
this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
443
463
|
);
|
|
444
464
|
}
|
|
445
465
|
|
|
@@ -775,7 +795,7 @@ class Controller implements ReviewController {
|
|
|
775
795
|
const shareId = this.snapshot.session?.shareId;
|
|
776
796
|
|
|
777
797
|
if (!shareId) return;
|
|
778
|
-
void persisted.then(() =>
|
|
798
|
+
void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
|
|
779
799
|
}
|
|
780
800
|
|
|
781
801
|
removeAnnotation(id: string): void {
|
|
@@ -868,12 +888,12 @@ class Controller implements ReviewController {
|
|
|
868
888
|
// a remembers the choice. A configured delay overrides; 0 closes now.
|
|
869
889
|
const delay = this.autoClose;
|
|
870
890
|
|
|
871
|
-
if (delay ===
|
|
872
|
-
else if (
|
|
873
|
-
else this.startCounting(
|
|
891
|
+
if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
892
|
+
else if (delay === 0) this.finishReview();
|
|
893
|
+
else this.startCounting(delay);
|
|
874
894
|
})
|
|
875
|
-
.catch((
|
|
876
|
-
this.setStatus(String(
|
|
895
|
+
.catch((cause: unknown) =>
|
|
896
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause)),
|
|
877
897
|
);
|
|
878
898
|
}
|
|
879
899
|
|
|
@@ -882,17 +902,18 @@ class Controller implements ReviewController {
|
|
|
882
902
|
|
|
883
903
|
if (!session) return;
|
|
884
904
|
this.setStatus("sharing…");
|
|
885
|
-
|
|
905
|
+
this.shareTransport
|
|
906
|
+
.publish(session)
|
|
886
907
|
.then(async ({ line, copied }) => {
|
|
887
908
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
888
|
-
const shareId =
|
|
909
|
+
const shareId = this.shareTransport.parseShareId(line);
|
|
889
910
|
|
|
890
911
|
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
891
912
|
this.setStatus("");
|
|
892
913
|
this.showToast(line, copied ? "share link copied" : "share link");
|
|
893
914
|
})
|
|
894
|
-
.catch((
|
|
895
|
-
this.setStatus(`share failed: ${
|
|
915
|
+
.catch((cause: unknown) =>
|
|
916
|
+
this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
896
917
|
);
|
|
897
918
|
}
|
|
898
919
|
|
|
@@ -908,10 +929,11 @@ class Controller implements ReviewController {
|
|
|
908
929
|
if (!session?.shareId || !this.client) return Promise.resolve();
|
|
909
930
|
const client = this.client;
|
|
910
931
|
|
|
911
|
-
return
|
|
932
|
+
return this.shareTransport
|
|
933
|
+
.pull(session.shareId)
|
|
912
934
|
.then((remote) =>
|
|
913
935
|
client.sessionMergeShared(session.id, {
|
|
914
|
-
annotations: collaboratorAnnotations(remote),
|
|
936
|
+
annotations: this.shareTransport.collaboratorAnnotations(remote),
|
|
915
937
|
participants: remote.participants,
|
|
916
938
|
}),
|
|
917
939
|
)
|
package/src/share.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
packSessionBlob,
|
|
14
14
|
} from "@cueloop/daemon/share-blob";
|
|
15
15
|
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { SessionRecordSchema } from "@cueloop/daemon/validate";
|
|
17
|
+
import * as v from "valibot";
|
|
16
18
|
import { copyToClipboard } from "./clipboard";
|
|
17
19
|
|
|
18
20
|
export interface ShareTarget {
|
|
@@ -63,7 +65,7 @@ export async function pullShare(shareId: string, target: ShareTarget = {}): Prom
|
|
|
63
65
|
|
|
64
66
|
if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
65
67
|
|
|
66
|
-
return JSON.parse(stdout)
|
|
68
|
+
return v.parse(SessionRecordSchema, JSON.parse(stdout));
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** A share's collaborator notes: the ones a viewer authored (author stamped). */
|
package/src/test-support.ts
CHANGED
|
@@ -31,9 +31,13 @@ export function isolateUserConfig(home: string, fileName = "no-config.toml"): ()
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
declare global {
|
|
35
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
/** The harness drives the real event loop by design; keep React's act warning off. */
|
|
35
39
|
export function allowEventLoopUpdates(): void {
|
|
36
|
-
|
|
40
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
package/src/theme-presets.ts
CHANGED
|
@@ -148,7 +148,14 @@ export const THEME_PRESETS: Record<ThemeName, Theme> = {
|
|
|
148
148
|
};
|
|
149
149
|
|
|
150
150
|
/** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
|
|
151
|
-
export const THEME_NAMES: ThemeName[] =
|
|
151
|
+
export const THEME_NAMES: ThemeName[] = [
|
|
152
|
+
"cueloop",
|
|
153
|
+
"rose-pine-moon",
|
|
154
|
+
"catppuccin-mocha",
|
|
155
|
+
"tokyo-night",
|
|
156
|
+
"gruvbox-dark",
|
|
157
|
+
"nord",
|
|
158
|
+
];
|
|
152
159
|
|
|
153
160
|
/** Human labels for the Settings cycle row, keyed by the persisted name. */
|
|
154
161
|
export const THEME_LABELS: Record<ThemeName, string> = {
|
|
@@ -169,7 +176,7 @@ export const THEME_LABELS: Record<ThemeName, string> = {
|
|
|
169
176
|
export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
|
|
170
177
|
if (name === "cueloop") return brandedTheme(appearance);
|
|
171
178
|
|
|
172
|
-
return THEME_PRESETS[name
|
|
179
|
+
return isThemeName(name) ? THEME_PRESETS[name] : brandedTheme(appearance);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
|
package/src/view-diff.ts
CHANGED
|
@@ -73,10 +73,7 @@ function fileLabel(file: FileDiffMetadata): string {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
76
|
-
export function diffRowAnchor(
|
|
77
|
-
rows: DiffRow[],
|
|
78
|
-
rowIndex: number,
|
|
79
|
-
): { quote: string; prefix: string; suffix: string } {
|
|
76
|
+
export function diffRowAnchor(rows: DiffRow[], rowIndex: number) {
|
|
80
77
|
const row = rows[rowIndex]!;
|
|
81
78
|
const prev = rows[rowIndex - 1];
|
|
82
79
|
const next = rows[rowIndex + 1];
|
package/src/view-plan.ts
CHANGED
|
@@ -285,12 +285,14 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
|
|
|
285
285
|
|
|
286
286
|
for (const run of inlineRuns(text)) {
|
|
287
287
|
if (run.role === "marker" || run.start === null) continue;
|
|
288
|
-
|
|
288
|
+
const styleRun: StyleRun = {
|
|
289
289
|
text: run.text,
|
|
290
290
|
role: run.role === "text" ? "plain" : run.role,
|
|
291
291
|
start: base + run.start,
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (run.href !== undefined) styleRun.href = run.href;
|
|
295
|
+
runs.push(styleRun);
|
|
294
296
|
}
|
|
295
297
|
|
|
296
298
|
return runs;
|
|
@@ -437,10 +439,7 @@ export function workRangeForRendered(
|
|
|
437
439
|
}
|
|
438
440
|
|
|
439
441
|
/** Line delta between two revision contents, for the sheet-header summary. */
|
|
440
|
-
export function revisionDelta(
|
|
441
|
-
previousContent: string,
|
|
442
|
-
nextContent: string,
|
|
443
|
-
): { added: number; removed: number } {
|
|
442
|
+
export function revisionDelta(previousContent: string, nextContent: string) {
|
|
444
443
|
const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
|
|
445
444
|
let added = 0;
|
|
446
445
|
let removed = 0;
|