@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.
Files changed (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
package/src/share.ts CHANGED
@@ -12,7 +12,15 @@ import {
12
12
  SHARE_UPLOAD_USER,
13
13
  packSessionBlob,
14
14
  } from "@cueloop/daemon/share-blob";
15
- import type { Annotation, ReviewSession } from "@cueloop/schema";
15
+ import {
16
+ removalEntries,
17
+ viewFollowing,
18
+ type Annotation,
19
+ type ReviewSession,
20
+ } from "@cueloop/schema";
21
+ import { SessionRecordSchema } from "@cueloop/daemon/validate";
22
+ import type { SharedMerge } from "@cueloop/daemon/client";
23
+ import * as v from "valibot";
16
24
  import { copyToClipboard } from "./clipboard";
17
25
 
18
26
  export interface ShareTarget {
@@ -39,7 +47,8 @@ export async function publishShare(
39
47
  ): Promise<ShareResult> {
40
48
  const { stdout, stderr, code } = await runShareSsh(
41
49
  "cueloop-share",
42
- packSessionBlob(session),
50
+ // the share follows one branch: collaborators see its path wherever the owner stands
51
+ packSessionBlob(viewFollowing(session)),
43
52
  target,
44
53
  );
45
54
 
@@ -63,7 +72,7 @@ export async function pullShare(shareId: string, target: ShareTarget = {}): Prom
63
72
 
64
73
  if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
65
74
 
66
- return JSON.parse(stdout) as ReviewSession;
75
+ return v.parse(SessionRecordSchema, JSON.parse(stdout));
67
76
  }
68
77
 
69
78
  /** A share's collaborator notes: the ones a viewer authored (author stamped). */
@@ -71,6 +80,25 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
71
80
  return session.annotations.filter((annotation) => annotation.author);
72
81
  }
73
82
 
83
+ /**
84
+ * What a pulled share hands the local merge: collaborators' notes, the
85
+ * participant registry, and the removals the share recorded, by entry id.
86
+ */
87
+ export function mergeFromShare(remote: ReviewSession): SharedMerge {
88
+ const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
89
+
90
+ if (remote.participants) merge.participants = remote.participants;
91
+ if (remote.history) {
92
+ merge.removals = removalEntries(remote.history).map((entry) => ({
93
+ id: entry.id,
94
+ annotationId: entry.annotationId,
95
+ createdAt: entry.createdAt,
96
+ }));
97
+ }
98
+
99
+ return merge;
100
+ }
101
+
74
102
  /**
75
103
  * Mirror the planner's own annotations up into the share, so collaborators see
76
104
  * them on their next refresh. Owner-gated at the gateway; the notes stay
@@ -90,26 +118,98 @@ export async function pushShare(
90
118
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
91
119
  }
92
120
 
121
+ export interface ShareWatchHandlers {
122
+ /** The whole session record, each time the share changes. */
123
+ onSession: (session: ReviewSession) => void;
124
+ /** The stream ended, for any reason; the caller decides whether to reconnect. */
125
+ onClose: (reason: string) => void;
126
+ }
127
+
128
+ const WatchFrameSchema = v.variant("type", [
129
+ v.object({ type: v.literal("ready") }),
130
+ v.object({ type: v.literal("ping") }),
131
+ v.object({ type: v.literal("session"), session: SessionRecordSchema }),
132
+ ]);
133
+
134
+ /**
135
+ * Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
136
+ * a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
137
+ */
138
+ export function watchShare(
139
+ shareId: string,
140
+ handlers: ShareWatchHandlers,
141
+ target: ShareTarget = {},
142
+ ): () => void {
143
+ const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
144
+ stdin: "pipe",
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ });
148
+
149
+ proc.stdin.write(shareId);
150
+ proc.stdin.end();
151
+ void readLines(proc.stdout, (line) => {
152
+ const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
153
+
154
+ if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
155
+ }).catch(() => {});
156
+ void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
157
+ handlers.onClose(stderr.trim() || `ssh exited ${code}`),
158
+ );
159
+
160
+ return () => proc.kill();
161
+ }
162
+
163
+ /** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
164
+ async function readLines(
165
+ stream: ReadableStream<Uint8Array>,
166
+ onLine: (line: string) => void,
167
+ ): Promise<void> {
168
+ const decoder = new TextDecoder();
169
+ let pending = "";
170
+
171
+ for await (const chunk of stream) {
172
+ pending += decoder.decode(chunk, { stream: true });
173
+ const lines = pending.split("\n");
174
+
175
+ pending = lines.pop() ?? "";
176
+ for (const line of lines) {
177
+ if (line.trim() === "") continue;
178
+ try {
179
+ onLine(line);
180
+ } catch {
181
+ // one bad line never ends the stream
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ function shareSshCommand(command: string, target: ShareTarget): string[] {
188
+ const host = target.host ?? DEFAULT_SHARE_HOST;
189
+ const port = target.port ?? DEFAULT_SHARE_PORT;
190
+
191
+ return [
192
+ "ssh",
193
+ "-p",
194
+ String(port),
195
+ "-o",
196
+ "StrictHostKeyChecking=accept-new",
197
+ `${SHARE_UPLOAD_USER}@${host}`,
198
+ command,
199
+ ];
200
+ }
201
+
93
202
  /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
94
203
  async function runShareSsh(
95
204
  command: string,
96
205
  stdin: Buffer,
97
206
  target: ShareTarget,
98
207
  ): Promise<{ stdout: string; stderr: string; code: number }> {
99
- const host = target.host ?? DEFAULT_SHARE_HOST;
100
- const port = target.port ?? DEFAULT_SHARE_PORT;
101
- const proc = Bun.spawn(
102
- [
103
- "ssh",
104
- "-p",
105
- String(port),
106
- "-o",
107
- "StrictHostKeyChecking=accept-new",
108
- `${SHARE_UPLOAD_USER}@${host}`,
109
- command,
110
- ],
111
- { stdin, stdout: "pipe", stderr: "pipe" },
112
- );
208
+ const proc = Bun.spawn(shareSshCommand(command, target), {
209
+ stdin,
210
+ stdout: "pipe",
211
+ stderr: "pipe",
212
+ });
113
213
  const [stdout, stderr, code] = await Promise.all([
114
214
  new Response(proc.stdout).text(),
115
215
  new Response(proc.stderr).text(),
@@ -2,8 +2,22 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createElement, type ReactElement } from "react";
6
+ import type { KeyEvent, MouseEvent } from "@opentui/core";
7
+ import type { TestRendererSetup } from "@opentui/core/testing";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { testRender } from "@opentui/react/test-utils";
5
10
  import { loadConfig } from "./config";
6
- import { isolateUserConfig } from "./test-support";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ locateText,
17
+ pressKey,
18
+ waitForText,
19
+ type FrameLocation,
20
+ } from "./test-support";
7
21
 
8
22
  describe("isolateUserConfig", () => {
9
23
  test("loadConfig sees defaults while isolated and the env is restored after", () => {
@@ -58,3 +72,181 @@ describe("isolateUserConfig", () => {
58
72
  }
59
73
  });
60
74
  });
75
+
76
+ interface RecordedKey {
77
+ name: string;
78
+ ctrl: boolean;
79
+ shift: boolean;
80
+ meta: boolean;
81
+ }
82
+
83
+ interface RecordedMouseEvent {
84
+ type: "down" | "up";
85
+ x: number;
86
+ y: number;
87
+ }
88
+
89
+ interface FixtureLog {
90
+ keys: RecordedKey[];
91
+ mouseEvents: RecordedMouseEvent[];
92
+ pressZoneClicks: number[];
93
+ }
94
+
95
+ /**
96
+ * A minimal interaction surface: one keyboard recorder, a root box recording
97
+ * down/up coordinates, and stacked text rows at known positions.
98
+ */
99
+ function InteractionFixture(properties: { log: FixtureLog }): ReactElement {
100
+ const { log } = properties;
101
+
102
+ useKeyboard((key: KeyEvent) => {
103
+ log.keys.push({ name: key.name, ctrl: key.ctrl, shift: key.shift, meta: key.meta });
104
+ });
105
+
106
+ return createElement(
107
+ "box",
108
+ {
109
+ width: "100%",
110
+ height: "100%",
111
+ flexDirection: "column",
112
+ onMouseDown: (event: MouseEvent) => {
113
+ log.mouseEvents.push({ type: "down", x: event.x, y: event.y });
114
+ },
115
+ onMouseUp: (event: MouseEvent) => {
116
+ log.mouseEvents.push({ type: "up", x: event.x, y: event.y });
117
+ },
118
+ },
119
+ createElement("text", { content: "header line" }),
120
+ createElement("text", { content: " shifted needle" }),
121
+ createElement("text", { content: " drag from here" }),
122
+ createElement("text", { content: " drop to here" }),
123
+ createElement("text", {
124
+ content: "press zone",
125
+ onMouseDown: (event: MouseEvent) => {
126
+ log.pressZoneClicks.push(event.x);
127
+ },
128
+ }),
129
+ );
130
+ }
131
+
132
+ async function renderFixture(): Promise<{ setup: TestRendererSetup; log: FixtureLog }> {
133
+ const log: FixtureLog = { keys: [], mouseEvents: [], pressZoneClicks: [] };
134
+ const setup = await testRender(createElement(InteractionFixture, { log }), {
135
+ width: 40,
136
+ height: 8,
137
+ });
138
+
139
+ await waitForText(setup, "press zone");
140
+
141
+ return { setup, log };
142
+ }
143
+
144
+ describe("pressKey", () => {
145
+ test("a ctrl+letter chord reaches the keyboard handler with ctrl set", async () => {
146
+ // Arrange
147
+ const { setup, log } = await renderFixture();
148
+
149
+ // Act
150
+ await pressKey(setup, "o", { ctrl: true });
151
+
152
+ // Assert
153
+ expect(log.keys).toEqual([{ name: "o", ctrl: true, shift: false, meta: false }]);
154
+ });
155
+
156
+ test("a named key with a modifier arrives as the modified key", async () => {
157
+ // Arrange
158
+ const { setup, log } = await renderFixture();
159
+
160
+ // Act
161
+ await pressKey(setup, "ARROW_RIGHT", { shift: true });
162
+
163
+ // Assert
164
+ expect(log.keys).toEqual([{ name: "right", ctrl: false, shift: true, meta: false }]);
165
+ });
166
+ });
167
+
168
+ describe("locateText", () => {
169
+ test("returns the 0-based row and start column of the first occurrence", async () => {
170
+ // Arrange
171
+ const { setup } = await renderFixture();
172
+
173
+ // Act
174
+ const located = locateText(setup, "shifted needle");
175
+
176
+ // Assert
177
+ expect(located).toEqual({ row: 1, column: 3 });
178
+ });
179
+
180
+ test("a miss throws with the needle and the full frame in the message", async () => {
181
+ // Arrange
182
+ const { setup } = await renderFixture();
183
+
184
+ // Act
185
+ const locateMissing = (): FrameLocation => locateText(setup, "absent needle");
186
+
187
+ // Assert
188
+ expect(locateMissing).toThrow('"absent needle"');
189
+ expect(locateMissing).toThrow("header line");
190
+ });
191
+ });
192
+
193
+ describe("frameRow", () => {
194
+ test("returns rows that reflect the vertical layout", async () => {
195
+ // Arrange
196
+ const { setup } = await renderFixture();
197
+
198
+ // Act / Assert
199
+ expect(frameRow(setup, "header line")).toBe(0);
200
+ expect(frameRow(setup, "drop to here")).toBe(3);
201
+ expect(frameRow(setup, "press zone")).toBe(4);
202
+ });
203
+ });
204
+
205
+ describe("clickText", () => {
206
+ test("lands the click on the element rendering the needle", async () => {
207
+ // Arrange
208
+ const { setup, log } = await renderFixture();
209
+
210
+ // Act
211
+ await clickText(setup, "press zone");
212
+
213
+ // Assert
214
+ expect(log.pressZoneClicks).toEqual([0]);
215
+ });
216
+
217
+ test("charOffset shifts the click within the needle", async () => {
218
+ // Arrange
219
+ const { setup, log } = await renderFixture();
220
+
221
+ // Act
222
+ await clickText(setup, "press zone", 6);
223
+
224
+ // Assert
225
+ expect(log.pressZoneClicks).toEqual([6]);
226
+ });
227
+ });
228
+
229
+ describe("dragText", () => {
230
+ test("delivers the down at the from-text and the up at the to-text", async () => {
231
+ // Arrange
232
+ const { setup, log } = await renderFixture();
233
+
234
+ // Act
235
+ await dragText(setup, "drag from here", "drop to here");
236
+
237
+ // Assert
238
+ expect(log.mouseEvents).toContainEqual({ type: "down", x: 2, y: 2 });
239
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 5, y: 3 });
240
+ });
241
+
242
+ test("an end offset lands the up that many characters past the to-text", async () => {
243
+ // Arrange
244
+ const { setup, log } = await renderFixture();
245
+
246
+ // Act
247
+ await dragText(setup, "drag from here", "drop to here", 4);
248
+
249
+ // Assert
250
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
251
+ });
252
+ });
@@ -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
- (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
40
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
37
41
  }
