@cueloop/client 0.1.0-alpha.77 → 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.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- 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 +91 -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 +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- 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/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- 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/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- 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/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- 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.test.ts +2 -2
- package/src/keymap.ts +0 -2
- 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/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- 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.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- 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 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The thread controller: every daemon round-trip and mutation
|
|
3
3
|
* primitive behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
5
|
* primitives (cut/edit/annotate/submit/...), and the post-submit completion
|
|
@@ -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,
|
|
@@ -132,6 +137,41 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
|
132
137
|
return `plan:${base.lineStart}-${base.lineEnd}`;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Whether two records of the same thread yield the same document projection (display, rows, files,
|
|
142
|
+
* models). Those derive only from the artifact content and the working copy, so an update that
|
|
143
|
+
* touched only annotations, status, or the verdict leaves them identical - the projection can be
|
|
144
|
+
* reused rather than re-parsed. Content and working copy are strings, compared here by value.
|
|
145
|
+
*/
|
|
146
|
+
export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
|
|
147
|
+
return (
|
|
148
|
+
previous.id === next.id &&
|
|
149
|
+
previous.artifact.content === next.artifact.content &&
|
|
150
|
+
previous.workingCopy === next.workingCopy
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The session with `annotation` upserted, for an optimistic paint the instant the composer closes
|
|
156
|
+
* rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
|
|
157
|
+
* lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
|
|
158
|
+
* replaces this guess when the write returns.
|
|
159
|
+
*/
|
|
160
|
+
export function withAnnotationUpserted(
|
|
161
|
+
session: Thread,
|
|
162
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
163
|
+
): Thread {
|
|
164
|
+
const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
|
|
165
|
+
const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
|
|
166
|
+
const optimistic: Annotation = { ...annotation, createdAt };
|
|
167
|
+
const annotations =
|
|
168
|
+
index >= 0
|
|
169
|
+
? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
|
|
170
|
+
: [...session.annotations, optimistic];
|
|
171
|
+
|
|
172
|
+
return { ...session, annotations };
|
|
173
|
+
}
|
|
174
|
+
|
|
135
175
|
export interface ControllerSnapshot {
|
|
136
176
|
session: Thread | null;
|
|
137
177
|
inbox: Thread[] | null;
|
|
@@ -156,8 +196,10 @@ export interface ShareTransport {
|
|
|
156
196
|
publish: typeof publishShare;
|
|
157
197
|
pull: typeof pullShare;
|
|
158
198
|
push: typeof pushShare;
|
|
199
|
+
revoke: typeof revokeShare;
|
|
159
200
|
watch: typeof watchShare;
|
|
160
201
|
parseShareId: typeof shareIdFromLine;
|
|
202
|
+
formatShareLine: typeof formatShareLine;
|
|
161
203
|
collaboratorAnnotations: typeof collaboratorAnnotations;
|
|
162
204
|
mergeFromShare: typeof mergeFromShare;
|
|
163
205
|
}
|
|
@@ -167,11 +209,20 @@ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
|
|
|
167
209
|
pull: pullShare,
|
|
168
210
|
watch: watchShare,
|
|
169
211
|
push: pushShare,
|
|
212
|
+
revoke: revokeShare,
|
|
170
213
|
parseShareId: shareIdFromLine,
|
|
214
|
+
formatShareLine,
|
|
171
215
|
collaboratorAnnotations,
|
|
172
216
|
mergeFromShare,
|
|
173
217
|
};
|
|
174
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
|
+
|
|
175
226
|
export interface ReviewControllerOptions {
|
|
176
227
|
home?: string;
|
|
177
228
|
sessionId?: string;
|
|
@@ -256,6 +307,8 @@ export interface ReviewController {
|
|
|
256
307
|
restoreCuration(id: string): void;
|
|
257
308
|
/** The $EDITOR hand-off on the working copy. */
|
|
258
309
|
edit(): void;
|
|
310
|
+
/** Track an edited thread body as the working copy and re-resolve annotations against it. */
|
|
311
|
+
saveEditedBody(content: string): void;
|
|
259
312
|
/**
|
|
260
313
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
261
314
|
* `end` is an offset within `endDisplayIndex` (the same block by default).
|
|
@@ -307,10 +360,22 @@ export interface ReviewController {
|
|
|
307
360
|
walkLeave(): void;
|
|
308
361
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
309
362
|
submit(verdict: VerdictKind, summary: string): void;
|
|
310
|
-
/** 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. */
|
|
311
364
|
share(): void;
|
|
365
|
+
/** Stop sharing the current session, revoking every link at the gateway. Owner only. */
|
|
366
|
+
unshare(): void;
|
|
312
367
|
/** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
|
|
313
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;
|
|
314
379
|
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
315
380
|
treeRows(): TreeRow[];
|
|
316
381
|
/**
|
|
@@ -345,6 +410,9 @@ interface DerivedSessionProjection {
|
|
|
345
410
|
display: DisplayBlock[];
|
|
346
411
|
rows: DiffRow[];
|
|
347
412
|
files: WalkFile[];
|
|
413
|
+
/** Raw per-file contents; parsed into a model lazily on first curation touch, not all upfront. */
|
|
414
|
+
fileContents: Map<string, DiffFileContents>;
|
|
415
|
+
/** Parsed file models, filled on demand from fileContents and memoized here. */
|
|
348
416
|
models: Map<string, FileDiffMetadata>;
|
|
349
417
|
tree: TreeRow[];
|
|
350
418
|
}
|
|
@@ -355,11 +423,21 @@ interface LiveWorkingDiff {
|
|
|
355
423
|
files: readonly DiffFileContents[];
|
|
356
424
|
}
|
|
357
425
|
|
|
426
|
+
interface DerivedCacheEntry {
|
|
427
|
+
derivedFor: Thread;
|
|
428
|
+
derivedForLiveDiff: LiveWorkingDiff | null;
|
|
429
|
+
derived: DerivedSessionProjection;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
|
|
433
|
+
const DERIVED_CACHE_LIMIT = 8;
|
|
434
|
+
|
|
358
435
|
class Controller implements ReviewController {
|
|
359
436
|
readonly readOnly: boolean;
|
|
360
437
|
private client: SessionClient | null = null;
|
|
361
438
|
private closed = false;
|
|
362
|
-
|
|
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>(() => ({
|
|
363
441
|
session: null,
|
|
364
442
|
inbox: null,
|
|
365
443
|
status: "",
|
|
@@ -368,8 +446,10 @@ class Controller implements ReviewController {
|
|
|
368
446
|
completion: { phase: "idle" },
|
|
369
447
|
editOrphanCount: 0,
|
|
370
448
|
walk: null,
|
|
371
|
-
};
|
|
372
|
-
private
|
|
449
|
+
}));
|
|
450
|
+
private get snapshot(): ControllerSnapshot {
|
|
451
|
+
return this.store.getState();
|
|
452
|
+
}
|
|
373
453
|
private autoClose: AutoClose = "off";
|
|
374
454
|
private quickActions: QuickAction[] = [];
|
|
375
455
|
private editor: string | undefined;
|
|
@@ -389,9 +469,14 @@ class Controller implements ReviewController {
|
|
|
389
469
|
display: [],
|
|
390
470
|
rows: [],
|
|
391
471
|
files: [],
|
|
472
|
+
fileContents: new Map(),
|
|
392
473
|
models: new Map(),
|
|
393
474
|
tree: [],
|
|
394
475
|
};
|
|
476
|
+
/** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
|
|
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;
|
|
395
480
|
/** File paths whose diff body is folded to just the file band. */
|
|
396
481
|
private collapsedFiles = new Set<string>();
|
|
397
482
|
/** File paths woven to their full contents (unchanged lines shown as context). */
|
|
@@ -407,17 +492,12 @@ class Controller implements ReviewController {
|
|
|
407
492
|
this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
|
|
408
493
|
}
|
|
409
494
|
|
|
410
|
-
subscribe = (listener: () => void): (() => void) =>
|
|
411
|
-
this.listeners.add(listener);
|
|
495
|
+
subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener);
|
|
412
496
|
|
|
413
|
-
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
497
|
+
getSnapshot = (): ControllerSnapshot => this.store.getState();
|
|
417
498
|
|
|
418
499
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
419
|
-
this.
|
|
420
|
-
for (const listener of this.listeners) listener();
|
|
500
|
+
this.store.setState(this.freezeServed(patch));
|
|
421
501
|
}
|
|
422
502
|
|
|
423
503
|
/** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
|
|
@@ -453,6 +533,7 @@ class Controller implements ReviewController {
|
|
|
453
533
|
const inbox = await client.sessionList({ status: "pending" });
|
|
454
534
|
|
|
455
535
|
if (this.options.sessionId) {
|
|
536
|
+
this.viewingId = this.options.sessionId;
|
|
456
537
|
this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
|
|
457
538
|
} else {
|
|
458
539
|
this.update({ inbox });
|
|
@@ -498,6 +579,57 @@ class Controller implements ReviewController {
|
|
|
498
579
|
}
|
|
499
580
|
|
|
500
581
|
// ── derived projections ─────────────────────
|
|
582
|
+
/**
|
|
583
|
+
* The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
|
|
584
|
+
* files, and models derive only from the artifact and working copy - never from annotations,
|
|
585
|
+
* status, or the verdict - so an update that touched only those produces an all-new session
|
|
586
|
+
* record but an identical projection.
|
|
587
|
+
*/
|
|
588
|
+
private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
|
|
589
|
+
return (
|
|
590
|
+
this.derivedFor !== null &&
|
|
591
|
+
this.derivedForLiveDiff === liveDiff &&
|
|
592
|
+
sameDerivationInputs(this.derivedFor, session)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private buildDerived(
|
|
597
|
+
session: Thread | null,
|
|
598
|
+
frozenDiff: boolean,
|
|
599
|
+
liveDiff: LiveWorkingDiff | null,
|
|
600
|
+
): DerivedSessionProjection {
|
|
601
|
+
const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
|
|
602
|
+
const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
|
|
603
|
+
const rows = content ? diffRows(content) : [];
|
|
604
|
+
const fileContents = new Map<string, DiffFileContents>();
|
|
605
|
+
|
|
606
|
+
for (const file of files) fileContents.set(file.path, file);
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
610
|
+
rows,
|
|
611
|
+
files: walkFiles(rows),
|
|
612
|
+
fileContents,
|
|
613
|
+
models: new Map<string, FileDiffMetadata>(),
|
|
614
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The parsed model for a changed file, computed on first touch and memoized in the projection. */
|
|
619
|
+
private fileModel(path: string): FileDiffMetadata | undefined {
|
|
620
|
+
const cached = this.derived.models.get(path);
|
|
621
|
+
|
|
622
|
+
if (cached) return cached;
|
|
623
|
+
const contents = this.derived.fileContents.get(path);
|
|
624
|
+
|
|
625
|
+
if (!contents) return undefined;
|
|
626
|
+
const model = parseFileDiff(contents);
|
|
627
|
+
|
|
628
|
+
this.derived.models.set(path, model);
|
|
629
|
+
|
|
630
|
+
return model;
|
|
631
|
+
}
|
|
632
|
+
|
|
501
633
|
private ensureDerived(): void {
|
|
502
634
|
const session = this.snapshot.session;
|
|
503
635
|
const frozenDiff = readsFrozenDiff(session);
|
|
@@ -505,27 +637,60 @@ class Controller implements ReviewController {
|
|
|
505
637
|
const liveDiff = frozenDiff ? null : this.liveDiff;
|
|
506
638
|
|
|
507
639
|
if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
|
|
508
|
-
//
|
|
509
|
-
if (
|
|
640
|
+
// reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
|
|
641
|
+
if (session !== null && this.reusesDerived(session, liveDiff)) {
|
|
642
|
+
this.derivedFor = session;
|
|
643
|
+
this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
|
|
644
|
+
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
|
|
648
|
+
if (this.derivedFor?.id !== session?.id) {
|
|
510
649
|
this.collapsedFiles.clear();
|
|
511
650
|
this.expandedFiles.clear();
|
|
512
651
|
}
|
|
652
|
+
// returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
|
|
653
|
+
const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
|
|
654
|
+
|
|
655
|
+
if (
|
|
656
|
+
cached &&
|
|
657
|
+
cached.derivedForLiveDiff === liveDiff &&
|
|
658
|
+
sameDerivationInputs(cached.derivedFor, session!)
|
|
659
|
+
) {
|
|
660
|
+
this.derivedFor = session;
|
|
661
|
+
this.derivedForLiveDiff = liveDiff;
|
|
662
|
+
this.derived = {
|
|
663
|
+
...cached.derived,
|
|
664
|
+
tree: session!.history ? treeRows(session!.history) : [],
|
|
665
|
+
};
|
|
666
|
+
this.rememberDerived(session!, liveDiff, this.derived);
|
|
667
|
+
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
513
670
|
this.derivedFor = session;
|
|
514
671
|
this.derivedForLiveDiff = liveDiff;
|
|
672
|
+
this.derived = this.buildDerived(session, frozenDiff, liveDiff);
|
|
673
|
+
if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
|
|
674
|
+
}
|
|
515
675
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
676
|
+
/** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
|
|
677
|
+
private rememberDerived(
|
|
678
|
+
session: Thread,
|
|
679
|
+
liveDiff: LiveWorkingDiff | null,
|
|
680
|
+
derived: DerivedSessionProjection,
|
|
681
|
+
): void {
|
|
682
|
+
this.derivedCache.delete(session.id);
|
|
683
|
+
this.derivedCache.set(session.id, {
|
|
684
|
+
derivedFor: session,
|
|
685
|
+
derivedForLiveDiff: liveDiff,
|
|
686
|
+
derived,
|
|
687
|
+
});
|
|
688
|
+
while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
|
|
689
|
+
const oldest = this.derivedCache.keys().next().value;
|
|
520
690
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
rows,
|
|
525
|
-
files: walkFiles(rows),
|
|
526
|
-
models,
|
|
527
|
-
tree: session?.history ? treeRows(session.history) : [],
|
|
528
|
-
};
|
|
691
|
+
if (oldest === undefined) break;
|
|
692
|
+
this.derivedCache.delete(oldest);
|
|
693
|
+
}
|
|
529
694
|
}
|
|
530
695
|
|
|
531
696
|
/** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
|
|
@@ -677,7 +842,11 @@ class Controller implements ReviewController {
|
|
|
677
842
|
// surface that as an unhandled rejection.
|
|
678
843
|
private async refreshSession(id: string): Promise<void> {
|
|
679
844
|
try {
|
|
680
|
-
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 });
|
|
681
850
|
} catch (cause) {
|
|
682
851
|
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
683
852
|
}
|
|
@@ -719,22 +888,42 @@ class Controller implements ReviewController {
|
|
|
719
888
|
// ── primitives ───────────────────────────────────
|
|
720
889
|
open(id: string): void {
|
|
721
890
|
this.locallyViewed.clear();
|
|
891
|
+
this.viewingId = id;
|
|
722
892
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
723
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
|
|
724
897
|
if (cached) this.update({ session: cached });
|
|
725
|
-
|
|
898
|
+
void this.refreshSession(id);
|
|
726
899
|
}
|
|
727
900
|
|
|
728
901
|
deleteSession(id: string): void {
|
|
729
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(() => {});
|
|
730
907
|
this.client
|
|
731
908
|
?.sessionDelete(id)
|
|
732
|
-
.then(() => this.setStatus("
|
|
909
|
+
.then(() => this.setStatus("thread deleted"))
|
|
733
910
|
.catch((cause: unknown) =>
|
|
734
911
|
this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
735
912
|
);
|
|
736
913
|
}
|
|
737
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
|
+
|
|
738
927
|
renameSession(id: string, title: string): void {
|
|
739
928
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
740
929
|
this.client
|
|
@@ -821,7 +1010,7 @@ class Controller implements ReviewController {
|
|
|
821
1010
|
|
|
822
1011
|
return null;
|
|
823
1012
|
}
|
|
824
|
-
const model = this.
|
|
1013
|
+
const model = this.fileModel(row.file);
|
|
825
1014
|
|
|
826
1015
|
if (!model) return null;
|
|
827
1016
|
|
|
@@ -885,15 +1074,16 @@ class Controller implements ReviewController {
|
|
|
885
1074
|
}
|
|
886
1075
|
|
|
887
1076
|
setShareAccess(githubLogins: string[]): void {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
+
});
|
|
897
1087
|
}
|
|
898
1088
|
|
|
899
1089
|
rejectedRows(): Set<number> {
|
|
@@ -902,7 +1092,7 @@ class Controller implements ReviewController {
|
|
|
902
1092
|
const rejected = new Set<number>();
|
|
903
1093
|
|
|
904
1094
|
this.derived.rows.forEach((row, index) => {
|
|
905
|
-
const model = this.
|
|
1095
|
+
const model = this.fileModel(row.file);
|
|
906
1096
|
|
|
907
1097
|
if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
|
|
908
1098
|
});
|
|
@@ -979,7 +1169,7 @@ class Controller implements ReviewController {
|
|
|
979
1169
|
|
|
980
1170
|
/** The change/deletion rows a rejection covers, for its preview and reveal row. */
|
|
981
1171
|
private rowsForRejection(rejection: HunkRejection): DiffRow[] {
|
|
982
|
-
const model = this.
|
|
1172
|
+
const model = this.fileModel(rejection.path);
|
|
983
1173
|
|
|
984
1174
|
if (!model) return [];
|
|
985
1175
|
const rows: DiffRow[] = [];
|
|
@@ -1008,8 +1198,7 @@ class Controller implements ReviewController {
|
|
|
1008
1198
|
const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
|
|
1009
1199
|
|
|
1010
1200
|
if (result.changed) {
|
|
1011
|
-
this.
|
|
1012
|
-
this.reconcileAnnotations(session, result.content);
|
|
1201
|
+
this.saveEditedBody(result.content);
|
|
1013
1202
|
this.setStatus("edits tracked - one diff");
|
|
1014
1203
|
} else this.setStatus("no changes");
|
|
1015
1204
|
} catch (err) {
|
|
@@ -1017,6 +1206,16 @@ class Controller implements ReviewController {
|
|
|
1017
1206
|
}
|
|
1018
1207
|
}
|
|
1019
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
|
+
|
|
1020
1219
|
/**
|
|
1021
1220
|
* Edit-exit reconciliation: re-resolve every annotation against the edited
|
|
1022
1221
|
* working copy with the quote-primary cascade. Annotations that stop
|
|
@@ -1078,12 +1277,15 @@ class Controller implements ReviewController {
|
|
|
1078
1277
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
1079
1278
|
const workIndexOf = (index: number): number =>
|
|
1080
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);
|
|
1081
1283
|
|
|
1082
1284
|
anchor = makeAnchor(
|
|
1083
1285
|
workBlocks,
|
|
1084
1286
|
workIndexOf(displayIndex),
|
|
1085
|
-
start,
|
|
1086
|
-
end,
|
|
1287
|
+
work.start,
|
|
1288
|
+
work.end,
|
|
1087
1289
|
workIndexOf(endDisplayIndex),
|
|
1088
1290
|
);
|
|
1089
1291
|
}
|
|
@@ -1094,7 +1296,7 @@ class Controller implements ReviewController {
|
|
|
1094
1296
|
: { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
|
|
1095
1297
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1096
1298
|
|
|
1097
|
-
this.
|
|
1299
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1098
1300
|
this.mirrorAnnotation(persisted, wire);
|
|
1099
1301
|
|
|
1100
1302
|
return wire.id;
|
|
@@ -1110,7 +1312,7 @@ class Controller implements ReviewController {
|
|
|
1110
1312
|
: { id: newAnnotationId(), kind: "comment", anchor, body, target };
|
|
1111
1313
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1112
1314
|
|
|
1113
|
-
this.
|
|
1315
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1114
1316
|
this.mirrorAnnotation(persisted, wire);
|
|
1115
1317
|
|
|
1116
1318
|
return wire.id;
|
|
@@ -1176,7 +1378,7 @@ class Controller implements ReviewController {
|
|
|
1176
1378
|
const wire = root.target ? { ...base, target: root.target } : base;
|
|
1177
1379
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1178
1380
|
|
|
1179
|
-
this.
|
|
1381
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1180
1382
|
this.mirrorAnnotation(persisted, wire);
|
|
1181
1383
|
|
|
1182
1384
|
return wire.id;
|
|
@@ -1190,7 +1392,7 @@ class Controller implements ReviewController {
|
|
|
1190
1392
|
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
1191
1393
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1192
1394
|
|
|
1193
|
-
this.
|
|
1395
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1194
1396
|
this.mirrorAnnotation(persisted, wire);
|
|
1195
1397
|
|
|
1196
1398
|
return wire.id;
|
|
@@ -1215,9 +1417,8 @@ class Controller implements ReviewController {
|
|
|
1215
1417
|
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
1216
1418
|
const persisted = this.client!.sessionComment(session.id, wire);
|
|
1217
1419
|
|
|
1218
|
-
this.
|
|
1420
|
+
this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
|
|
1219
1421
|
this.mirrorAnnotation(persisted, wire);
|
|
1220
|
-
this.setStatus("annotation updated");
|
|
1221
1422
|
}
|
|
1222
1423
|
|
|
1223
1424
|
// push only after the local write lands, so a rejected write never leaks to the share
|
|
@@ -1226,12 +1427,16 @@ class Controller implements ReviewController {
|
|
|
1226
1427
|
annotation: Omit<Annotation, "createdAt">,
|
|
1227
1428
|
): void {
|
|
1228
1429
|
const session = this.snapshot.session;
|
|
1229
|
-
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
|
+
);
|
|
1230
1435
|
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1436
|
+
if (links.length === 0) return;
|
|
1437
|
+
void persisted
|
|
1438
|
+
.then(() => Promise.all(links.map((link) => this.shareTransport.push(link.id, [annotation]))))
|
|
1439
|
+
.catch(() => {});
|
|
1235
1440
|
}
|
|
1236
1441
|
|
|
1237
1442
|
removeAnnotation(id: string): void {
|
|
@@ -1346,25 +1551,118 @@ class Controller implements ReviewController {
|
|
|
1346
1551
|
}
|
|
1347
1552
|
|
|
1348
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");
|
|
1349
1563
|
const session = this.snapshot.session;
|
|
1350
1564
|
|
|
1351
|
-
if (!session) return;
|
|
1565
|
+
if (!session || !this.client) return;
|
|
1566
|
+
const client = this.client;
|
|
1567
|
+
const shareBranch = session.history?.branch ?? MAIN_BRANCH;
|
|
1568
|
+
|
|
1352
1569
|
this.setStatus("sharing…");
|
|
1353
1570
|
this.freezeForShare(session)
|
|
1354
|
-
.then((shared) =>
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
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;
|
|
1358
1574
|
|
|
1359
|
-
|
|
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]);
|
|
1360
1590
|
this.setStatus("");
|
|
1361
|
-
this.showToast(line, copied ? "
|
|
1591
|
+
this.showToast(line, copied ? "link copied" : "link created");
|
|
1362
1592
|
})
|
|
1363
1593
|
.catch((cause: unknown) =>
|
|
1364
1594
|
this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
1365
1595
|
);
|
|
1366
1596
|
}
|
|
1367
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
|
+
|
|
1368
1666
|
goToEntry(entryId: string, summary?: string): void {
|
|
1369
1667
|
const session = this.snapshot.session;
|
|
1370
1668
|
|
|
@@ -1448,11 +1746,15 @@ class Controller implements ReviewController {
|
|
|
1448
1746
|
client
|
|
1449
1747
|
.sessionFork(session.id)
|
|
1450
1748
|
.then(async (fork) => {
|
|
1749
|
+
const shareBranch = fork.history?.branch ?? MAIN_BRANCH;
|
|
1451
1750
|
const shared = await this.freezeForShare(fork);
|
|
1452
|
-
const { line, copied } = await this.shareTransport.publish(shared);
|
|
1453
|
-
const
|
|
1751
|
+
const { line, copied } = await this.shareTransport.publish({ ...shared, shareBranch });
|
|
1752
|
+
const id = this.shareTransport.parseShareId(line);
|
|
1454
1753
|
|
|
1455
|
-
if (
|
|
1754
|
+
if (id)
|
|
1755
|
+
await client.sessionSetShares(fork.id, [
|
|
1756
|
+
{ id, requireAuth: false, allowlist: [], shareBranch },
|
|
1757
|
+
]);
|
|
1456
1758
|
this.setStatus("");
|
|
1457
1759
|
this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
|
|
1458
1760
|
})
|
|
@@ -1483,14 +1785,19 @@ class Controller implements ReviewController {
|
|
|
1483
1785
|
* event path. Best-effort: a failed refresh is silent.
|
|
1484
1786
|
*/
|
|
1485
1787
|
pullShared(): Promise<void> {
|
|
1486
|
-
const
|
|
1788
|
+
const links = this.linksFor(this.snapshot.session);
|
|
1487
1789
|
|
|
1488
|
-
if (
|
|
1790
|
+
if (links.length === 0) return Promise.resolve();
|
|
1489
1791
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
.
|
|
1493
|
-
|
|
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(() => {});
|
|
1494
1801
|
}
|
|
1495
1802
|
|
|
1496
1803
|
private mergeShared(remote: Thread): Promise<void> {
|
|
@@ -1505,9 +1812,10 @@ class Controller implements ReviewController {
|
|
|
1505
1812
|
}
|
|
1506
1813
|
|
|
1507
1814
|
/**
|
|
1508
|
-
* Follow the
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
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
|
|
1511
1819
|
* backoff. Returns a stop handle the caller runs on leave.
|
|
1512
1820
|
*/
|
|
1513
1821
|
startShareSync(): () => void {
|
|
@@ -1518,7 +1826,7 @@ class Controller implements ReviewController {
|
|
|
1518
1826
|
|
|
1519
1827
|
this.shareRun = run;
|
|
1520
1828
|
const connect = (): void => {
|
|
1521
|
-
const shareId = this.snapshot.session?.
|
|
1829
|
+
const shareId = this.linksFor(this.snapshot.session)[0]?.id;
|
|
1522
1830
|
|
|
1523
1831
|
if (this.shareRun !== run || this.closed || !shareId) return;
|
|
1524
1832
|
void this.pullShared();
|