@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79
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 +14 -13
- package/src/App.diff.test.tsx +9 -4
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +75 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.ts +0 -1
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
package/src/thread-controller.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
11
|
+
import { createStore, type StoreApi } from "zustand/vanilla";
|
|
11
12
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
12
13
|
import {
|
|
13
14
|
applyPathView,
|
|
@@ -25,12 +26,14 @@ import {
|
|
|
25
26
|
restoreLine,
|
|
26
27
|
returnPaneFor,
|
|
27
28
|
switchBranch,
|
|
29
|
+
threadShareLinks,
|
|
28
30
|
viewOfPath,
|
|
29
31
|
type Anchor,
|
|
30
32
|
type Annotation,
|
|
31
33
|
type AnnotationTarget,
|
|
32
34
|
type Artifact,
|
|
33
35
|
type DiffFileContents,
|
|
36
|
+
type ShareLink,
|
|
34
37
|
type Thread,
|
|
35
38
|
type SessionHistory,
|
|
36
39
|
type VerdictKind,
|
|
@@ -42,10 +45,12 @@ import {
|
|
|
42
45
|
publishShare,
|
|
43
46
|
pullShare,
|
|
44
47
|
pushShare,
|
|
48
|
+
revokeShare,
|
|
45
49
|
watchShare,
|
|
46
50
|
shareIdFromLine,
|
|
51
|
+
formatShareLine,
|
|
47
52
|
} from "./share";
|
|
48
|
-
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
53
|
+
import { buildDisplay, nextWorkBlock, renderedSpanToWork, type DisplayBlock } from "./view-plan";
|
|
49
54
|
import { entryTarget, treeRows, type TreeRow } from "./tree-view";
|
|
50
55
|
import {
|
|
51
56
|
diffRowBlocks,
|
|
@@ -191,8 +196,10 @@ export interface ShareTransport {
|
|
|
191
196
|
publish: typeof publishShare;
|
|
192
197
|
pull: typeof pullShare;
|
|
193
198
|
push: typeof pushShare;
|
|
199
|
+
revoke: typeof revokeShare;
|
|
194
200
|
watch: typeof watchShare;
|
|
195
201
|
parseShareId: typeof shareIdFromLine;
|
|
202
|
+
formatShareLine: typeof formatShareLine;
|
|
196
203
|
collaboratorAnnotations: typeof collaboratorAnnotations;
|
|
197
204
|
mergeFromShare: typeof mergeFromShare;
|
|
198
205
|
}
|
|
@@ -202,11 +209,20 @@ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
|
|
|
202
209
|
pull: pullShare,
|
|
203
210
|
watch: watchShare,
|
|
204
211
|
push: pushShare,
|
|
212
|
+
revoke: revokeShare,
|
|
205
213
|
parseShareId: shareIdFromLine,
|
|
214
|
+
formatShareLine,
|
|
206
215
|
collaboratorAnnotations,
|
|
207
216
|
mergeFromShare,
|
|
208
217
|
};
|
|
209
218
|
|
|
219
|
+
/** The editable fields of a share link, as the share wizard collects them. */
|
|
220
|
+
export interface NewShareLink {
|
|
221
|
+
name?: string;
|
|
222
|
+
requireAuth: boolean;
|
|
223
|
+
allowlist: string[];
|
|
224
|
+
}
|
|
225
|
+
|
|
210
226
|
export interface ReviewControllerOptions {
|
|
211
227
|
home?: string;
|
|
212
228
|
sessionId?: string;
|
|
@@ -291,6 +307,8 @@ export interface ReviewController {
|
|
|
291
307
|
restoreCuration(id: string): void;
|
|
292
308
|
/** The $EDITOR hand-off on the working copy. */
|
|
293
309
|
edit(): void;
|
|
310
|
+
/** Track an edited thread body as the working copy and re-resolve annotations against it. */
|
|
311
|
+
saveEditedBody(content: string): void;
|
|
294
312
|
/**
|
|
295
313
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
296
314
|
* `end` is an offset within `endDisplayIndex` (the same block by default).
|
|
@@ -342,10 +360,22 @@ export interface ReviewController {
|
|
|
342
360
|
walkLeave(): void;
|
|
343
361
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
344
362
|
submit(verdict: VerdictKind, summary: string): void;
|
|
345
|
-
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
363
|
+
/** Publish the current session as a public share link; the ssh line lands on the clipboard. */
|
|
346
364
|
share(): void;
|
|
365
|
+
/** Stop sharing the current session, revoking every link at the gateway. Owner only. */
|
|
366
|
+
unshare(): void;
|
|
347
367
|
/** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
|
|
348
368
|
setShareAccess(githubLogins: string[]): void;
|
|
369
|
+
/** The current thread's share links (a legacy single share migrates to a one-element list). */
|
|
370
|
+
shareLinks(): ShareLink[];
|
|
371
|
+
/** Publish a new share link with the given name and auth; the ssh line lands on the clipboard. Owner only. */
|
|
372
|
+
createShareLink(input: NewShareLink): void;
|
|
373
|
+
/** Update a link's name and auth in place, re-pushing its access to the gateway. Owner only. */
|
|
374
|
+
updateShareLink(id: string, input: NewShareLink): void;
|
|
375
|
+
/** Revoke a link and remove it from the thread. Owner only. */
|
|
376
|
+
deleteShareLink(id: string): void;
|
|
377
|
+
/** Copy a link's ssh line to the clipboard. */
|
|
378
|
+
copyShareLink(id: string): void;
|
|
349
379
|
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
350
380
|
treeRows(): TreeRow[];
|
|
351
381
|
/**
|
|
@@ -406,7 +436,8 @@ class Controller implements ReviewController {
|
|
|
406
436
|
readonly readOnly: boolean;
|
|
407
437
|
private client: SessionClient | null = null;
|
|
408
438
|
private closed = false;
|
|
409
|
-
|
|
439
|
+
/** The controller's state lives in a zustand store; internal reads go through the snapshot getter. */
|
|
440
|
+
private readonly store: StoreApi<ControllerSnapshot> = createStore<ControllerSnapshot>(() => ({
|
|
410
441
|
session: null,
|
|
411
442
|
inbox: null,
|
|
412
443
|
status: "",
|
|
@@ -415,8 +446,10 @@ class Controller implements ReviewController {
|
|
|
415
446
|
completion: { phase: "idle" },
|
|
416
447
|
editOrphanCount: 0,
|
|
417
448
|
walk: null,
|
|
418
|
-
};
|
|
419
|
-
private
|
|
449
|
+
}));
|
|
450
|
+
private get snapshot(): ControllerSnapshot {
|
|
451
|
+
return this.store.getState();
|
|
452
|
+
}
|
|
420
453
|
private autoClose: AutoClose = "off";
|
|
421
454
|
private quickActions: QuickAction[] = [];
|
|
422
455
|
private editor: string | undefined;
|
|
@@ -442,6 +475,8 @@ class Controller implements ReviewController {
|
|
|
442
475
|
};
|
|
443
476
|
/** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
|
|
444
477
|
private derivedCache = new Map<string, DerivedCacheEntry>();
|
|
478
|
+
/** The thread the view is on now; a revalidation for a since-abandoned thread is discarded. */
|
|
479
|
+
private viewingId: string | undefined;
|
|
445
480
|
/** File paths whose diff body is folded to just the file band. */
|
|
446
481
|
private collapsedFiles = new Set<string>();
|
|
447
482
|
/** File paths woven to their full contents (unchanged lines shown as context). */
|
|
@@ -457,17 +492,12 @@ class Controller implements ReviewController {
|
|
|
457
492
|
this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
|
|
458
493
|
}
|
|
459
494
|
|
|
460
|
-
subscribe = (listener: () => void): (() => void) =>
|
|
461
|
-
this.listeners.add(listener);
|
|
495
|
+
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener);
|
|
462
496
|
|
|
463
|
-
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
497
|
+
getSnapshot = (): ControllerSnapshot => this.store.getState();
|
|
467
498
|
|
|
468
499
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
469
|
-
this.
|
|
470
|
-
for (const listener of this.listeners) listener();
|
|
500
|
+
this.store.setState(this.freezeServed(patch));
|
|
471
501
|
}
|
|
472
502
|
|
|
473
503
|
/** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
|
|
@@ -503,6 +533,7 @@ class Controller implements ReviewController {
|
|
|
503
533
|
const inbox = await client.sessionList({ status: "pending" });
|
|
504
534
|
|
|
505
535
|
if (this.options.sessionId) {
|
|
536
|
+
this.viewingId = this.options.sessionId;
|
|
506
537
|
this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
|
|
507
538
|
} else {
|
|
508
539
|
this.update({ inbox });
|
|
@@ -811,7 +842,11 @@ class Controller implements ReviewController {
|
|
|
811
842
|
// surface that as an unhandled rejection.
|
|
812
843
|
private async refreshSession(id: string): Promise<void> {
|
|
813
844
|
try {
|
|
814
|
-
if (this.client)
|
|
845
|
+
if (!this.client) return;
|
|
846
|
+
const session = await this.client.sessionGet(id);
|
|
847
|
+
|
|
848
|
+
// a rapid switch may have moved on during the fetch; never clobber the now-viewed thread
|
|
849
|
+
if (this.viewingId === session.id) this.update({ session });
|
|
815
850
|
} catch (cause) {
|
|
816
851
|
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
817
852
|
}
|
|
@@ -853,22 +888,42 @@ class Controller implements ReviewController {
|
|
|
853
888
|
// ── primitives ───────────────────────────────────
|
|
854
889
|
open(id: string): void {
|
|
855
890
|
this.locallyViewed.clear();
|
|
891
|
+
this.viewingId = id;
|
|
856
892
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
857
893
|
|
|
894
|
+
// paint the sidebar copy at once for a snappy switch, then revalidate: the inbox
|
|
895
|
+
// copy trails the daemon for a live diff, whose session.updated refreshes the open
|
|
896
|
+
// thread but not the list, so a returned-to diff would otherwise show stale content
|
|
858
897
|
if (cached) this.update({ session: cached });
|
|
859
|
-
|
|
898
|
+
void this.refreshSession(id);
|
|
860
899
|
}
|
|
861
900
|
|
|
862
901
|
deleteSession(id: string): void {
|
|
863
902
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
903
|
+
// a deleted thread's links must not outlive it; revoke every one first, best-effort, so an
|
|
904
|
+
// unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
|
|
905
|
+
for (const link of this.linksFor(this.sessionRecord(id)))
|
|
906
|
+
void this.shareTransport.revoke(link.id).catch(() => {});
|
|
864
907
|
this.client
|
|
865
908
|
?.sessionDelete(id)
|
|
866
|
-
.then(() => this.setStatus("
|
|
909
|
+
.then(() => this.setStatus("thread deleted"))
|
|
867
910
|
.catch((cause: unknown) =>
|
|
868
911
|
this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
869
912
|
);
|
|
870
913
|
}
|
|
871
914
|
|
|
915
|
+
/** The record for a thread id, whether it is the open thread or a sidebar row. */
|
|
916
|
+
private sessionRecord(id: string): Thread | undefined {
|
|
917
|
+
if (this.snapshot.session?.id === id) return this.snapshot.session;
|
|
918
|
+
|
|
919
|
+
return this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** A thread's share links, migrating a legacy single share; empty when never shared. */
|
|
923
|
+
private linksFor(session: Thread | null | undefined): ShareLink[] {
|
|
924
|
+
return session ? (threadShareLinks(session) ?? []) : [];
|
|
925
|
+
}
|
|
926
|
+
|
|
872
927
|
renameSession(id: string, title: string): void {
|
|
873
928
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
874
929
|
this.client
|
|
@@ -1019,15 +1074,16 @@ class Controller implements ReviewController {
|
|
|
1019
1074
|
}
|
|
1020
1075
|
|
|
1021
1076
|
setShareAccess(githubLogins: string[]): void {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1077
|
+
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
1078
|
+
// legacy single-allowlist entry point: apply it to the thread's primary link
|
|
1079
|
+
const primary = this.linksFor(this.snapshot.session)[0];
|
|
1080
|
+
|
|
1081
|
+
if (!primary) return;
|
|
1082
|
+
this.updateShareLink(primary.id, {
|
|
1083
|
+
name: primary.name,
|
|
1084
|
+
requireAuth: true,
|
|
1085
|
+
allowlist: githubLogins,
|
|
1086
|
+
});
|
|
1031
1087
|
}
|
|
1032
1088
|
|
|
1033
1089
|
rejectedRows(): Set<number> {
|
|
@@ -1142,8 +1198,7 @@ class Controller implements ReviewController {
|
|
|
1142
1198
|
const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
|
|
1143
1199
|
|
|
1144
1200
|
if (result.changed) {
|
|
1145
|
-
this.
|
|
1146
|
-
this.reconcileAnnotations(session, result.content);
|
|
1201
|
+
this.saveEditedBody(result.content);
|
|
1147
1202
|
this.setStatus("edits tracked - one diff");
|
|
1148
1203
|
} else this.setStatus("no changes");
|
|
1149
1204
|
} catch (err) {
|
|
@@ -1151,6 +1206,16 @@ class Controller implements ReviewController {
|
|
|
1151
1206
|
}
|
|
1152
1207
|
}
|
|
1153
1208
|
|
|
1209
|
+
// no toast: leaving the inline editor is a quiet return to the read-only view
|
|
1210
|
+
saveEditedBody(content: string): void {
|
|
1211
|
+
const session = this.snapshot.session;
|
|
1212
|
+
|
|
1213
|
+
if (!session || session.status === "resolved") return;
|
|
1214
|
+
if (content === this.working()) return;
|
|
1215
|
+
this.setWorkingCopy(content);
|
|
1216
|
+
this.reconcileAnnotations(session, content);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1154
1219
|
/**
|
|
1155
1220
|
* Edit-exit reconciliation: re-resolve every annotation against the edited
|
|
1156
1221
|
* working copy with the quote-primary cascade. Annotations that stop
|
|
@@ -1212,12 +1277,15 @@ class Controller implements ReviewController {
|
|
|
1212
1277
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
1213
1278
|
const workIndexOf = (index: number): number =>
|
|
1214
1279
|
display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
|
|
1280
|
+
// the plan surface hands back rendered offsets (inline markers concealed); the quote is cut
|
|
1281
|
+
// from work text, so map the selection onto the work characters it covers first
|
|
1282
|
+
const work = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
|
|
1215
1283
|
|
|
1216
1284
|
anchor = makeAnchor(
|
|
1217
1285
|
workBlocks,
|
|
1218
1286
|
workIndexOf(displayIndex),
|
|
1219
|
-
start,
|
|
1220
|
-
end,
|
|
1287
|
+
work.start,
|
|
1288
|
+
work.end,
|
|
1221
1289
|
workIndexOf(endDisplayIndex),
|
|
1222
1290
|
);
|
|
1223
1291
|
}
|
|
@@ -1359,12 +1427,16 @@ class Controller implements ReviewController {
|
|
|
1359
1427
|
annotation: Omit<Annotation, "createdAt">,
|
|
1360
1428
|
): void {
|
|
1361
1429
|
const session = this.snapshot.session;
|
|
1362
|
-
const
|
|
1430
|
+
const branch = session?.history?.branch;
|
|
1431
|
+
// each link follows one branch: a note lands on the links whose branch is the one being edited
|
|
1432
|
+
const links = this.linksFor(session).filter(
|
|
1433
|
+
(link) => branch === undefined || branch === (link.shareBranch ?? MAIN_BRANCH),
|
|
1434
|
+
);
|
|
1363
1435
|
|
|
1364
|
-
if (
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1436
|
+
if (links.length === 0) return;
|
|
1437
|
+
void persisted
|
|
1438
|
+
.then(() => Promise.all(links.map((link) => this.shareTransport.push(link.id, [annotation]))))
|
|
1439
|
+
.catch(() => {});
|
|
1368
1440
|
}
|
|
1369
1441
|
|
|
1370
1442
|
removeAnnotation(id: string): void {
|
|
@@ -1479,25 +1551,118 @@ class Controller implements ReviewController {
|
|
|
1479
1551
|
}
|
|
1480
1552
|
|
|
1481
1553
|
share(): void {
|
|
1554
|
+
this.createShareLink({ requireAuth: false, allowlist: [] });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
shareLinks(): ShareLink[] {
|
|
1558
|
+
return this.linksFor(this.snapshot.session);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
createShareLink(input: NewShareLink): void {
|
|
1562
|
+
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
1482
1563
|
const session = this.snapshot.session;
|
|
1483
1564
|
|
|
1484
|
-
if (!session) return;
|
|
1565
|
+
if (!session || !this.client) return;
|
|
1566
|
+
const client = this.client;
|
|
1567
|
+
const shareBranch = session.history?.branch ?? MAIN_BRANCH;
|
|
1568
|
+
|
|
1485
1569
|
this.setStatus("sharing…");
|
|
1486
1570
|
this.freezeForShare(session)
|
|
1487
|
-
.then((shared) =>
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
const shareId = this.shareTransport.parseShareId(line);
|
|
1571
|
+
.then((shared) => {
|
|
1572
|
+
// bake this link's access into its own blob: an allowlist gates it, absent = public
|
|
1573
|
+
const access = input.requireAuth ? { githubLogins: input.allowlist } : undefined;
|
|
1491
1574
|
|
|
1492
|
-
|
|
1575
|
+
return this.shareTransport.publish({ ...shared, access, shareBranch });
|
|
1576
|
+
})
|
|
1577
|
+
.then(async ({ line, copied }) => {
|
|
1578
|
+
const id = this.shareTransport.parseShareId(line);
|
|
1579
|
+
|
|
1580
|
+
if (!id) throw new Error("gateway returned no share id");
|
|
1581
|
+
const link: ShareLink = {
|
|
1582
|
+
id,
|
|
1583
|
+
name: input.name?.trim() || undefined,
|
|
1584
|
+
requireAuth: input.requireAuth,
|
|
1585
|
+
allowlist: input.requireAuth ? input.allowlist : [],
|
|
1586
|
+
shareBranch,
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
await client.sessionSetShares(session.id, [...this.linksFor(this.snapshot.session), link]);
|
|
1493
1590
|
this.setStatus("");
|
|
1494
|
-
this.showToast(line, copied ? "
|
|
1591
|
+
this.showToast(line, copied ? "link copied" : "link created");
|
|
1495
1592
|
})
|
|
1496
1593
|
.catch((cause: unknown) =>
|
|
1497
1594
|
this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
1498
1595
|
);
|
|
1499
1596
|
}
|
|
1500
1597
|
|
|
1598
|
+
updateShareLink(id: string, input: NewShareLink): void {
|
|
1599
|
+
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
1600
|
+
const session = this.snapshot.session;
|
|
1601
|
+
|
|
1602
|
+
if (!session || !this.client) return;
|
|
1603
|
+
const links = this.linksFor(session);
|
|
1604
|
+
|
|
1605
|
+
if (!links.some((link) => link.id === id)) return;
|
|
1606
|
+
const allowlist = input.requireAuth ? input.allowlist : [];
|
|
1607
|
+
// re-push the link's access to its blob: an allowlist for private, "public" clears it
|
|
1608
|
+
void this.shareTransport
|
|
1609
|
+
.push(id, [], input.requireAuth ? { githubLogins: allowlist } : "public")
|
|
1610
|
+
.catch(() => {});
|
|
1611
|
+
const next = links.map((link) =>
|
|
1612
|
+
link.id === id
|
|
1613
|
+
? {
|
|
1614
|
+
...link,
|
|
1615
|
+
name: input.name?.trim() || undefined,
|
|
1616
|
+
requireAuth: input.requireAuth,
|
|
1617
|
+
allowlist,
|
|
1618
|
+
}
|
|
1619
|
+
: link,
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
this.applyOptimistic(
|
|
1623
|
+
{ ...session, shares: next },
|
|
1624
|
+
this.client.sessionSetShares(session.id, next),
|
|
1625
|
+
);
|
|
1626
|
+
this.setStatus("link updated");
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
deleteShareLink(id: string): void {
|
|
1630
|
+
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
1631
|
+
const session = this.snapshot.session;
|
|
1632
|
+
|
|
1633
|
+
if (!session || !this.client) return;
|
|
1634
|
+
const remaining = this.linksFor(session).filter((link) => link.id !== id);
|
|
1635
|
+
|
|
1636
|
+
void this.shareTransport.revoke(id).catch(() => {});
|
|
1637
|
+
this.applyOptimistic(
|
|
1638
|
+
{ ...session, shares: remaining },
|
|
1639
|
+
this.client.sessionSetShares(session.id, remaining),
|
|
1640
|
+
);
|
|
1641
|
+
this.setStatus("link revoked");
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
copyShareLink(id: string): void {
|
|
1645
|
+
const line = this.shareTransport.formatShareLine(id);
|
|
1646
|
+
|
|
1647
|
+
void copyToClipboard(line).then((copied) =>
|
|
1648
|
+
this.showToast(line, copied ? "link copied" : "link"),
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** Stop sharing the open thread: revoke every link so none of them resolve. */
|
|
1653
|
+
unshare(): void {
|
|
1654
|
+
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
1655
|
+
const session = this.snapshot.session;
|
|
1656
|
+
const links = this.linksFor(session);
|
|
1657
|
+
|
|
1658
|
+
if (!session || links.length === 0) return this.setStatus("this thread is not shared");
|
|
1659
|
+
this.stopShareSync();
|
|
1660
|
+
void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})))
|
|
1661
|
+
.then(() => this.setStatus("sharing stopped"))
|
|
1662
|
+
.catch(() => {});
|
|
1663
|
+
void this.client?.sessionSetShares(session.id, []).catch(() => {});
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1501
1666
|
goToEntry(entryId: string, summary?: string): void {
|
|
1502
1667
|
const session = this.snapshot.session;
|
|
1503
1668
|
|
|
@@ -1581,11 +1746,15 @@ class Controller implements ReviewController {
|
|
|
1581
1746
|
client
|
|
1582
1747
|
.sessionFork(session.id)
|
|
1583
1748
|
.then(async (fork) => {
|
|
1749
|
+
const shareBranch = fork.history?.branch ?? MAIN_BRANCH;
|
|
1584
1750
|
const shared = await this.freezeForShare(fork);
|
|
1585
|
-
const { line, copied } = await this.shareTransport.publish(shared);
|
|
1586
|
-
const
|
|
1751
|
+
const { line, copied } = await this.shareTransport.publish({ ...shared, shareBranch });
|
|
1752
|
+
const id = this.shareTransport.parseShareId(line);
|
|
1587
1753
|
|
|
1588
|
-
if (
|
|
1754
|
+
if (id)
|
|
1755
|
+
await client.sessionSetShares(fork.id, [
|
|
1756
|
+
{ id, requireAuth: false, allowlist: [], shareBranch },
|
|
1757
|
+
]);
|
|
1589
1758
|
this.setStatus("");
|
|
1590
1759
|
this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
|
|
1591
1760
|
})
|
|
@@ -1616,14 +1785,19 @@ class Controller implements ReviewController {
|
|
|
1616
1785
|
* event path. Best-effort: a failed refresh is silent.
|
|
1617
1786
|
*/
|
|
1618
1787
|
pullShared(): Promise<void> {
|
|
1619
|
-
const
|
|
1788
|
+
const links = this.linksFor(this.snapshot.session);
|
|
1620
1789
|
|
|
1621
|
-
if (
|
|
1790
|
+
if (links.length === 0) return Promise.resolve();
|
|
1622
1791
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
.
|
|
1626
|
-
|
|
1792
|
+
// pull every link's blob and union its collaborator notes back in
|
|
1793
|
+
return Promise.all(
|
|
1794
|
+
links.map((link) =>
|
|
1795
|
+
this.shareTransport
|
|
1796
|
+
.pull(link.id)
|
|
1797
|
+
.then((remote) => this.mergeShared(remote))
|
|
1798
|
+
.catch(() => {}),
|
|
1799
|
+
),
|
|
1800
|
+
).then(() => {});
|
|
1627
1801
|
}
|
|
1628
1802
|
|
|
1629
1803
|
private mergeShared(remote: Thread): Promise<void> {
|
|
@@ -1638,9 +1812,10 @@ class Controller implements ReviewController {
|
|
|
1638
1812
|
}
|
|
1639
1813
|
|
|
1640
1814
|
/**
|
|
1641
|
-
* Follow the
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1815
|
+
* Follow the thread's shares live while it is open. To bound idle cost at one
|
|
1816
|
+
* connection per open thread (not one per link), a single watch stream follows
|
|
1817
|
+
* the primary link for instant updates, while a pull on each (re)connect
|
|
1818
|
+
* catches up every link's collaborator notes. A dropped link reconnects with
|
|
1644
1819
|
* backoff. Returns a stop handle the caller runs on leave.
|
|
1645
1820
|
*/
|
|
1646
1821
|
startShareSync(): () => void {
|
|
@@ -1651,7 +1826,7 @@ class Controller implements ReviewController {
|
|
|
1651
1826
|
|
|
1652
1827
|
this.shareRun = run;
|
|
1653
1828
|
const connect = (): void => {
|
|
1654
|
-
const shareId = this.snapshot.session?.
|
|
1829
|
+
const shareId = this.linksFor(this.snapshot.session)[0]?.id;
|
|
1655
1830
|
|
|
1656
1831
|
if (this.shareRun !== run || this.closed || !shareId) return;
|
|
1657
1832
|
void this.pullShared();
|
|
@@ -37,7 +37,6 @@ import {
|
|
|
37
37
|
} from "./thread-selection";
|
|
38
38
|
import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
|
|
39
39
|
import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
|
|
40
|
-
import { matchesLeader } from "./thread-chords";
|
|
41
40
|
import {
|
|
42
41
|
activeSlashToken,
|
|
43
42
|
insertSlashItem,
|
|
@@ -107,8 +106,8 @@ export interface AnnotationSurfaceOptions {
|
|
|
107
106
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
108
107
|
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
109
108
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
/** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
|
|
110
|
+
onNavCommand?: (key: KeyEvent) => boolean;
|
|
112
111
|
onExit: () => void;
|
|
113
112
|
}
|
|
114
113
|
|
|
@@ -119,6 +118,8 @@ export interface AnnotationSurface {
|
|
|
119
118
|
cursor: number;
|
|
120
119
|
head: TextPosition;
|
|
121
120
|
compose: ComposeState | null;
|
|
121
|
+
/** True while the surface is in nav mode: bare keys act as commands, typing does not compose. */
|
|
122
|
+
navMode: boolean;
|
|
122
123
|
focusedDiscussion: string | null;
|
|
123
124
|
/** The block to keep in view: an opening card, a focused discussion, else the caret. */
|
|
124
125
|
revealBlockIndex: number;
|
|
@@ -183,8 +184,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
183
184
|
onReply,
|
|
184
185
|
onUpdateAnnotation,
|
|
185
186
|
resolveAuthorLabel,
|
|
186
|
-
|
|
187
|
-
onLeaderCommand,
|
|
187
|
+
onNavCommand,
|
|
188
188
|
onExit,
|
|
189
189
|
} = options;
|
|
190
190
|
const palette = annotationPaletteFor(tokens);
|
|
@@ -199,7 +199,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
199
199
|
return { head: start, anchor: start };
|
|
200
200
|
});
|
|
201
201
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
202
|
-
const
|
|
202
|
+
const [navMode, setNavModeState] = useState(false);
|
|
203
|
+
const navModeRef = useRef(false);
|
|
204
|
+
const setNavMode = (value: boolean): void => {
|
|
205
|
+
navModeRef.current = value;
|
|
206
|
+
setNavModeState(value);
|
|
207
|
+
};
|
|
203
208
|
|
|
204
209
|
useEffect(() => {
|
|
205
210
|
onComposingChange?.(compose !== null);
|
|
@@ -514,7 +519,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
514
519
|
if (!composerReady.current) handlePremountKey(key, activeCompose);
|
|
515
520
|
};
|
|
516
521
|
|
|
517
|
-
/**
|
|
522
|
+
/** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
|
|
523
|
+
const toggleFoldAtCursor = (): void => {
|
|
524
|
+
setFolded((current) => {
|
|
525
|
+
const next = new Set(current);
|
|
526
|
+
|
|
527
|
+
for (const discussion of discussions) {
|
|
528
|
+
if (discussion.blockIndex !== cursor) continue;
|
|
529
|
+
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
530
|
+
else next.add(discussion.key);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return next;
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
|
|
518
538
|
const handleDiscussionVerb = (key: KeyEvent): boolean => {
|
|
519
539
|
// comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
|
|
520
540
|
if (key.name === "m" && (key.super || key.meta || key.option)) {
|
|
@@ -538,21 +558,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
538
558
|
|
|
539
559
|
return true;
|
|
540
560
|
}
|
|
541
|
-
if (key.name === "tab") {
|
|
542
|
-
setFolded((current) => {
|
|
543
|
-
const next = new Set(current);
|
|
544
|
-
|
|
545
|
-
for (const discussion of discussions) {
|
|
546
|
-
if (discussion.blockIndex !== cursor) continue;
|
|
547
|
-
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
548
|
-
else next.add(discussion.key);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
return next;
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
return true;
|
|
555
|
-
}
|
|
556
561
|
// enter replies into the focused discussion, else the cursor block's discussion
|
|
557
562
|
if (key.name === "return") {
|
|
558
563
|
const replyTarget =
|
|
@@ -668,23 +673,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
668
673
|
|
|
669
674
|
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
670
675
|
if (key.name === "escape") {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
if (focusedDiscussion !== null) return setFocusedDiscussion(null);
|
|
676
|
+
// from type mode esc only enters nav, so a held mark survives for `c`
|
|
677
|
+
if (!navModeRef.current) return setNavMode(true);
|
|
678
|
+
if (focusedDiscussion !== null) setFocusedDiscussion(null);
|
|
677
679
|
|
|
678
680
|
return collapseCaret();
|
|
679
681
|
}
|
|
680
|
-
if (
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
682
|
+
if (navModeRef.current) {
|
|
683
|
+
if (handleCaretKey(key)) return;
|
|
684
|
+
if (key.name === "z") return toggleFoldAtCursor();
|
|
685
|
+
if (onNavCommand?.(key)) return;
|
|
686
|
+
if (key.name === "c") {
|
|
687
|
+
setNavMode(false);
|
|
688
|
+
|
|
689
|
+
return openNewCompose("");
|
|
690
|
+
}
|
|
691
|
+
if (printableSequence(key)) {
|
|
692
|
+
setNavMode(false);
|
|
693
|
+
startTyping(key);
|
|
694
|
+
}
|
|
688
695
|
|
|
689
696
|
return;
|
|
690
697
|
}
|
|
@@ -865,6 +872,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
865
872
|
const pressed = positionAt(allGeometry(), event.x, event.y);
|
|
866
873
|
|
|
867
874
|
if (!pressed || !source.annotatable(pressed.blockIndex)) return;
|
|
875
|
+
setNavMode(false);
|
|
868
876
|
const stamp = { time: Date.now(), x: event.x, y: event.y };
|
|
869
877
|
const wordMode = isDoubleClick(lastClick.current, stamp);
|
|
870
878
|
|
|
@@ -892,6 +900,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
892
900
|
cursor,
|
|
893
901
|
head,
|
|
894
902
|
compose,
|
|
903
|
+
navMode,
|
|
895
904
|
focusedDiscussion,
|
|
896
905
|
revealBlockIndex,
|
|
897
906
|
spanQuote,
|