@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.77
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.identity.test.tsx +78 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +176 -5
- package/src/app-screens.tsx +13 -0
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/DiffContentView.tsx +5 -1
- package/src/components/GridTabContent.tsx +2 -0
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadView.tsx +5 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +93 -7
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +44 -1
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/share.ts +10 -2
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +14 -0
- package/src/use-annotation-surface.tsx +10 -2
- package/src/use-settings-dialog.tsx +24 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadConfig, persistIdentity } from "./config";
|
|
6
|
+
|
|
7
|
+
function withTempConfig(run: (path: string) => void): void {
|
|
8
|
+
const directory = mkdtempSync(join(tmpdir(), "cueloop-config-"));
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
run(join(directory, "config.toml"));
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(directory, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("defaults identity to a typed provider with no name", () => {
|
|
18
|
+
withTempConfig((path) => {
|
|
19
|
+
expect(loadConfig({ userConfigPath: path }).identity).toEqual({ provider: "typed" });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("persists and reloads a GitHub-synced identity", () => {
|
|
24
|
+
withTempConfig((path) => {
|
|
25
|
+
persistIdentity({ name: "Markus M", provider: "github" }, path);
|
|
26
|
+
|
|
27
|
+
expect(loadConfig({ userConfigPath: path }).identity).toEqual({
|
|
28
|
+
name: "Markus M",
|
|
29
|
+
provider: "github",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rewrites the identity block in place on a re-sync", () => {
|
|
35
|
+
withTempConfig((path) => {
|
|
36
|
+
persistIdentity({ name: "typed name", provider: "typed" }, path);
|
|
37
|
+
persistIdentity({ name: "markus", provider: "github" }, path);
|
|
38
|
+
|
|
39
|
+
expect(loadConfig({ userConfigPath: path }).identity).toEqual({
|
|
40
|
+
name: "markus",
|
|
41
|
+
provider: "github",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a display name containing a bracket survives a re-sync without corrupting the config", () => {
|
|
47
|
+
withTempConfig((path) => {
|
|
48
|
+
persistIdentity({ name: "Alice [Smith]", provider: "typed" }, path);
|
|
49
|
+
persistIdentity({ name: "Bob", provider: "github" }, path);
|
|
50
|
+
|
|
51
|
+
expect(loadConfig({ userConfigPath: path }).identity).toEqual({
|
|
52
|
+
name: "Bob",
|
|
53
|
+
provider: "github",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("repository config cannot forge the reviewer identity", () => {
|
|
59
|
+
withTempConfig((userPath) => {
|
|
60
|
+
persistIdentity({ name: "Me", provider: "typed" }, userPath);
|
|
61
|
+
const repoRoot = mkdtempSync(join(tmpdir(), "cueloop-repo-"));
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(join(repoRoot, ".cueloop"), { recursive: true });
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(repoRoot, ".cueloop", "config.toml"),
|
|
67
|
+
'[identity]\nname = "Forged"\nprovider = "github"\n',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(loadConfig({ userConfigPath: userPath, repoRoot }).identity).toEqual({
|
|
71
|
+
name: "Me",
|
|
72
|
+
provider: "typed",
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface CueloopConfig {
|
|
|
114
114
|
};
|
|
115
115
|
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
116
116
|
authors: Record<string, string>;
|
|
117
|
+
/** The local reviewer's own display name, and whether it was typed or synced from GitHub ([identity] table). */
|
|
118
|
+
identity: IdentityConfig;
|
|
117
119
|
/** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
|
|
118
120
|
actions: QuickAction[];
|
|
119
121
|
/** Directory of user-level skills surfaced in the "/" palette ([skills] path); ~/.agents/skills default. */
|
|
@@ -128,6 +130,15 @@ export interface ExperimentalConfig {
|
|
|
128
130
|
prototypePixels: boolean;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/** Where a display name came from: typed by the user, or verified from a GitHub login. */
|
|
134
|
+
export type IdentityProvider = "typed" | "github";
|
|
135
|
+
|
|
136
|
+
export interface IdentityConfig {
|
|
137
|
+
/** The reviewer's own display name; absent until they set or sync one. */
|
|
138
|
+
name?: string;
|
|
139
|
+
provider: IdentityProvider;
|
|
140
|
+
}
|
|
141
|
+
|
|
131
142
|
/** Every action in the grammar, with its default binding(s). */
|
|
132
143
|
export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
133
144
|
down: ["j", "down"],
|
|
@@ -166,6 +177,7 @@ function skillsPathFrom(
|
|
|
166
177
|
const ConfigDocumentSchema = v.object({
|
|
167
178
|
actions: v.optional(v.array(v.unknown())),
|
|
168
179
|
authors: v.optional(v.unknown()),
|
|
180
|
+
identity: v.optional(v.unknown()),
|
|
169
181
|
integrations: v.optional(v.unknown()),
|
|
170
182
|
keys: v.optional(v.unknown()),
|
|
171
183
|
theme: v.optional(v.unknown()),
|
|
@@ -174,6 +186,11 @@ const ConfigDocumentSchema = v.object({
|
|
|
174
186
|
skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
|
|
175
187
|
});
|
|
176
188
|
|
|
189
|
+
const IdentitySchema = v.object({
|
|
190
|
+
name: v.fallback(v.optional(v.string()), undefined),
|
|
191
|
+
provider: v.fallback(v.optional(v.picklist(["typed", "github"])), undefined),
|
|
192
|
+
});
|
|
193
|
+
|
|
177
194
|
const QuickActionSchema = v.object({
|
|
178
195
|
prompt: v.pipe(
|
|
179
196
|
v.string(),
|
|
@@ -275,6 +292,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
|
|
|
275
292
|
function layer(
|
|
276
293
|
base: CueloopConfig,
|
|
277
294
|
raw: v.InferOutput<typeof ConfigDocumentSchema>,
|
|
295
|
+
allowIdentity: boolean,
|
|
278
296
|
): CueloopConfig {
|
|
279
297
|
const out: CueloopConfig = {
|
|
280
298
|
keys: { ...base.keys },
|
|
@@ -282,6 +300,7 @@ function layer(
|
|
|
282
300
|
themeOverrides: { ...base.themeOverrides },
|
|
283
301
|
ui: { ...base.ui },
|
|
284
302
|
authors: { ...base.authors },
|
|
303
|
+
identity: { ...base.identity },
|
|
285
304
|
actions: [...base.actions],
|
|
286
305
|
skillsPath: base.skillsPath,
|
|
287
306
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
@@ -289,6 +308,7 @@ function layer(
|
|
|
289
308
|
};
|
|
290
309
|
const actions = parseActions(raw.actions);
|
|
291
310
|
const authors = v.safeParse(AuthorsSchema, raw.authors);
|
|
311
|
+
const identity = v.safeParse(IdentitySchema, raw.identity);
|
|
292
312
|
const keys = v.safeParse(KeysSchema, raw.keys);
|
|
293
313
|
const ui = v.safeParse(UiSchema, raw.ui);
|
|
294
314
|
const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
|
|
@@ -311,6 +331,11 @@ function layer(
|
|
|
311
331
|
out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
|
|
312
332
|
}
|
|
313
333
|
}
|
|
334
|
+
// The reviewer identity is a personal credential; a repo must never forge a verified name.
|
|
335
|
+
if (allowIdentity && identity.success) {
|
|
336
|
+
if (identity.output.name !== undefined) out.identity.name = identity.output.name;
|
|
337
|
+
if (identity.output.provider !== undefined) out.identity.provider = identity.output.provider;
|
|
338
|
+
}
|
|
314
339
|
if (ui.success) applyUi(out.ui, ui.output);
|
|
315
340
|
if (integrations.success && integrations.output.obsidian) {
|
|
316
341
|
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
@@ -336,6 +361,7 @@ export function loadConfig(
|
|
|
336
361
|
pins: [],
|
|
337
362
|
},
|
|
338
363
|
authors: {},
|
|
364
|
+
identity: { provider: "typed" },
|
|
339
365
|
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
340
366
|
skillsPath: join(homedir(), ".agents", "skills"),
|
|
341
367
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
@@ -357,7 +383,7 @@ export function loadConfig(
|
|
|
357
383
|
try {
|
|
358
384
|
const raw = parseToml(readFileSync(path, "utf8"));
|
|
359
385
|
|
|
360
|
-
config = layer(config, raw);
|
|
386
|
+
config = layer(config, raw, path === userPath);
|
|
361
387
|
const ui = v.safeParse(UiSchema, raw.ui);
|
|
362
388
|
const rawTheme = ui.success ? ui.output.theme : undefined;
|
|
363
389
|
const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
|
|
@@ -459,6 +485,23 @@ export function persistActions(actions: QuickAction[], userConfigPath?: string):
|
|
|
459
485
|
writeFileSync(path, text);
|
|
460
486
|
}
|
|
461
487
|
|
|
488
|
+
/** Persist the reviewer's own display name and its provider (`[identity]`) into the user config. */
|
|
489
|
+
export function persistIdentity(identity: IdentityConfig, userConfigPath?: string): void {
|
|
490
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
491
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
492
|
+
const nameLine = identity.name === undefined ? "" : `name = ${tomlString(identity.name)}\n`;
|
|
493
|
+
const block = `[identity]\n${nameLine}provider = ${tomlString(identity.provider)}\n`;
|
|
494
|
+
|
|
495
|
+
if (/^\[identity\]/m.test(text)) {
|
|
496
|
+
// Consume the header and its assignment lines only, so a bracket in a quoted name cannot truncate the block.
|
|
497
|
+
text = text.replace(/^\[identity\].*(?:\n(?![[\n]).*)*\n?/m, block);
|
|
498
|
+
} else {
|
|
499
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + block;
|
|
500
|
+
}
|
|
501
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
502
|
+
writeFileSync(path, text);
|
|
503
|
+
}
|
|
504
|
+
|
|
462
505
|
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
463
506
|
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
464
507
|
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveGithubIdentity } from "./github-identity";
|
|
6
|
+
|
|
7
|
+
const originalBinary = process.env.CUELOOP_GH;
|
|
8
|
+
const tempDirectories: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (originalBinary === undefined) delete process.env.CUELOOP_GH;
|
|
12
|
+
else process.env.CUELOOP_GH = originalBinary;
|
|
13
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function stubGithubCli(script: string): string {
|
|
19
|
+
const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
|
|
20
|
+
|
|
21
|
+
tempDirectories.push(directory);
|
|
22
|
+
const path = join(directory, "gh");
|
|
23
|
+
|
|
24
|
+
writeFileSync(path, `#!/bin/sh\n${script}\n`);
|
|
25
|
+
chmodSync(path, 0o755);
|
|
26
|
+
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("reads the GitHub login and name from gh", async () => {
|
|
31
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
|
|
32
|
+
|
|
33
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("drops a null name to undefined", async () => {
|
|
37
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
|
|
38
|
+
|
|
39
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to null when gh exits non-zero (logged out)", async () => {
|
|
43
|
+
process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
|
|
44
|
+
|
|
45
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("falls back to null when gh is not installed", async () => {
|
|
49
|
+
process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
|
|
50
|
+
|
|
51
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("falls back to null when gh prints an unexpected shape", async () => {
|
|
55
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
|
|
56
|
+
|
|
57
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
58
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
|
|
2
|
+
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
export interface GithubIdentity {
|
|
6
|
+
login: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const GithubUserSchema = v.object({
|
|
11
|
+
login: v.string(),
|
|
12
|
+
name: v.nullish(v.string()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** The gh binary is injectable so tests stub it without a real GitHub login. */
|
|
16
|
+
function githubCliBinary(): string {
|
|
17
|
+
return process.env.CUELOOP_GH || "gh";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read the authenticated GitHub login and name, or null when gh cannot answer. */
|
|
21
|
+
export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
|
|
22
|
+
try {
|
|
23
|
+
const githubProcess = Bun.spawn(
|
|
24
|
+
[githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
|
|
25
|
+
{ stdout: "pipe", stderr: "ignore" },
|
|
26
|
+
);
|
|
27
|
+
const [output, exitCode] = await Promise.all([
|
|
28
|
+
new Response(githubProcess.stdout).text(),
|
|
29
|
+
githubProcess.exited,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
if (exitCode !== 0) return null;
|
|
33
|
+
const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
|
|
34
|
+
|
|
35
|
+
if (!parsed.success) return null;
|
|
36
|
+
|
|
37
|
+
return { login: parsed.output.login, name: parsed.output.name ?? undefined };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -108,6 +108,7 @@ function baseController(): ReviewController {
|
|
|
108
108
|
walkLeave: mock(),
|
|
109
109
|
submit: mock(),
|
|
110
110
|
share: mock(),
|
|
111
|
+
setShareAccess: mock(),
|
|
111
112
|
treeRows: mock(() => []),
|
|
112
113
|
goToEntry: mock(),
|
|
113
114
|
branch: mock(),
|
|
@@ -143,6 +144,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
143
144
|
quickActions: [],
|
|
144
145
|
renameAuthor: mock(),
|
|
145
146
|
renameThread: mock(),
|
|
147
|
+
setLocalIdentityName: mock(),
|
|
146
148
|
liveInput: { current: "" },
|
|
147
149
|
setCursor: mock(),
|
|
148
150
|
setInboxCursor: mock(),
|
|
@@ -383,7 +385,7 @@ describe("openSubmit", () => {
|
|
|
383
385
|
});
|
|
384
386
|
|
|
385
387
|
describe("share", () => {
|
|
386
|
-
test("
|
|
388
|
+
test("opens the public/private choice overlay instead of publishing directly", () => {
|
|
387
389
|
// Arrange
|
|
388
390
|
const deps = makeDeps();
|
|
389
391
|
const dispatch = createIntentDispatch(deps);
|
|
@@ -392,7 +394,8 @@ describe("share", () => {
|
|
|
392
394
|
dispatch({ type: "share" });
|
|
393
395
|
|
|
394
396
|
// Assert
|
|
395
|
-
expect(deps.
|
|
397
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "shareChoice", index: 0 });
|
|
398
|
+
expect(deps.controller.share).not.toHaveBeenCalled();
|
|
396
399
|
});
|
|
397
400
|
});
|
|
398
401
|
|
|
@@ -547,6 +550,18 @@ describe("rename author", () => {
|
|
|
547
550
|
expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
|
|
548
551
|
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
549
552
|
});
|
|
553
|
+
|
|
554
|
+
test("confirmDialog in renameSelf mode sets the trimmed local identity name and closes", () => {
|
|
555
|
+
// Arrange
|
|
556
|
+
const deps = makeDeps({ mode: { type: "renameSelf", text: " Robin " } });
|
|
557
|
+
|
|
558
|
+
// Act
|
|
559
|
+
createIntentDispatch(deps)({ type: "confirmDialog" });
|
|
560
|
+
|
|
561
|
+
// Assert
|
|
562
|
+
expect(deps.setLocalIdentityName).toHaveBeenCalledWith("Robin");
|
|
563
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
564
|
+
});
|
|
550
565
|
});
|
|
551
566
|
|
|
552
567
|
describe("collaborator self-name", () => {
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type Mode =
|
|
|
39
39
|
| { type: "rename"; authorId: string; text: string }
|
|
40
40
|
| { type: "renameThread"; sessionId: string; text: string }
|
|
41
41
|
| { type: "nameSelf"; text: string }
|
|
42
|
+
| { type: "renameSelf"; text: string }
|
|
43
|
+
| { type: "shareChoice"; index: number }
|
|
42
44
|
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
43
45
|
|
|
44
46
|
/** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
|
|
@@ -98,6 +100,8 @@ export interface IntentDispatchDeps {
|
|
|
98
100
|
renameAuthor: (id: string, name: string) => void;
|
|
99
101
|
/** Rename a thread's title through the daemon. */
|
|
100
102
|
renameThread: (id: string, title: string) => void;
|
|
103
|
+
/** Set the local reviewer's typed display name and persist it (App-owned). */
|
|
104
|
+
setLocalIdentityName: (name: string) => void;
|
|
101
105
|
|
|
102
106
|
liveInput: MutableRefObject<string>;
|
|
103
107
|
|
|
@@ -238,6 +242,7 @@ function handleConfirmDialog(
|
|
|
238
242
|
else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
|
|
239
243
|
else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
|
|
240
244
|
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
245
|
+
else if (mode.type === "renameSelf") deps.setLocalIdentityName(mode.text.trim());
|
|
241
246
|
else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
|
|
242
247
|
deps.setMode({ type: "normal" });
|
|
243
248
|
}
|
|
@@ -381,8 +386,9 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
381
386
|
deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
382
387
|
}
|
|
383
388
|
|
|
389
|
+
// share opens the public/private choice overlay; the App owns the choice keys and publishes on pick
|
|
384
390
|
function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
|
|
385
|
-
deps.
|
|
391
|
+
deps.setMode({ type: "shareChoice", index: 0 });
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
|
package/src/keymap.ts
CHANGED
package/src/share.ts
CHANGED
|
@@ -12,7 +12,13 @@ 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 ShareAccess,
|
|
20
|
+
type Thread,
|
|
21
|
+
} from "@cueloop/schema";
|
|
16
22
|
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
17
23
|
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
18
24
|
import * as v from "valibot";
|
|
@@ -102,11 +108,13 @@ export function mergeFromShare(remote: Thread): SharedMerge {
|
|
|
102
108
|
export async function pushShare(
|
|
103
109
|
shareId: string,
|
|
104
110
|
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
111
|
+
access?: ShareAccess,
|
|
105
112
|
target: ShareTarget = {},
|
|
106
113
|
): Promise<void> {
|
|
114
|
+
const payload = access ? { shareId, annotations, access } : { shareId, annotations };
|
|
107
115
|
const { stderr, code } = await runShareSsh(
|
|
108
116
|
"cueloop-push",
|
|
109
|
-
Buffer.from(JSON.stringify(
|
|
117
|
+
Buffer.from(JSON.stringify(payload)),
|
|
110
118
|
target,
|
|
111
119
|
);
|
|
112
120
|
|
|
@@ -109,6 +109,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
|
|
|
109
109
|
|
|
110
110
|
return session;
|
|
111
111
|
}),
|
|
112
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
112
113
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
113
114
|
sessionBranch: unimplemented("sessionBranch"),
|
|
114
115
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -73,6 +73,7 @@ function fakeClient(session: Thread): SessionClient {
|
|
|
73
73
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
74
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
75
|
sessionCurate: unimplemented("sessionCurate"),
|
|
76
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
76
77
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
78
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
79
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
2
|
import { ManualClock } from "@opentui/core/testing";
|
|
3
|
-
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
3
|
+
import { SCHEMA_VERSION, type Annotation, type ShareAccess, type Thread } from "@cueloop/schema";
|
|
4
4
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
5
|
import {
|
|
6
6
|
createReviewController,
|
|
@@ -14,7 +14,11 @@ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", cop
|
|
|
14
14
|
let remote: Thread;
|
|
15
15
|
const pullShare = mock(async () => remote);
|
|
16
16
|
const pushShare = mock(
|
|
17
|
-
async (
|
|
17
|
+
async (
|
|
18
|
+
_shareId: string,
|
|
19
|
+
_annotations: Array<Omit<Annotation, "createdAt">>,
|
|
20
|
+
_access?: ShareAccess,
|
|
21
|
+
) => {},
|
|
18
22
|
);
|
|
19
23
|
|
|
20
24
|
const shareTransport: ShareTransport = {
|
|
@@ -73,6 +77,7 @@ function fakeClient(session: Thread): FakeSessionClient {
|
|
|
73
77
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
78
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
79
|
sessionCurate: unimplemented("sessionCurate"),
|
|
80
|
+
sessionSetAccess: mock(async () => session),
|
|
76
81
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
82
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
83
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -149,6 +154,35 @@ describe("share", () => {
|
|
|
149
154
|
});
|
|
150
155
|
});
|
|
151
156
|
|
|
157
|
+
describe("setShareAccess", () => {
|
|
158
|
+
test("pushes the allowlist up to the gateway when the plan is already shared", async () => {
|
|
159
|
+
// Arrange
|
|
160
|
+
pushShare.mockClear();
|
|
161
|
+
const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
controller.setShareAccess(["octocat"]);
|
|
165
|
+
await tick();
|
|
166
|
+
|
|
167
|
+
// Assert - the existing link must enforce the new access, so it rides the push
|
|
168
|
+
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
169
|
+
expect(pushShare.mock.calls[0]?.[2]).toEqual({ githubLogins: ["octocat"] });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("does not push when the plan was never shared", async () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
pushShare.mockClear();
|
|
175
|
+
const { controller } = await connectedController(sessionFixture());
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
controller.setShareAccess(["octocat"]);
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
152
186
|
describe("pullShared", () => {
|
|
153
187
|
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
154
188
|
// Arrange
|
|
@@ -82,6 +82,7 @@ function fakeClient(session: Thread) {
|
|
|
82
82
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
83
83
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
84
84
|
sessionCurate: unimplemented("sessionCurate"),
|
|
85
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
85
86
|
sessionNavigate: mock(pending),
|
|
86
87
|
sessionBranch: mock(pending),
|
|
87
88
|
sessionSwitch: mock(pending),
|
package/src/thread-controller.ts
CHANGED
|
@@ -309,6 +309,8 @@ export interface ReviewController {
|
|
|
309
309
|
submit(verdict: VerdictKind, summary: string): void;
|
|
310
310
|
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
311
311
|
share(): void;
|
|
312
|
+
/** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
|
|
313
|
+
setShareAccess(githubLogins: string[]): void;
|
|
312
314
|
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
313
315
|
treeRows(): TreeRow[];
|
|
314
316
|
/**
|
|
@@ -882,6 +884,18 @@ class Controller implements ReviewController {
|
|
|
882
884
|
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
883
885
|
}
|
|
884
886
|
|
|
887
|
+
setShareAccess(githubLogins: string[]): void {
|
|
888
|
+
const session = this.snapshot.session;
|
|
889
|
+
|
|
890
|
+
if (!session) return;
|
|
891
|
+
const access = { githubLogins };
|
|
892
|
+
const expected: Thread = { ...session, access };
|
|
893
|
+
|
|
894
|
+
this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
|
|
895
|
+
// an already-shared link must enforce the new access, so push it up to the gateway blob
|
|
896
|
+
if (session.shareId) void this.shareTransport.push(session.shareId, [], access).catch(() => {});
|
|
897
|
+
}
|
|
898
|
+
|
|
885
899
|
rejectedRows(): Set<number> {
|
|
886
900
|
this.ensureDerived();
|
|
887
901
|
if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
16
16
|
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
17
|
import { useKeyboard } from "@opentui/react";
|
|
18
|
-
import type { Thread } from "@cueloop/schema";
|
|
18
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
19
|
import type { Mark } from "./view-plan";
|
|
20
20
|
import type { QuickAction } from "./config";
|
|
21
21
|
import type { Theme } from "./theme";
|
|
@@ -104,6 +104,8 @@ export interface AnnotationSurfaceOptions {
|
|
|
104
104
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
105
105
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
106
106
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
107
|
+
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
108
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
107
109
|
leaderCombos?: readonly string[];
|
|
108
110
|
onLeaderCommand?: (key: KeyEvent) => void;
|
|
109
111
|
onExit: () => void;
|
|
@@ -179,6 +181,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
179
181
|
onAnnotate,
|
|
180
182
|
onReply,
|
|
181
183
|
onUpdateAnnotation,
|
|
184
|
+
resolveAuthorLabel,
|
|
182
185
|
leaderCombos,
|
|
183
186
|
onLeaderCommand,
|
|
184
187
|
onExit,
|
|
@@ -735,7 +738,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
735
738
|
node: editingHere ? (
|
|
736
739
|
composerNode
|
|
737
740
|
) : (
|
|
738
|
-
<CommentRow
|
|
741
|
+
<CommentRow
|
|
742
|
+
key={annotation.id}
|
|
743
|
+
annotation={annotation}
|
|
744
|
+
tokens={tokens}
|
|
745
|
+
authorLabel={resolveAuthorLabel?.(annotation)}
|
|
746
|
+
/>
|
|
739
747
|
),
|
|
740
748
|
};
|
|
741
749
|
});
|
|
@@ -83,6 +83,10 @@ export function useSettingsDialog(params: {
|
|
|
83
83
|
quickActions: QuickAction[];
|
|
84
84
|
setQuickActions: Dispatch<SetStateAction<QuickAction[]>>;
|
|
85
85
|
setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
|
|
86
|
+
identityName?: string;
|
|
87
|
+
identityProvider: "typed" | "github";
|
|
88
|
+
onSyncGithubIdentity: () => void;
|
|
89
|
+
onRenameDisplayName: () => void;
|
|
86
90
|
}): SettingsDialogModel {
|
|
87
91
|
const {
|
|
88
92
|
theme,
|
|
@@ -98,6 +102,10 @@ export function useSettingsDialog(params: {
|
|
|
98
102
|
quickActions,
|
|
99
103
|
setQuickActions,
|
|
100
104
|
setMenuDialog,
|
|
105
|
+
identityName,
|
|
106
|
+
identityProvider,
|
|
107
|
+
onSyncGithubIdentity,
|
|
108
|
+
onRenameDisplayName,
|
|
101
109
|
} = params;
|
|
102
110
|
|
|
103
111
|
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
@@ -157,6 +165,15 @@ export function useSettingsDialog(params: {
|
|
|
157
165
|
},
|
|
158
166
|
],
|
|
159
167
|
},
|
|
168
|
+
{
|
|
169
|
+
id: "account",
|
|
170
|
+
name: "Account",
|
|
171
|
+
rows: [
|
|
172
|
+
{ key: "displayName", label: "Display name", kind: "text" },
|
|
173
|
+
{ key: "identitySource", label: "Source", kind: "text" },
|
|
174
|
+
{ key: "syncGithub", label: "Sync from GitHub", kind: "text" },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
160
177
|
{
|
|
161
178
|
id: "appearance",
|
|
162
179
|
name: "Appearance",
|
|
@@ -202,6 +219,9 @@ export function useSettingsDialog(params: {
|
|
|
202
219
|
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
203
220
|
diffView: diffView === "split" ? "Split" : "Stacked",
|
|
204
221
|
theme: THEME_LABELS[themeName],
|
|
222
|
+
displayName: identityName ?? "-- not set --",
|
|
223
|
+
identitySource: identityProvider === "github" ? "GitHub" : "typed",
|
|
224
|
+
syncGithub: "enter to sync",
|
|
205
225
|
};
|
|
206
226
|
const cycleSetting = (rowKey: string): void => {
|
|
207
227
|
if (rowKey === "autoClose") {
|
|
@@ -220,6 +240,10 @@ export function useSettingsDialog(params: {
|
|
|
220
240
|
setThemeName(next);
|
|
221
241
|
setTheme(composeTheme(next, themeOverrides, appearance));
|
|
222
242
|
persistTheme(next);
|
|
243
|
+
} else if (rowKey === "syncGithub") {
|
|
244
|
+
onSyncGithubIdentity();
|
|
245
|
+
} else if (rowKey === "displayName") {
|
|
246
|
+
onRenameDisplayName();
|
|
223
247
|
}
|
|
224
248
|
};
|
|
225
249
|
|