38
42
 
39
43
  /**
@@ -63,6 +67,70 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
63
67
  await settle(setup);
64
68
  }
65
69
 
70
+ /** Drive one key press with modifiers: KeyCodes names ("ARROW_RIGHT") or single letters (ctrl+o). */
71
+ export async function pressKey(
72
+ setup: TestRendererSetup,
73
+ key: string,
74
+ modifiers?: { shift?: boolean; ctrl?: boolean; meta?: boolean },
75
+ ): Promise<void> {
76
+ setup.mockInput.pressKey(key, modifiers);
77
+ await settle(setup);
78
+ }
79
+
80
+ /** A 0-based cell position within a captured char frame. */
81
+ export interface FrameLocation {
82
+ row: number;
83
+ column: number;
84
+ }
85
+
86
+ /** Locate the first visual line containing the needle: 0-based row and start column. */
87
+ export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
88
+ const frame = setup.captureCharFrame();
89
+
90
+ for (const [row, line] of frame.split("\n").entries()) {
91
+ const column = line.indexOf(needle);
92
+
93
+ if (column !== -1) return { row, column };
94
+ }
95
+
96
+ throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
97
+ }
98
+
99
+ /** The 0-based visual row of the needle - for relative vertical-layout assertions. */
100
+ export function frameRow(setup: TestRendererSetup, needle: string): number {
101
+ return locateText(setup, needle).row;
102
+ }
103
+
104
+ /** Click the first on-screen occurrence of the needle, offset in characters from its start. */
105
+ export async function clickText(
106
+ setup: TestRendererSetup,
107
+ needle: string,
108
+ charOffset = 0,
109
+ ): Promise<void> {
110
+ const { row, column } = locateText(setup, needle);
111
+
112
+ await setup.mockMouse.click(column + charOffset, row);
113
+ await settle(setup);
114
+ }
115
+
116
+ /**
117
+ * Drag from the first occurrence of one needle to the first occurrence of
118
+ * another, `toCharOffset` characters past the second needle's start - so a
119
+ * character-precise selection can end inside or right after a word.
120
+ */
121
+ export async function dragText(
122
+ setup: TestRendererSetup,
123
+ fromNeedle: string,
124
+ toNeedle: string,
125
+ toCharOffset = 0,
126
+ ): Promise<void> {
127
+ const from = locateText(setup, fromNeedle);
128
+ const to = locateText(setup, toNeedle);
129
+
130
+ await setup.mockMouse.drag(from.column, from.row, to.column + toCharOffset, to.row);
131
+ await settle(setup);
132
+ }
133
+
66
134
  // Below the 60s per-test budget so a genuinely stuck wait throws its frame
