@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66
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 +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- 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/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
package/src/share.ts
CHANGED
|
@@ -12,8 +12,14 @@ import {
|
|
|
12
12
|
SHARE_UPLOAD_USER,
|
|
13
13
|
packSessionBlob,
|
|
14
14
|
} from "@cueloop/daemon/share-blob";
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
removalEntries,
|
|
17
|
+
viewFollowing,
|
|
18
|
+
type Annotation,
|
|
19
|
+
type ReviewSession,
|
|
20
|
+
} from "@cueloop/schema";
|
|
16
21
|
import { SessionRecordSchema } from "@cueloop/daemon/validate";
|
|
22
|
+
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
17
23
|
import * as v from "valibot";
|
|
18
24
|
import { copyToClipboard } from "./clipboard";
|
|
19
25
|
|
|
@@ -41,7 +47,8 @@ export async function publishShare(
|
|
|
41
47
|
): Promise<ShareResult> {
|
|
42
48
|
const { stdout, stderr, code } = await runShareSsh(
|
|
43
49
|
"cueloop-share",
|
|
44
|
-
|
|
50
|
+
// the share follows one branch: collaborators see its path wherever the owner stands
|
|
51
|
+
packSessionBlob(viewFollowing(session)),
|
|
45
52
|
target,
|
|
46
53
|
);
|
|
47
54
|
|
|
@@ -73,6 +80,25 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
|
|
|
73
80
|
return session.annotations.filter((annotation) => annotation.author);
|
|
74
81
|
}
|
|
75
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
|
+
|
|
76
102
|
/**
|
|
77
103
|
* Mirror the planner's own annotations up into the share, so collaborators see
|
|
78
104
|
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
@@ -92,26 +118,98 @@ export async function pushShare(
|
|
|
92
118
|
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
93
119
|
}
|
|
94
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
|
+
|
|
95
202
|
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
96
203
|
async function runShareSsh(
|
|
97
204
|
command: string,
|
|
98
205
|
stdin: Buffer,
|
|
99
206
|
target: ShareTarget,
|
|
100
207
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"-p",
|
|
107
|
-
String(port),
|
|
108
|
-
"-o",
|
|
109
|
-
"StrictHostKeyChecking=accept-new",
|
|
110
|
-
`${SHARE_UPLOAD_USER}@${host}`,
|
|
111
|
-
command,
|
|
112
|
-
],
|
|
113
|
-
{ stdin, stdout: "pipe", stderr: "pipe" },
|
|
114
|
-
);
|
|
208
|
+
const proc = Bun.spawn(shareSshCommand(command, target), {
|
|
209
|
+
stdin,
|
|
210
|
+
stdout: "pipe",
|
|
211
|
+
stderr: "pipe",
|
|
212
|
+
});
|
|
115
213
|
const [stdout, stderr, code] = await Promise.all([
|
|
116
214
|
new Response(proc.stdout).text(),
|
|
117
215
|
new Response(proc.stderr).text(),
|
package/src/test-support.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/test-support.ts
CHANGED
|
@@ -67,6 +67,70 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
|
|
|
67
67
|
await settle(setup);
|
|
68
68
|
}
|
|
69
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
|
+
|
|
70
134
|
// Below the 60s per-test budget so a genuinely stuck wait throws its frame
|
|
71
135
|
// error (useful) before bun's bare "timed out" fires. Generous because a
|
|
72
136
|
// contended CI runner starves the render loop and daemon round-trips.
|
package/src/theme.ts
CHANGED
|
@@ -54,8 +54,8 @@ export const DARK: Theme = {
|
|
|
54
54
|
text: "#e4e6ec",
|
|
55
55
|
textMuted: "#b1b6c4",
|
|
56
56
|
textDim: "#6b7280",
|
|
57
|
-
accent: "#
|
|
58
|
-
accentInk: "#
|
|
57
|
+
accent: "#cba6f7",
|
|
58
|
+
accentInk: "#17171a",
|
|
59
59
|
green: "#62d96b",
|
|
60
60
|
red: "#f08080",
|
|
61
61
|
blue: "#a9c8f5",
|
|
@@ -80,8 +80,8 @@ export const LIGHT: Theme = {
|
|
|
80
80
|
text: "#1c1d24",
|
|
81
81
|
textMuted: "#4b5162",
|
|
82
82
|
textDim: "#868c9c",
|
|
83
|
-
accent: "#
|
|
84
|
-
accentInk: "#
|
|
83
|
+
accent: "#5b3ea6",
|
|
84
|
+
accentInk: "#fbfbfa",
|
|
85
85
|
green: "#2f8a3e",
|
|
86
86
|
red: "#c0392b",
|
|
87
87
|
blue: "#2f6fb0",
|
|
@@ -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
|
+
});
|