@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/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
13
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
|
+
import * as v from "valibot";
|
|
15
16
|
import { DARK, type Theme } from "./theme";
|
|
16
17
|
import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
|
|
17
18
|
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
@@ -112,7 +113,7 @@ export interface CueloopConfig {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/** Every action in the grammar, with its default binding(s). */
|
|
115
|
-
export const DEFAULT_KEYS:
|
|
116
|
+
export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
116
117
|
down: ["j", "down"],
|
|
117
118
|
up: ["k", "up"],
|
|
118
119
|
top: ["g"],
|
|
@@ -136,100 +137,87 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
136
137
|
review_narrower: ["["],
|
|
137
138
|
};
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
const ConfigDocumentSchema = v.object({
|
|
141
|
+
actions: v.optional(v.array(v.unknown())),
|
|
142
|
+
authors: v.optional(v.unknown()),
|
|
143
|
+
integrations: v.optional(v.unknown()),
|
|
144
|
+
keys: v.optional(v.unknown()),
|
|
145
|
+
theme: v.optional(v.unknown()),
|
|
146
|
+
ui: v.optional(v.unknown()),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const QuickActionSchema = v.object({
|
|
150
|
+
prompt: v.pipe(
|
|
151
|
+
v.string(),
|
|
152
|
+
v.check((prompt) => Boolean(prompt.trim())),
|
|
153
|
+
),
|
|
154
|
+
metadata: v.optional(v.string()),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const AuthorsSchema = v.record(v.string(), v.unknown());
|
|
158
|
+
const KeysSchema = v.record(v.string(), v.unknown());
|
|
159
|
+
const KeyComboSchema = v.union([v.string(), v.array(v.string())]);
|
|
160
|
+
// Every field falls back to undefined on its own, so one bad value in a
|
|
161
|
+
// section never discards the section's other settings.
|
|
162
|
+
const UiSchema = v.object({
|
|
163
|
+
auto_close: v.fallback(
|
|
164
|
+
v.optional(v.union([v.literal("off"), v.pipe(v.number(), v.minValue(0))])),
|
|
165
|
+
undefined,
|
|
166
|
+
),
|
|
167
|
+
editor: v.fallback(v.optional(v.string()), undefined),
|
|
168
|
+
review_width: v.fallback(v.optional(v.pipe(v.number(), v.finite())), undefined),
|
|
169
|
+
review_state: v.fallback(v.optional(v.picklist(["expanded", "compact", "hidden"])), undefined),
|
|
170
|
+
theme: v.fallback(v.optional(v.string()), undefined),
|
|
171
|
+
});
|
|
172
|
+
const ObsidianSchema = v.object({
|
|
173
|
+
vault: v.fallback(v.optional(v.string()), undefined),
|
|
174
|
+
folder: v.fallback(v.optional(v.string()), undefined),
|
|
175
|
+
filenameFormat: v.fallback(v.optional(v.string()), undefined),
|
|
176
|
+
separator: v.fallback(v.optional(v.picklist(["space", "dash", "underscore"])), undefined),
|
|
177
|
+
exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
|
|
178
|
+
});
|
|
179
|
+
const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
|
|
180
|
+
const ThemeOverridesSchema = v.record(v.string(), v.unknown());
|
|
181
|
+
|
|
182
|
+
function isThemeToken(token: string): token is keyof Theme {
|
|
183
|
+
return token in DARK;
|
|
142
184
|
}
|
|
143
185
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* is skipped; any valid table REPLACES the defaults (the whole set is the
|
|
147
|
-
* user's, not merged). Returns undefined when nothing usable is present.
|
|
148
|
-
*/
|
|
149
|
-
function parseActions(raw: unknown): QuickAction[] | undefined {
|
|
150
|
-
if (!Array.isArray(raw)) return undefined;
|
|
151
|
-
const actions: QuickAction[] = [];
|
|
152
|
-
|
|
153
|
-
for (const entry of raw) {
|
|
154
|
-
if (!entry || typeof entry !== "object") continue;
|
|
155
|
-
const table = entry as Record<string, unknown>;
|
|
156
|
-
const prompt = table["prompt"];
|
|
157
|
-
|
|
158
|
-
if (typeof prompt !== "string" || !prompt.trim()) continue;
|
|
159
|
-
const metadata = table["metadata"];
|
|
160
|
-
|
|
161
|
-
actions.push(
|
|
162
|
-
typeof metadata === "string" && metadata.trim() ? { prompt, metadata } : { prompt },
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return actions.length ? actions : undefined;
|
|
186
|
+
function parseToml(text: string) {
|
|
187
|
+
return v.parse(ConfigDocumentSchema, Bun.TOML.parse(text));
|
|
167
188
|
}
|
|
168
189
|
|
|
169
|
-
function
|
|
170
|
-
|
|
190
|
+
function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
|
|
191
|
+
if (!entries) return undefined;
|
|
192
|
+
const actions: QuickAction[] = [];
|
|
171
193
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (typeof value === "string") target[id] = value;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const result = v.safeParse(QuickActionSchema, entry);
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
|
|
197
|
+
if (!result.success) continue;
|
|
198
|
+
const { prompt, metadata } = result.output;
|
|
180
199
|
|
|
181
|
-
|
|
182
|
-
for (const [action, combo] of Object.entries(keys)) {
|
|
183
|
-
target[action] = Array.isArray(combo) ? combo : [combo];
|
|
200
|
+
actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
|
|
184
201
|
}
|
|
185
|
-
}
|
|
186
202
|
|
|
187
|
-
|
|
188
|
-
if (value === undefined) return;
|
|
189
|
-
if (value === "off") target.autoClose = "off";
|
|
190
|
-
else if (typeof value === "number" && value >= 0) target.autoClose = value;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function mergeUi(target: CueloopConfig["ui"], raw: unknown): void {
|
|
194
|
-
const ui = raw as
|
|
195
|
-
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
196
|
-
| undefined;
|
|
197
|
-
|
|
198
|
-
if (!ui) return;
|
|
199
|
-
mergeAutoClose(target, ui.auto_close);
|
|
200
|
-
if (typeof ui.editor === "string" && ui.editor.trim()) target.editor = ui.editor.trim();
|
|
201
|
-
if (typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
202
|
-
target.reviewWidth = clampWidth(ui.review_width);
|
|
203
|
-
}
|
|
204
|
-
if (
|
|
205
|
-
ui.review_state === "expanded" ||
|
|
206
|
-
ui.review_state === "compact" ||
|
|
207
|
-
ui.review_state === "hidden"
|
|
208
|
-
) {
|
|
209
|
-
target.reviewState = ui.review_state;
|
|
210
|
-
}
|
|
203
|
+
return actions.length ? actions : undefined;
|
|
211
204
|
}
|
|
212
205
|
|
|
213
|
-
function mergeObsidian(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (
|
|
218
|
-
if (
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
const separator = obsidian["separator"];
|
|
223
|
-
|
|
224
|
-
if (separator === "space" || separator === "dash" || separator === "underscore")
|
|
225
|
-
target.separator = separator;
|
|
226
|
-
const exportOn = obsidian["exportOn"];
|
|
227
|
-
|
|
228
|
-
if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual")
|
|
229
|
-
target.exportOn = exportOn;
|
|
206
|
+
function mergeObsidian(
|
|
207
|
+
target: ObsidianConfig,
|
|
208
|
+
obsidian: v.InferOutput<typeof ObsidianSchema>,
|
|
209
|
+
): void {
|
|
210
|
+
if (obsidian.vault !== undefined) target.vault = obsidian.vault;
|
|
211
|
+
if (obsidian.folder !== undefined) target.folder = obsidian.folder;
|
|
212
|
+
if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
|
|
213
|
+
if (obsidian.separator !== undefined) target.separator = obsidian.separator;
|
|
214
|
+
if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
|
|
230
215
|
}
|
|
231
216
|
|
|
232
|
-
function layer(
|
|
217
|
+
function layer(
|
|
218
|
+
base: CueloopConfig,
|
|
219
|
+
raw: v.InferOutput<typeof ConfigDocumentSchema>,
|
|
220
|
+
): CueloopConfig {
|
|
233
221
|
const out: CueloopConfig = {
|
|
234
222
|
keys: { ...base.keys },
|
|
235
223
|
theme: { ...base.theme },
|
|
@@ -239,13 +227,38 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
239
227
|
actions: [...base.actions],
|
|
240
228
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
241
229
|
};
|
|
242
|
-
const actions = parseActions(raw
|
|
230
|
+
const actions = parseActions(raw.actions);
|
|
231
|
+
const authors = v.safeParse(AuthorsSchema, raw.authors);
|
|
232
|
+
const keys = v.safeParse(KeysSchema, raw.keys);
|
|
233
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
234
|
+
const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
|
|
243
235
|
|
|
244
236
|
if (actions) out.actions = actions;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
237
|
+
if (authors.success) {
|
|
238
|
+
for (const [id, value] of Object.entries(authors.output)) {
|
|
239
|
+
const name = v.safeParse(v.string(), value);
|
|
240
|
+
|
|
241
|
+
if (name.success) out.authors[id] = name.output;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (keys.success) {
|
|
245
|
+
for (const [action, value] of Object.entries(keys.output)) {
|
|
246
|
+
const combo = v.safeParse(KeyComboSchema, value);
|
|
247
|
+
|
|
248
|
+
if (combo.success)
|
|
249
|
+
out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (ui.success) {
|
|
253
|
+
if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
|
|
254
|
+
if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
|
|
255
|
+
if (ui.output.review_width !== undefined)
|
|
256
|
+
out.ui.reviewWidth = clampWidth(ui.output.review_width);
|
|
257
|
+
if (ui.output.review_state !== undefined) out.ui.reviewState = ui.output.review_state;
|
|
258
|
+
}
|
|
259
|
+
if (integrations.success && integrations.output.obsidian) {
|
|
260
|
+
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
261
|
+
}
|
|
249
262
|
|
|
250
263
|
return out;
|
|
251
264
|
}
|
|
@@ -284,10 +297,18 @@ export function loadConfig(
|
|
|
284
297
|
const raw = parseToml(readFileSync(path, "utf8"));
|
|
285
298
|
|
|
286
299
|
config = layer(config, raw);
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
300
|
+
const ui = v.safeParse(UiSchema, raw.ui);
|
|
301
|
+
const rawTheme = ui.success ? ui.output.theme : undefined;
|
|
302
|
+
const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
|
|
303
|
+
|
|
304
|
+
if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
|
|
305
|
+
if (parsedThemeOverrides.success) {
|
|
306
|
+
for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
|
|
307
|
+
const override = v.safeParse(v.string(), value);
|
|
308
|
+
|
|
309
|
+
if (override.success && isThemeToken(token)) themeOverrides[token] = override.output;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
291
312
|
} catch {
|
|
292
313
|
// a broken config never blocks a review; defaults win
|
|
293
314
|
}
|
|
@@ -299,14 +320,6 @@ export function loadConfig(
|
|
|
299
320
|
return config;
|
|
300
321
|
}
|
|
301
322
|
|
|
302
|
-
/** Merge a raw `[theme]` table's known string tokens into the accumulated overrides. */
|
|
303
|
-
function collectThemeOverrides(raw: unknown, into: Partial<Record<keyof Theme, string>>): void {
|
|
304
|
-
if (!raw || typeof raw !== "object") return;
|
|
305
|
-
for (const [token, value] of Object.entries(raw)) {
|
|
306
|
-
if (token in DARK && typeof value === "string") into[token as keyof Theme] = value;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
323
|
/** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
|
|
311
324
|
export function actionFor(
|
|
312
325
|
keys: Record<string, string[]>,
|
package/src/editor.ts
CHANGED
|
@@ -21,7 +21,11 @@ export interface EditResult {
|
|
|
21
21
|
const DEFAULT_EDITOR = "nano";
|
|
22
22
|
|
|
23
23
|
/** GUI editors fork and return immediately unless given their wait flag. */
|
|
24
|
-
|
|
24
|
+
interface GuiWaitFlags {
|
|
25
|
+
[editor: string]: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUI_WAIT_FLAGS: GuiWaitFlags = {
|
|
25
29
|
code: ["--wait"],
|
|
26
30
|
"code-insiders": ["--wait"],
|
|
27
31
|
codium: ["--wait"],
|
|
@@ -8,7 +8,7 @@ const factory = loadGhosttyTerminals();
|
|
|
8
8
|
describe("ghostty-terminal FFI", () => {
|
|
9
9
|
test("reports platform support without throwing", () => {
|
|
10
10
|
// Assert - null on unsupported platforms, a factory where a dylib ships
|
|
11
|
-
expect(factory === null ||
|
|
11
|
+
expect(factory === null || factory.create !== undefined).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
|
package/src/herdr.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* is best-effort - a failure never blocks closing the review.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
|
|
11
|
+
const HerdrPaneSchema = v.object({
|
|
12
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
9
15
|
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
10
16
|
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
11
17
|
try {
|
|
@@ -15,8 +21,10 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
|
15
21
|
});
|
|
16
22
|
|
|
17
23
|
if (got.exitCode !== 0) return false;
|
|
18
|
-
const parsed = JSON.parse(got.stdout.toString())
|
|
19
|
-
|
|
24
|
+
const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) return false;
|
|
27
|
+
const tab = parsed.output.result?.pane?.tab_id;
|
|
20
28
|
|
|
21
29
|
if (!tab) return false;
|
|
22
30
|
|
|
@@ -6,54 +6,106 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
-
import type {
|
|
9
|
+
import type { SetStateAction } from "react";
|
|
10
|
+
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
10
11
|
import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
|
|
12
|
+
import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
|
|
11
13
|
import type { DisplayBlock } from "./view-plan";
|
|
12
14
|
|
|
13
15
|
function block(text: string): DisplayBlock {
|
|
14
|
-
return {
|
|
16
|
+
return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
function
|
|
19
|
+
function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
|
|
18
20
|
return {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
id,
|
|
22
|
+
kind: "comment",
|
|
23
|
+
anchor: { quote: "", prefix: "", suffix: "" },
|
|
24
|
+
body: "",
|
|
25
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sessionWith(
|
|
31
|
+
annotations: Annotation[],
|
|
32
|
+
overrides: Partial<ReviewSession> = {},
|
|
33
|
+
): ReviewSession {
|
|
34
|
+
return {
|
|
35
|
+
schemaVersion: SCHEMA_VERSION,
|
|
36
|
+
id: "ses_1",
|
|
37
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
38
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
39
|
+
revisions: [],
|
|
40
|
+
annotations,
|
|
41
|
+
verdict: null,
|
|
42
|
+
status: "pending",
|
|
43
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
22
46
|
}
|
|
23
47
|
|
|
48
|
+
const EMPTY_SNAPSHOT: ControllerSnapshot = {
|
|
49
|
+
session: null,
|
|
50
|
+
inbox: null,
|
|
51
|
+
status: "",
|
|
52
|
+
toast: null,
|
|
53
|
+
error: null,
|
|
54
|
+
completion: { phase: "idle" },
|
|
55
|
+
editOrphanCount: 0,
|
|
56
|
+
walk: null,
|
|
57
|
+
};
|
|
58
|
+
|
|
24
59
|
/** A controller where every verb is a mock; annotate returns undefined by default. */
|
|
25
|
-
function baseController() {
|
|
60
|
+
function baseController(): ReviewController {
|
|
26
61
|
return {
|
|
62
|
+
readOnly: false,
|
|
63
|
+
subscribe: mock(() => () => {}),
|
|
64
|
+
getSnapshot: mock(() => EMPTY_SNAPSHOT),
|
|
65
|
+
connect: mock(),
|
|
66
|
+
close: mock(),
|
|
67
|
+
applyConfig: mock(),
|
|
27
68
|
setStatus: mock(),
|
|
69
|
+
showToast: mock(),
|
|
70
|
+
dismissToast: mock(),
|
|
71
|
+
display: mock(() => []),
|
|
72
|
+
rows: mock(() => []),
|
|
73
|
+
files: mock(() => []),
|
|
74
|
+
working: mock(() => ""),
|
|
28
75
|
open: mock(),
|
|
29
76
|
deleteSession: mock(),
|
|
30
77
|
setSelfName: mock(),
|
|
31
78
|
cut: mock(),
|
|
79
|
+
toggleRejectHunk: mock(),
|
|
80
|
+
toggleRejectChange: mock(),
|
|
81
|
+
rejectedRows: mock(() => new Set<number>()),
|
|
32
82
|
curationItems: mock(() => []),
|
|
33
83
|
restoreCuration: mock(),
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
edit: mock(),
|
|
85
|
+
annotate: mock(() => undefined),
|
|
86
|
+
annotatePrototype: mock(() => undefined),
|
|
36
87
|
updateAnnotation: mock(),
|
|
37
88
|
removeAnnotation: mock(),
|
|
89
|
+
setWorkingCopy: mock(),
|
|
38
90
|
walkStart: mock(),
|
|
39
91
|
walkForward: mock(),
|
|
40
92
|
walkBack: mock(),
|
|
41
93
|
walkLeave: mock(),
|
|
42
94
|
submit: mock(),
|
|
43
95
|
share: mock(),
|
|
96
|
+
pullShared: mock(() => Promise.resolve()),
|
|
97
|
+
startSharePoll: mock(() => () => {}),
|
|
44
98
|
finishReview: mock(),
|
|
45
|
-
optInAutoClose: mock(),
|
|
46
99
|
dismissCompletion: mock(),
|
|
100
|
+
optInAutoClose: mock(),
|
|
47
101
|
saveReviewPanel: mock(),
|
|
48
102
|
};
|
|
49
103
|
}
|
|
50
104
|
|
|
51
105
|
/** A deps bag where every effect is a mock and every read has a plain default. */
|
|
52
106
|
function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
|
|
53
|
-
const controller = baseController();
|
|
54
|
-
|
|
55
107
|
return {
|
|
56
|
-
controller:
|
|
108
|
+
controller: baseController(),
|
|
57
109
|
onExit: mock(),
|
|
58
110
|
isDiff: false,
|
|
59
111
|
display: [],
|
|
@@ -90,20 +142,24 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
90
142
|
};
|
|
91
143
|
}
|
|
92
144
|
|
|
145
|
+
function isUpdater(action: SetStateAction<number>): action is (current: number) => number {
|
|
146
|
+
return typeof action === "function";
|
|
147
|
+
}
|
|
148
|
+
|
|
93
149
|
describe("move", () => {
|
|
94
150
|
test("down clamps at the last row and up clamps at the first", () => {
|
|
95
151
|
// Arrange
|
|
96
|
-
const
|
|
152
|
+
const setCursor = mock((_action: SetStateAction<number>) => {});
|
|
153
|
+
const deps = makeDeps({ display: [block("a"), block("b"), block("c")], setCursor });
|
|
97
154
|
const dispatch = createIntentDispatch(deps);
|
|
98
155
|
|
|
99
156
|
// Act
|
|
100
157
|
dispatch({ type: "move", to: "down" });
|
|
101
158
|
|
|
102
159
|
// Assert
|
|
103
|
-
const advance =
|
|
104
|
-
current: number,
|
|
105
|
-
) => number;
|
|
160
|
+
const advance = setCursor.mock.calls[0]![0];
|
|
106
161
|
|
|
162
|
+
if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
|
|
107
163
|
expect(advance(2)).toBe(2); // already at the end, stays
|
|
108
164
|
expect(advance(0)).toBe(1);
|
|
109
165
|
});
|
|
@@ -124,7 +180,10 @@ describe("move", () => {
|
|
|
124
180
|
describe("annotation navigation", () => {
|
|
125
181
|
test("nextAnnotation wraps from the last annotation back to the first", () => {
|
|
126
182
|
// Arrange
|
|
127
|
-
const deps = makeDeps({
|
|
183
|
+
const deps = makeDeps({
|
|
184
|
+
session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
|
|
185
|
+
focusedAnnotationId: "a3",
|
|
186
|
+
});
|
|
128
187
|
const dispatch = createIntentDispatch(deps);
|
|
129
188
|
|
|
130
189
|
// Act
|
|
@@ -136,7 +195,10 @@ describe("annotation navigation", () => {
|
|
|
136
195
|
|
|
137
196
|
test("prevAnnotation wraps from the first annotation to the last", () => {
|
|
138
197
|
// Arrange
|
|
139
|
-
const deps = makeDeps({
|
|
198
|
+
const deps = makeDeps({
|
|
199
|
+
session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
|
|
200
|
+
focusedAnnotationId: "a1",
|
|
201
|
+
});
|
|
140
202
|
const dispatch = createIntentDispatch(deps);
|
|
141
203
|
|
|
142
204
|
// Act
|
|
@@ -148,12 +210,11 @@ describe("annotation navigation", () => {
|
|
|
148
210
|
|
|
149
211
|
test("cycling skips annotations a revision already addressed", () => {
|
|
150
212
|
// Arrange: a2 is addressed, so next from a1 lands on a3
|
|
151
|
-
const session = sessionWith([
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
};
|
|
213
|
+
const session = sessionWith([
|
|
214
|
+
annotation("a1"),
|
|
215
|
+
annotation("a2", { resolution: { revision: 2, source: "agent" } }),
|
|
216
|
+
annotation("a3"),
|
|
217
|
+
]);
|
|
157
218
|
const deps = makeDeps({ session, focusedAnnotationId: "a1" });
|
|
158
219
|
const dispatch = createIntentDispatch(deps);
|
|
159
220
|
|
|
@@ -233,7 +294,7 @@ describe("marker-actions popover", () => {
|
|
|
233
294
|
mode: { type: "spanActions", span, index: 1 },
|
|
234
295
|
session: sessionWith([]),
|
|
235
296
|
quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
|
|
236
|
-
controller: { ...baseController(), annotate }
|
|
297
|
+
controller: { ...baseController(), annotate },
|
|
237
298
|
});
|
|
238
299
|
const dispatch = createIntentDispatch(deps);
|
|
239
300
|
|
|
@@ -253,7 +314,7 @@ describe("marker-actions popover", () => {
|
|
|
253
314
|
mode: { type: "spanActions", span, index: 0 },
|
|
254
315
|
session: sessionWith([]),
|
|
255
316
|
quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
|
|
256
|
-
controller: { ...baseController(), annotate }
|
|
317
|
+
controller: { ...baseController(), annotate },
|
|
257
318
|
});
|
|
258
319
|
const dispatch = createIntentDispatch(deps);
|
|
259
320
|
|
|
@@ -320,28 +381,15 @@ describe("share", () => {
|
|
|
320
381
|
});
|
|
321
382
|
|
|
322
383
|
describe("restoreCuration", () => {
|
|
323
|
-
const items = [
|
|
324
|
-
{
|
|
325
|
-
|
|
326
|
-
source: "diff" as const,
|
|
327
|
-
label: "f:1 - hunk",
|
|
328
|
-
preview: [],
|
|
329
|
-
revealIndex: 1,
|
|
330
|
-
},
|
|
331
|
-
{
|
|
332
|
-
id: "diff:f#1#2",
|
|
333
|
-
source: "diff" as const,
|
|
334
|
-
label: "f:9 - change",
|
|
335
|
-
preview: [],
|
|
336
|
-
revealIndex: 9,
|
|
337
|
-
},
|
|
384
|
+
const items: CurationItem[] = [
|
|
385
|
+
{ id: "diff:f#0#hunk", source: "diff", preview: [], revealIndex: 1 },
|
|
386
|
+
{ id: "diff:f#1#2", source: "diff", preview: [], revealIndex: 9 },
|
|
338
387
|
];
|
|
339
388
|
|
|
340
389
|
test("restores the selected item and clears the selection", () => {
|
|
341
390
|
// Arrange
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
|
|
391
|
+
const controller = { ...baseController(), curationItems: mock(() => items) };
|
|
392
|
+
const deps = makeDeps({ selectedCurationId: "diff:f#1#2", controller });
|
|
345
393
|
const dispatch = createIntentDispatch(deps);
|
|
346
394
|
|
|
347
395
|
// Act
|
|
@@ -354,9 +402,8 @@ describe("restoreCuration", () => {
|
|
|
354
402
|
|
|
355
403
|
test("with nothing selected, undoes the last removal", () => {
|
|
356
404
|
// Arrange
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
|
|
405
|
+
const controller = { ...baseController(), curationItems: mock(() => items) };
|
|
406
|
+
const deps = makeDeps({ selectedCurationId: undefined, controller });
|
|
360
407
|
const dispatch = createIntentDispatch(deps);
|
|
361
408
|
|
|
362
409
|
// Act
|
|
@@ -369,8 +416,6 @@ describe("restoreCuration", () => {
|
|
|
369
416
|
test("does nothing when there is nothing curated out", () => {
|
|
370
417
|
// Arrange
|
|
371
418
|
const deps = makeDeps();
|
|
372
|
-
|
|
373
|
-
(deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
|
|
374
419
|
const dispatch = createIntentDispatch(deps);
|
|
375
420
|
|
|
376
421
|
// Act
|
|
@@ -398,7 +443,9 @@ describe("exit", () => {
|
|
|
398
443
|
describe("inbox delete", () => {
|
|
399
444
|
test("requestDeleteSession opens the confirm on the cursor row", () => {
|
|
400
445
|
// Arrange
|
|
401
|
-
const inbox = [
|
|
446
|
+
const inbox = [
|
|
447
|
+
sessionWith([], { artifact: { type: "plan", content: "", meta: { title: "Plan A" } } }),
|
|
448
|
+
];
|
|
402
449
|
const deps = makeDeps({ inbox, inboxCursor: 0 });
|
|
403
450
|
|
|
404
451
|
// Act
|
|
@@ -428,7 +475,7 @@ describe("inbox delete", () => {
|
|
|
428
475
|
describe("rename author", () => {
|
|
429
476
|
test("openRename seeds the prompt with the author's current local name", () => {
|
|
430
477
|
// Arrange
|
|
431
|
-
const session =
|
|
478
|
+
const session = sessionWith([annotation("a1", { author: "SHA256:x" })]);
|
|
432
479
|
const deps = makeDeps({
|
|
433
480
|
session,
|
|
434
481
|
focusedAnnotationId: "a1",
|
|
@@ -448,7 +495,7 @@ describe("rename author", () => {
|
|
|
448
495
|
|
|
449
496
|
test("openRename on your own note does nothing but explain", () => {
|
|
450
497
|
// Arrange
|
|
451
|
-
const session =
|
|
498
|
+
const session = sessionWith([annotation("a1")]);
|
|
452
499
|
const deps = makeDeps({ session, focusedAnnotationId: "a1" });
|
|
453
500
|
|
|
454
501
|
// Act
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -526,9 +526,11 @@ function handleResizeReviewPanel(
|
|
|
526
526
|
deps.controller.saveReviewPanel({ width: next });
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
|
|
529
|
+
type IntentHandlers = {
|
|
530
530
|
[Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
|
|
531
|
-
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const intentHandlers: IntentHandlers = {
|
|
532
534
|
exit: handleExit,
|
|
533
535
|
status: handleStatus,
|
|
534
536
|
move: handleMove,
|
|
@@ -572,14 +574,14 @@ const intentHandlers: {
|
|
|
572
574
|
resizeReviewPanel: handleResizeReviewPanel,
|
|
573
575
|
};
|
|
574
576
|
|
|
577
|
+
function dispatchIntent<Kind extends Intent["type"]>(
|
|
578
|
+
intent: IntentOfType<Kind>,
|
|
579
|
+
deps: IntentDispatchDeps,
|
|
580
|
+
): void {
|
|
581
|
+
intentHandlers[intent.type](intent, deps);
|
|
582
|
+
}
|
|
583
|
+
|
|
575
584
|
/** Build the intent handler for one render from its dependency bag. */
|
|
576
585
|
export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
|
|
577
|
-
return (intent: Intent): void =>
|
|
578
|
-
const handler = intentHandlers[intent.type] as (
|
|
579
|
-
intent: Intent,
|
|
580
|
-
deps: IntentDispatchDeps,
|
|
581
|
-
) => void;
|
|
582
|
-
|
|
583
|
-
handler(intent, deps);
|
|
584
|
-
};
|
|
586
|
+
return (intent: Intent): void => dispatchIntent(intent, deps);
|
|
585
587
|
}
|