67
135
  // error (useful) before bun's bare "timed out" fires. Generous because a
68
136
  // contended CI runner starves the render loop and daemon round-trips.
@@ -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[] = Object.keys(THEME_PRESETS) as 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 as ThemeName] ?? brandedTheme(appearance);
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. */
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveThreadChord } from "./thread-chords";
3
+
4
+ const owner = { composing: false, isOwner: true, resolved: false, treeActive: false };
5
+
6
+ describe("resolveThreadChord", () => {
7
+ test("ctrl+enter opens the submit overlay for an owner with an open review", () => {
8
+ // Assert: the terminal-safe chord and the cmd forms all submit
9
+ expect(resolveThreadChord({ name: "return", ctrl: true }, owner)).toEqual({
10
+ type: "openSubmit",
11
+ });
12
+ expect(resolveThreadChord({ name: "return", super: true }, owner)).toEqual({
13
+ type: "openSubmit",
14
+ });
15
+ expect(resolveThreadChord({ name: "enter", meta: true }, owner)).toEqual({
16
+ type: "openSubmit",
17
+ });
18
+ });
19
+
20
+ test("session chords map to their intents", () => {
21
+ // Assert
22
+ expect(resolveThreadChord({ name: "e", ctrl: true }, owner)).toEqual({ type: "edit" });
23
+ expect(resolveThreadChord({ name: "s", ctrl: true }, owner)).toEqual({ type: "share" });
24
+ expect(resolveThreadChord({ name: "r", ctrl: true }, owner)).toEqual({
25
+ type: "cycleReviewPanel",
26
+ });
27
+ });
28
+
29
+ test("option chords drive the rail and curation with the plan sheet's letters", () => {
30
+ // Assert
31
+ expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
32
+ type: "nextAnnotation",
33
+ });
34
+ expect(resolveThreadChord({ name: "p", meta: true }, owner)).toEqual({
35
+ type: "prevAnnotation",
36
+ });
37
+ expect(resolveThreadChord({ name: "e", meta: true }, owner)).toEqual({ type: "editCard" });
38
+ expect(resolveThreadChord({ name: "backspace", meta: true }, owner)).toEqual({
39
+ type: "removeAnnotation",
40
+ });
41
+ expect(resolveThreadChord({ name: "r", meta: true }, owner)).toEqual({ type: "openRename" });
42
+ expect(resolveThreadChord({ name: "x", meta: true }, owner)).toEqual({ type: "cut" });
43
+ expect(resolveThreadChord({ name: "u", meta: true }, owner)).toEqual({
44
+ type: "restoreCuration",
45
+ });
46
+ expect(resolveThreadChord({ name: "w", meta: true }, owner)).toEqual({
47
+ type: "resizeReviewPanel",
48
+ direction: 1,
49
+ });
50
+ expect(resolveThreadChord({ name: "x", meta: true }, { ...owner, isOwner: false })).toEqual({
51
+ type: "status",
52
+ message: "observer - read-only",
53
+ });
54
+ });
55
+
56
+ test("an open composer swallows every chord, so typing is never hijacked", () => {
57
+ // Assert
58
+ expect(
59
+ resolveThreadChord({ name: "return", ctrl: true }, { ...owner, composing: true }),
60
+ ).toBeNull();
61
+ expect(resolveThreadChord({ name: "s", ctrl: true }, { ...owner, composing: true })).toBeNull();
62
+ });
63
+
64
+ test("plain keys and unbound chords belong to the document grammar", () => {
65
+ // Assert
66
+ expect(resolveThreadChord({ name: "return" }, owner)).toBeNull();
67
+ expect(resolveThreadChord({ name: "s" }, owner)).toBeNull();
68
+ expect(resolveThreadChord({ name: "x", ctrl: true }, owner)).toBeNull();
69
+ });
70
+
71
+ test("a collaborator is told a primitive is read-only, and a resolved review cannot be resubmitted", () => {
72
+ // Assert
73
+ const collaborator = { ...owner, isOwner: false };
74
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
75
+
76
+ expect(resolveThreadChord({ name: "return", ctrl: true }, collaborator)).toEqual(readOnly);
77
+ expect(resolveThreadChord({ name: "e", ctrl: true }, collaborator)).toEqual(readOnly);
78
+ expect(resolveThreadChord({ name: "s", ctrl: true }, collaborator)).toEqual(readOnly);
79
+ expect(resolveThreadChord({ name: "r", ctrl: true }, collaborator)).toEqual({
80
+ type: "cycleReviewPanel",
81
+ });
82
+ expect(
83
+ resolveThreadChord({ name: "return", ctrl: true }, { ...owner, resolved: true }),
84
+ ).toBeNull();
85
+ });
86
+
87
+ test("a resolved review refuses every change and says so; renaming an author stays open", () => {
88
+ // Arrange
89
+ const resolved = { ...owner, resolved: true };
90
+ const submitted = { type: "status" as const, message: "review submitted - read-only" };
91
+
92
+ // Assert
93
+ expect(resolveThreadChord({ name: "e", ctrl: true }, resolved)).toEqual(submitted);
94
+ expect(resolveThreadChord({ name: "e", meta: true }, resolved)).toEqual(submitted);
95
+ expect(resolveThreadChord({ name: "backspace", meta: true }, resolved)).toEqual(submitted);
96
+ expect(resolveThreadChord({ name: "x", meta: true }, resolved)).toEqual(submitted);
97
+ expect(resolveThreadChord({ name: "u", meta: true }, resolved)).toEqual(submitted);
98
+ expect(resolveThreadChord({ name: "r", meta: true }, resolved)).toEqual({ type: "openRename" });
99
+ expect(resolveThreadChord({ name: "s", ctrl: true }, resolved)).toEqual({ type: "share" });
100
+ });
101
+
102
+ test("the tree chords: toggle, move on the Tree tab, go, branch, label, fork, hand off", () => {
103
+ // Arrange
104
+ const onTree = { ...owner, treeActive: true };
105
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
106
+ const submitted = { type: "status" as const, message: "review submitted - read-only" };
107
+
108
+ // Assert: next / previous follow the tab that shows
109
+ expect(resolveThreadChord({ name: "t", meta: true }, owner)).toEqual({ type: "toggleTree" });
110
+ expect(resolveThreadChord({ name: "n", meta: true }, onTree)).toEqual({
111
+ type: "treeMove",
112
+ direction: 1,
113
+ });
114
+ expect(resolveThreadChord({ name: "p", meta: true }, onTree)).toEqual({
115
+ type: "treeMove",
116
+ direction: -1,
117
+ });
118
+ expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
119
+ type: "nextAnnotation",
120
+ });
121
+ expect(resolveThreadChord({ name: "g", meta: true }, owner)).toEqual({ type: "treeGo" });
122
+ expect(resolveThreadChord({ name: "b", meta: true }, owner)).toEqual({ type: "treeBranch" });
123
+ expect(resolveThreadChord({ name: "l", meta: true }, owner)).toEqual({ type: "treeLabel" });
124
+ expect(resolveThreadChord({ name: "f", meta: true }, owner)).toEqual({ type: "treeFork" });
125
+ expect(resolveThreadChord({ name: "h", meta: true }, owner)).toEqual({ type: "treeForkShare" });
126
+ // moving the tree is the owner's; a fork is open after a verdict, a move is not
127
+ expect(resolveThreadChord({ name: "g", meta: true }, { ...owner, isOwner: false })).toEqual(
128
+ readOnly,
129
+ );
130
+ expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, isOwner: false })).toEqual(
131
+ readOnly,
132
+ );
133
+ expect(resolveThreadChord({ name: "b", meta: true }, { ...owner, resolved: true })).toEqual(
134
+ submitted,
135
+ );
136
+ expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, resolved: true })).toEqual({
137
+ type: "treeFork",
138
+ });
139
+ expect(resolveThreadChord({ name: "t", meta: true }, { ...owner, isOwner: false })).toEqual({
140
+ type: "toggleTree",
141
+ });
142
+ });
143
+
144
+ test("an observer cannot edit or delete cards either", () => {
145
+ // Arrange
146
+ const observer = { ...owner, isOwner: false };
147
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
148
+
149
+ // Assert
150
+ expect(resolveThreadChord({ name: "e", meta: true }, observer)).toEqual(readOnly);
151
+ expect(resolveThreadChord({ name: "backspace", meta: true }, observer)).toEqual(readOnly);
152
+ expect(resolveThreadChord({ name: "r", meta: true }, observer)).toEqual({ type: "openRename" });
153
+ });
154
+ });