@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The review-session 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
|
|
6
6
|
* lifecycle including the notes-vault export and the herdr return-focus.
|
|
7
7
|
* App subscribes to snapshots and keeps only view state.
|
|
8
8
|
*/
|
|
@@ -10,32 +10,42 @@
|
|
|
10
10
|
import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
11
11
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
12
12
|
import {
|
|
13
|
+
applyPathView,
|
|
14
|
+
createBranch,
|
|
13
15
|
cutBlock,
|
|
14
16
|
detectHerdr,
|
|
17
|
+
labelTip,
|
|
18
|
+
MAIN_BRANCH,
|
|
15
19
|
makeAnchor,
|
|
20
|
+
navigateTo,
|
|
16
21
|
newAnnotationId,
|
|
17
22
|
parseBlocks,
|
|
18
23
|
resolveAnchor,
|
|
19
24
|
restoreBlock,
|
|
20
25
|
restoreLine,
|
|
21
26
|
returnPaneFor,
|
|
27
|
+
switchBranch,
|
|
28
|
+
viewOfPath,
|
|
22
29
|
type Annotation,
|
|
23
30
|
type ReviewSession,
|
|
31
|
+
type SessionHistory,
|
|
24
32
|
type VerdictKind,
|
|
25
33
|
} from "@cueloop/schema";
|
|
26
34
|
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
27
35
|
import {
|
|
28
36
|
collaboratorAnnotations,
|
|
37
|
+
mergeFromShare,
|
|
29
38
|
publishShare,
|
|
30
39
|
pullShare,
|
|
31
40
|
pushShare,
|
|
41
|
+
watchShare,
|
|
32
42
|
shareIdFromLine,
|
|
33
43
|
} from "./share";
|
|
34
44
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
45
|
+
import { entryTarget, treeRows, type TreeRow } from "./tree-view";
|
|
35
46
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
36
47
|
import {
|
|
37
48
|
changeRejectionForRow,
|
|
38
|
-
curateDiff,
|
|
39
49
|
hunkRejectionForRow,
|
|
40
50
|
isRowRejected,
|
|
41
51
|
parseFileDiff,
|
|
@@ -71,9 +81,12 @@ export type Completion =
|
|
|
71
81
|
export const DEFAULT_AUTO_CLOSE = 5;
|
|
72
82
|
|
|
73
83
|
/** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
|
|
74
|
-
|
|
84
|
+
/** Reconnect backoff for a dropped watch stream: doubles from the floor to the cap. */
|
|
85
|
+
export const SHARE_RECONNECT_MIN_MS = 1000;
|
|
86
|
+
export const SHARE_RECONNECT_MAX_MS = 30_000;
|
|
75
87
|
|
|
76
88
|
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
89
|
+
const EMPTY_REJECTIONS: HunkRejection[] = [];
|
|
77
90
|
const EMPTY_REJECTED_ROWS: Set<number> = new Set();
|
|
78
91
|
|
|
79
92
|
/** Shared empty list so "nothing curated out" is a stable identity for renders. */
|
|
@@ -131,6 +144,26 @@ export interface ControllerSnapshot {
|
|
|
131
144
|
walk: { index: number } | null;
|
|
132
145
|
}
|
|
133
146
|
|
|
147
|
+
export interface ShareTransport {
|
|
148
|
+
publish: typeof publishShare;
|
|
149
|
+
pull: typeof pullShare;
|
|
150
|
+
push: typeof pushShare;
|
|
151
|
+
watch: typeof watchShare;
|
|
152
|
+
parseShareId: typeof shareIdFromLine;
|
|
153
|
+
collaboratorAnnotations: typeof collaboratorAnnotations;
|
|
154
|
+
mergeFromShare: typeof mergeFromShare;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
|
|
158
|
+
publish: publishShare,
|
|
159
|
+
pull: pullShare,
|
|
160
|
+
watch: watchShare,
|
|
161
|
+
push: pushShare,
|
|
162
|
+
parseShareId: shareIdFromLine,
|
|
163
|
+
collaboratorAnnotations,
|
|
164
|
+
mergeFromShare,
|
|
165
|
+
};
|
|
166
|
+
|
|
134
167
|
export interface ReviewControllerOptions {
|
|
135
168
|
home?: string;
|
|
136
169
|
sessionId?: string;
|
|
@@ -145,6 +178,7 @@ export interface ReviewControllerOptions {
|
|
|
145
178
|
* renders a decrypted share instead.
|
|
146
179
|
*/
|
|
147
180
|
openClient?: () => Promise<SessionClient>;
|
|
181
|
+
shareTransport?: ShareTransport;
|
|
148
182
|
}
|
|
149
183
|
|
|
150
184
|
export interface ReviewController {
|
|
@@ -188,6 +222,7 @@ export interface ReviewController {
|
|
|
188
222
|
edit(): void;
|
|
189
223
|
/**
|
|
190
224
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
225
|
+
* `end` is an offset within `endDisplayIndex` (the same block by default).
|
|
191
226
|
* Returns the minted annotation id so the view can select the new card.
|
|
192
227
|
*/
|
|
193
228
|
annotate(
|
|
@@ -196,7 +231,13 @@ export interface ReviewController {
|
|
|
196
231
|
start: number,
|
|
197
232
|
end: number,
|
|
198
233
|
body: string,
|
|
234
|
+
endDisplayIndex?: number,
|
|
199
235
|
): string | undefined;
|
|
236
|
+
/**
|
|
237
|
+
* Reply to `rootAnnotationId`: the reply shares the root's anchor and names
|
|
238
|
+
* it in replyTo, so the discussion stays one conversation. Returns the minted id.
|
|
239
|
+
*/
|
|
240
|
+
reply(rootAnnotationId: string, body: string): string | undefined;
|
|
200
241
|
/** Anchor a prototype comment to a DOM element by its selector. */
|
|
201
242
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
202
243
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
@@ -214,10 +255,25 @@ export interface ReviewController {
|
|
|
214
255
|
submit(verdict: VerdictKind, summary: string): void;
|
|
215
256
|
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
216
257
|
share(): void;
|
|
258
|
+
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
259
|
+
treeRows(): TreeRow[];
|
|
260
|
+
/**
|
|
261
|
+
* Go to an entry: switch to the branch whose tip it is, or move the current
|
|
262
|
+
* branch's tip back to it, with an optional branch summary. Owner only.
|
|
263
|
+
*/
|
|
264
|
+
goToEntry(entryId: string, summary?: string): void;
|
|
265
|
+
/** Start a branch at the current tip and switch to it. */
|
|
266
|
+
branch(name: string): void;
|
|
267
|
+
/** Name the current tip as a checkpoint. */
|
|
268
|
+
labelTip(label: string): void;
|
|
269
|
+
/** Copy the current path into a new session and open it. */
|
|
270
|
+
fork(): void;
|
|
271
|
+
/** Fork, then share the fork; the current session stays open and unshared. */
|
|
272
|
+
forkAndShare(): void;
|
|
217
273
|
/** Pull a shared plan's collaborator notes back and union them in (planner only). */
|
|
218
274
|
pullShared(): Promise<void>;
|
|
219
275
|
/** Poll the share for collaborator notes while it is open; returns a stop handle. */
|
|
220
|
-
|
|
276
|
+
startShareSync(): () => void;
|
|
221
277
|
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
222
278
|
finishReview(): void;
|
|
223
279
|
dismissCompletion(): void;
|
|
@@ -235,6 +291,14 @@ export function createReviewController(options: ReviewControllerOptions): Review
|
|
|
235
291
|
return new Controller(options);
|
|
236
292
|
}
|
|
237
293
|
|
|
294
|
+
interface DerivedSessionProjection {
|
|
295
|
+
display: DisplayBlock[];
|
|
296
|
+
rows: DiffRow[];
|
|
297
|
+
files: WalkFile[];
|
|
298
|
+
models: Map<string, FileDiffMetadata>;
|
|
299
|
+
tree: TreeRow[];
|
|
300
|
+
}
|
|
301
|
+
|
|
238
302
|
class Controller implements ReviewController {
|
|
239
303
|
readonly readOnly: boolean;
|
|
240
304
|
private client: SessionClient | null = null;
|
|
@@ -254,29 +318,29 @@ class Controller implements ReviewController {
|
|
|
254
318
|
private editor: string | undefined;
|
|
255
319
|
private exporters: BundledExporter[] = [];
|
|
256
320
|
private readonly clock: Clock;
|
|
321
|
+
private readonly shareTransport: ShareTransport;
|
|
257
322
|
private countdown: TimerHandle | undefined;
|
|
258
|
-
private
|
|
323
|
+
private shareReconnect: TimerHandle | undefined;
|
|
324
|
+
private shareStop: (() => void) | null = null;
|
|
259
325
|
private shareRun: object | null = null;
|
|
260
326
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
261
327
|
private derivedFor: ReviewSession | null = null;
|
|
262
|
-
private derived: {
|
|
263
|
-
display: DisplayBlock[];
|
|
264
|
-
rows: DiffRow[];
|
|
265
|
-
files: WalkFile[];
|
|
266
|
-
/** Per-path parsed diff models, for hunk curation; empty when files absent. */
|
|
267
|
-
models: Map<string, FileDiffMetadata>;
|
|
268
|
-
} = {
|
|
328
|
+
private derived: DerivedSessionProjection = {
|
|
269
329
|
display: [],
|
|
270
330
|
rows: [],
|
|
271
331
|
files: [],
|
|
272
332
|
models: new Map(),
|
|
333
|
+
tree: [],
|
|
273
334
|
};
|
|
274
|
-
/** The
|
|
275
|
-
private rejections: HunkRejection[]
|
|
335
|
+
/** The diff's reject decisions live on the session record; the daemon curates from them. */
|
|
336
|
+
private get rejections(): HunkRejection[] {
|
|
337
|
+
return this.snapshot.session?.curation ?? EMPTY_REJECTIONS;
|
|
338
|
+
}
|
|
276
339
|
|
|
277
340
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
278
341
|
this.readOnly = options.readOnly ?? false;
|
|
279
342
|
this.clock = options.clock ?? new SystemClock();
|
|
343
|
+
this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
|
|
280
344
|
}
|
|
281
345
|
|
|
282
346
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -324,7 +388,7 @@ class Controller implements ReviewController {
|
|
|
324
388
|
close(): void {
|
|
325
389
|
this.closed = true;
|
|
326
390
|
this.clearCountdown();
|
|
327
|
-
this.
|
|
391
|
+
this.stopShareSync();
|
|
328
392
|
this.client?.close();
|
|
329
393
|
}
|
|
330
394
|
|
|
@@ -369,9 +433,16 @@ class Controller implements ReviewController {
|
|
|
369
433
|
rows,
|
|
370
434
|
files: walkFiles(rows),
|
|
371
435
|
models,
|
|
436
|
+
tree: session?.history ? treeRows(session.history) : [],
|
|
372
437
|
};
|
|
373
438
|
}
|
|
374
439
|
|
|
440
|
+
treeRows(): TreeRow[] {
|
|
441
|
+
this.ensureDerived();
|
|
442
|
+
|
|
443
|
+
return this.derived.tree;
|
|
444
|
+
}
|
|
445
|
+
|
|
375
446
|
display(): DisplayBlock[] {
|
|
376
447
|
this.ensureDerived();
|
|
377
448
|
|
|
@@ -402,16 +473,16 @@ class Controller implements ReviewController {
|
|
|
402
473
|
private async refreshSession(id: string): Promise<void> {
|
|
403
474
|
try {
|
|
404
475
|
if (this.client) this.update({ session: await this.client.sessionGet(id) });
|
|
405
|
-
} catch (
|
|
406
|
-
if (!this.closed) this.setStatus(
|
|
476
|
+
} catch (cause) {
|
|
477
|
+
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
407
478
|
}
|
|
408
479
|
}
|
|
409
480
|
|
|
410
481
|
private async refreshInbox(): Promise<void> {
|
|
411
482
|
try {
|
|
412
483
|
if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
|
|
413
|
-
} catch (
|
|
414
|
-
if (!this.closed) this.setStatus(
|
|
484
|
+
} catch (cause) {
|
|
485
|
+
if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
|
|
415
486
|
}
|
|
416
487
|
}
|
|
417
488
|
|
|
@@ -419,15 +490,30 @@ class Controller implements ReviewController {
|
|
|
419
490
|
private apply(mutation: Promise<ReviewSession>): void {
|
|
420
491
|
mutation
|
|
421
492
|
.then((session) => this.update({ session }))
|
|
422
|
-
.catch((
|
|
423
|
-
this.setStatus(String(
|
|
493
|
+
.catch((cause: unknown) =>
|
|
494
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause)),
|
|
424
495
|
);
|
|
425
496
|
}
|
|
426
497
|
|
|
427
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Show the expected result before the daemon answers, so a second keypress
|
|
500
|
+
* builds on the first instead of on the stale snapshot. Answers arrive in
|
|
501
|
+
* request order over the socket and replace the guess with the daemon's copy.
|
|
502
|
+
*/
|
|
503
|
+
private applyOptimistic(expected: ReviewSession, mutation: Promise<ReviewSession>): void {
|
|
504
|
+
this.update({ session: expected });
|
|
505
|
+
mutation
|
|
506
|
+
.then((session) => this.update({ session }))
|
|
507
|
+
.catch((cause: unknown) => {
|
|
508
|
+
// a refused guess must not stay on screen: the daemon's record replaces it
|
|
509
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause));
|
|
510
|
+
void this.refreshSession(expected.id);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── primitives ───────────────────────────────────
|
|
428
515
|
open(id: string): void {
|
|
429
516
|
this.locallyViewed.clear();
|
|
430
|
-
this.rejections = [];
|
|
431
517
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
432
518
|
|
|
433
519
|
if (cached) this.update({ session: cached });
|
|
@@ -438,8 +524,8 @@ class Controller implements ReviewController {
|
|
|
438
524
|
this.client
|
|
439
525
|
?.sessionDelete(id)
|
|
440
526
|
.then(() => this.setStatus("plan deleted"))
|
|
441
|
-
.catch((
|
|
442
|
-
this.setStatus(`delete failed: ${
|
|
527
|
+
.catch((cause: unknown) =>
|
|
528
|
+
this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
443
529
|
);
|
|
444
530
|
}
|
|
445
531
|
|
|
@@ -462,7 +548,15 @@ class Controller implements ReviewController {
|
|
|
462
548
|
if (block.type === "del") {
|
|
463
549
|
this.restoreDelBlock(block, displayIndex);
|
|
464
550
|
} else if (block.work) {
|
|
465
|
-
|
|
551
|
+
const workIndex = parseBlocks(working).findIndex(
|
|
552
|
+
(candidate) => candidate.lineStart === block.work!.lineStart,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if (workIndex === -1) return;
|
|
556
|
+
this.applyOptimistic(
|
|
557
|
+
{ ...session, workingCopy: cutBlock(working, block.work) },
|
|
558
|
+
this.client!.sessionCutBlock(session.id, workIndex),
|
|
559
|
+
);
|
|
466
560
|
this.setStatus("block cut - it serializes into the diff");
|
|
467
561
|
}
|
|
468
562
|
}
|
|
@@ -477,11 +571,19 @@ class Controller implements ReviewController {
|
|
|
477
571
|
nextWorkBlock(this.display(), displayIndex),
|
|
478
572
|
working.split("\n").length,
|
|
479
573
|
);
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
574
|
+
const base = session.artifact.content;
|
|
575
|
+
const baseIndex = parseBlocks(base).findIndex(
|
|
576
|
+
(candidate) => candidate.lineStart === block.base!.lineStart,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
if (baseIndex === -1) return;
|
|
580
|
+
const expected: ReviewSession = {
|
|
581
|
+
...session,
|
|
582
|
+
workingCopy: restoreBlock(base, working, block.base, line),
|
|
583
|
+
};
|
|
483
584
|
|
|
484
|
-
|
|
585
|
+
if (expected.workingCopy === undefined) delete expected.workingCopy;
|
|
586
|
+
this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
|
|
485
587
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
486
588
|
}
|
|
487
589
|
|
|
@@ -520,18 +622,18 @@ class Controller implements ReviewController {
|
|
|
520
622
|
const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
|
|
521
623
|
|
|
522
624
|
if (this.rejections.some(wholeHunk)) {
|
|
523
|
-
this.
|
|
625
|
+
this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
|
|
524
626
|
this.setStatus("hunk restored");
|
|
525
627
|
} else {
|
|
526
628
|
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
527
|
-
|
|
629
|
+
const others = this.rejections.filter(
|
|
528
630
|
(rejection) =>
|
|
529
631
|
!(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
|
|
530
632
|
);
|
|
531
|
-
|
|
633
|
+
|
|
634
|
+
this.curate([...others, target]);
|
|
532
635
|
this.setStatus("hunk rejected - dropped from the working copy");
|
|
533
636
|
}
|
|
534
|
-
this.recomputeCuration();
|
|
535
637
|
}
|
|
536
638
|
|
|
537
639
|
toggleRejectChange(rowIndex: number): void {
|
|
@@ -547,24 +649,23 @@ class Controller implements ReviewController {
|
|
|
547
649
|
|
|
548
650
|
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
549
651
|
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
550
|
-
this.
|
|
652
|
+
this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
|
|
551
653
|
this.setStatus("change restored");
|
|
552
654
|
} else {
|
|
553
|
-
this.rejections
|
|
655
|
+
this.curate([...this.rejections, target]);
|
|
554
656
|
this.setStatus("change rejected - dropped from the working copy");
|
|
555
657
|
}
|
|
556
|
-
this.recomputeCuration();
|
|
557
658
|
}
|
|
558
659
|
|
|
559
|
-
/**
|
|
560
|
-
private
|
|
561
|
-
const
|
|
660
|
+
/** Hand the daemon the full set of reject decisions; the working copy follows. */
|
|
661
|
+
private curate(rejections: HunkRejection[]): void {
|
|
662
|
+
const session = this.snapshot.session;
|
|
663
|
+
|
|
664
|
+
if (!session) return;
|
|
665
|
+
const expected: ReviewSession = { ...session, curation: rejections };
|
|
562
666
|
|
|
563
|
-
if (!
|
|
564
|
-
|
|
565
|
-
this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
|
|
566
|
-
// dimming reads `rejections` directly; a bare update forces the re-read
|
|
567
|
-
this.update({});
|
|
667
|
+
if (!rejections.length) delete expected.curation;
|
|
668
|
+
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
568
669
|
}
|
|
569
670
|
|
|
570
671
|
rejectedRows(): Set<number> {
|
|
@@ -596,9 +697,8 @@ class Controller implements ReviewController {
|
|
|
596
697
|
const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
|
|
597
698
|
|
|
598
699
|
if (kept.length === this.rejections.length) return;
|
|
599
|
-
this.
|
|
700
|
+
this.curate(kept);
|
|
600
701
|
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
601
|
-
this.recomputeCuration();
|
|
602
702
|
|
|
603
703
|
return;
|
|
604
704
|
}
|
|
@@ -710,6 +810,7 @@ class Controller implements ReviewController {
|
|
|
710
810
|
start: number,
|
|
711
811
|
end: number,
|
|
712
812
|
body: string,
|
|
813
|
+
endDisplayIndex: number = displayIndex,
|
|
713
814
|
): string | undefined {
|
|
714
815
|
const session = this.snapshot.session;
|
|
715
816
|
|
|
@@ -721,10 +822,16 @@ class Controller implements ReviewController {
|
|
|
721
822
|
} else {
|
|
722
823
|
const display = this.display();
|
|
723
824
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
724
|
-
const
|
|
725
|
-
display.slice(0,
|
|
726
|
-
|
|
727
|
-
anchor = makeAnchor(
|
|
825
|
+
const workIndexOf = (index: number): number =>
|
|
826
|
+
display.slice(0, index + 1).filter((entry) => entry.work).length - 1;
|
|
827
|
+
|
|
828
|
+
anchor = makeAnchor(
|
|
829
|
+
workBlocks,
|
|
830
|
+
workIndexOf(displayIndex),
|
|
831
|
+
start,
|
|
832
|
+
end,
|
|
833
|
+
workIndexOf(endDisplayIndex),
|
|
834
|
+
);
|
|
728
835
|
}
|
|
729
836
|
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
730
837
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
@@ -736,6 +843,28 @@ class Controller implements ReviewController {
|
|
|
736
843
|
return wire.id;
|
|
737
844
|
}
|
|
738
845
|
|
|
846
|
+
reply(rootAnnotationId: string, body: string): string | undefined {
|
|
847
|
+
const session = this.snapshot.session;
|
|
848
|
+
const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
|
|
849
|
+
|
|
850
|
+
if (!session || !root) return undefined;
|
|
851
|
+
// a reply to a reply still hangs off the discussion's root comment
|
|
852
|
+
const wire = {
|
|
853
|
+
id: newAnnotationId(),
|
|
854
|
+
kind: "comment",
|
|
855
|
+
anchor: root.anchor,
|
|
856
|
+
body,
|
|
857
|
+
replyTo: root.replyTo ?? root.id,
|
|
858
|
+
};
|
|
859
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
860
|
+
|
|
861
|
+
this.apply(persisted);
|
|
862
|
+
this.mirrorAnnotation(persisted, wire);
|
|
863
|
+
this.setStatus("reply added");
|
|
864
|
+
|
|
865
|
+
return wire.id;
|
|
866
|
+
}
|
|
867
|
+
|
|
739
868
|
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
740
869
|
const session = this.snapshot.session;
|
|
741
870
|
|
|
@@ -758,8 +887,16 @@ class Controller implements ReviewController {
|
|
|
758
887
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
759
888
|
|
|
760
889
|
if (!existing) return;
|
|
761
|
-
// the daemon's annotate
|
|
762
|
-
|
|
890
|
+
// the daemon's annotate primitive upserts by id: same id, anchor, and
|
|
891
|
+
// reply link, new body
|
|
892
|
+
const wire: Omit<Annotation, "createdAt"> = {
|
|
893
|
+
id: existing.id,
|
|
894
|
+
kind: existing.kind,
|
|
895
|
+
anchor: existing.anchor,
|
|
896
|
+
body,
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
|
|
763
900
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
764
901
|
|
|
765
902
|
this.apply(persisted);
|
|
@@ -772,10 +909,13 @@ class Controller implements ReviewController {
|
|
|
772
909
|
persisted: Promise<ReviewSession>,
|
|
773
910
|
annotation: Omit<Annotation, "createdAt">,
|
|
774
911
|
): void {
|
|
775
|
-
const
|
|
912
|
+
const session = this.snapshot.session;
|
|
913
|
+
const shareId = session?.shareId;
|
|
776
914
|
|
|
777
915
|
if (!shareId) return;
|
|
778
|
-
|
|
916
|
+
// the share follows one branch: a note left on another stays the owner's
|
|
917
|
+
if (session.history && session.history.branch !== (session.shareBranch ?? MAIN_BRANCH)) return;
|
|
918
|
+
void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
|
|
779
919
|
}
|
|
780
920
|
|
|
781
921
|
removeAnnotation(id: string): void {
|
|
@@ -823,7 +963,7 @@ class Controller implements ReviewController {
|
|
|
823
963
|
|
|
824
964
|
if (!current) return; // already on the end card
|
|
825
965
|
// advancing IS the viewed mark: the step is complete once you move past
|
|
826
|
-
// it; the daemon
|
|
966
|
+
// it; the daemon primitive merges, so only the new path travels
|
|
827
967
|
if (!this.viewedSet().has(current.path)) {
|
|
828
968
|
this.locallyViewed.add(current.path);
|
|
829
969
|
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
@@ -868,12 +1008,12 @@ class Controller implements ReviewController {
|
|
|
868
1008
|
// a remembers the choice. A configured delay overrides; 0 closes now.
|
|
869
1009
|
const delay = this.autoClose;
|
|
870
1010
|
|
|
871
|
-
if (delay ===
|
|
872
|
-
else if (
|
|
873
|
-
else this.startCounting(
|
|
1011
|
+
if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
1012
|
+
else if (delay === 0) this.finishReview();
|
|
1013
|
+
else this.startCounting(delay);
|
|
874
1014
|
})
|
|
875
|
-
.catch((
|
|
876
|
-
this.setStatus(String(
|
|
1015
|
+
.catch((cause: unknown) =>
|
|
1016
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause)),
|
|
877
1017
|
);
|
|
878
1018
|
}
|
|
879
1019
|
|
|
@@ -882,72 +1022,208 @@ class Controller implements ReviewController {
|
|
|
882
1022
|
|
|
883
1023
|
if (!session) return;
|
|
884
1024
|
this.setStatus("sharing…");
|
|
885
|
-
|
|
1025
|
+
this.shareTransport
|
|
1026
|
+
.publish(session)
|
|
886
1027
|
.then(async ({ line, copied }) => {
|
|
887
1028
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
888
|
-
const shareId =
|
|
1029
|
+
const shareId = this.shareTransport.parseShareId(line);
|
|
889
1030
|
|
|
890
1031
|
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
891
1032
|
this.setStatus("");
|
|
892
1033
|
this.showToast(line, copied ? "share link copied" : "share link");
|
|
893
1034
|
})
|
|
894
|
-
.catch((
|
|
895
|
-
this.setStatus(`share failed: ${
|
|
1035
|
+
.catch((cause: unknown) =>
|
|
1036
|
+
this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
896
1037
|
);
|
|
897
1038
|
}
|
|
898
1039
|
|
|
1040
|
+
goToEntry(entryId: string, summary?: string): void {
|
|
1041
|
+
const session = this.snapshot.session;
|
|
1042
|
+
|
|
1043
|
+
if (!session?.history || !this.client) return;
|
|
1044
|
+
const target = entryTarget(session.history, entryId);
|
|
1045
|
+
|
|
1046
|
+
if (target === null) return this.setStatus("that entry is not on any branch");
|
|
1047
|
+
if (target.kind === "here") return this.setStatus("already at the tip");
|
|
1048
|
+
if (target.kind === "switch") {
|
|
1049
|
+
this.moveTree(session, switchBranch(session.history, target.branch), () =>
|
|
1050
|
+
this.client!.sessionSwitch(session.id, target.branch),
|
|
1051
|
+
);
|
|
1052
|
+
this.setStatus(`on branch ${target.branch}`);
|
|
1053
|
+
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// an entry another branch reaches: the daemon stands on that branch first, in the same request
|
|
1057
|
+
const onBranch = switchBranch(session.history, target.branch);
|
|
1058
|
+
const options = summary === undefined || summary === "" ? {} : { summary };
|
|
1059
|
+
const moved = navigateTo(onBranch, entryId, options);
|
|
1060
|
+
const branch = target.branch === session.history.branch ? undefined : target.branch;
|
|
1061
|
+
|
|
1062
|
+
this.moveTree(session, moved, () =>
|
|
1063
|
+
this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
|
|
1064
|
+
);
|
|
1065
|
+
this.setStatus(
|
|
1066
|
+
options.summary === undefined
|
|
1067
|
+
? "moved back - later entries stay in the tree"
|
|
1068
|
+
: "moved back with a summary",
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
branch(name: string): void {
|
|
1073
|
+
const session = this.snapshot.session;
|
|
1074
|
+
const branchName = name.trim();
|
|
1075
|
+
|
|
1076
|
+
if (!session?.history || !this.client) return;
|
|
1077
|
+
if (!branchName) return this.setStatus("a branch needs a name");
|
|
1078
|
+
if (session.history.tips[branchName] !== undefined)
|
|
1079
|
+
return this.setStatus(`branch ${branchName} exists`);
|
|
1080
|
+
this.moveTree(session, createBranch(session.history, branchName), () =>
|
|
1081
|
+
this.client!.sessionBranch(session.id, branchName),
|
|
1082
|
+
);
|
|
1083
|
+
this.setStatus(`on branch ${branchName}`);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
labelTip(label: string): void {
|
|
1087
|
+
const session = this.snapshot.session;
|
|
1088
|
+
const name = label.trim();
|
|
1089
|
+
|
|
1090
|
+
if (!session?.history || !this.client) return;
|
|
1091
|
+
if (!name) return this.setStatus("a checkpoint needs a name");
|
|
1092
|
+
this.moveTree(session, labelTip(session.history, name), () =>
|
|
1093
|
+
this.client!.sessionLabel(session.id, name),
|
|
1094
|
+
);
|
|
1095
|
+
this.setStatus(`checkpoint ${name}`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
fork(): void {
|
|
1099
|
+
const session = this.snapshot.session;
|
|
1100
|
+
|
|
1101
|
+
if (!session || !this.client) return;
|
|
1102
|
+
this.client
|
|
1103
|
+
.sessionFork(session.id)
|
|
1104
|
+
.then((fork) => {
|
|
1105
|
+
this.update({ session: fork });
|
|
1106
|
+
this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
|
|
1107
|
+
})
|
|
1108
|
+
.catch((cause: unknown) =>
|
|
1109
|
+
this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
forkAndShare(): void {
|
|
1114
|
+
const session = this.snapshot.session;
|
|
1115
|
+
|
|
1116
|
+
if (!session || !this.client) return;
|
|
1117
|
+
const client = this.client;
|
|
1118
|
+
|
|
1119
|
+
this.setStatus("forking and sharing…");
|
|
1120
|
+
client
|
|
1121
|
+
.sessionFork(session.id)
|
|
1122
|
+
.then(async (fork) => {
|
|
1123
|
+
const { line, copied } = await this.shareTransport.publish(fork);
|
|
1124
|
+
const shareId = this.shareTransport.parseShareId(line);
|
|
1125
|
+
|
|
1126
|
+
if (shareId) await client.sessionSetShareId(fork.id, shareId);
|
|
1127
|
+
this.setStatus("");
|
|
1128
|
+
this.showToast(line, copied ? "fork shared - link copied" : "fork shared");
|
|
1129
|
+
})
|
|
1130
|
+
.catch((cause: unknown) =>
|
|
1131
|
+
this.setStatus(
|
|
1132
|
+
`fork and share failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1133
|
+
),
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Show a moved tree at once - the record re-derived from its new path - and
|
|
1139
|
+
* let the daemon's answer replace it. A refused move surfaces as status.
|
|
1140
|
+
*/
|
|
1141
|
+
private moveTree(
|
|
1142
|
+
session: ReviewSession,
|
|
1143
|
+
history: SessionHistory,
|
|
1144
|
+
request: () => Promise<ReviewSession>,
|
|
1145
|
+
): void {
|
|
1146
|
+
const expected: ReviewSession = { ...session, history };
|
|
1147
|
+
|
|
1148
|
+
applyPathView(
|
|
1149
|
+
expected,
|
|
1150
|
+
viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
|
|
1151
|
+
);
|
|
1152
|
+
this.applyOptimistic(expected, request());
|
|
1153
|
+
}
|
|
1154
|
+
|
|
899
1155
|
/**
|
|
900
|
-
* Pull collaborator notes for a shared plan and union them in
|
|
901
|
-
* merge
|
|
902
|
-
*
|
|
903
|
-
* Best-effort: a failed refresh is silent and the next tick retries.
|
|
1156
|
+
* Pull collaborator notes for a shared plan and union them in. The daemon's
|
|
1157
|
+
* merge emits session.updated, so the re-render happens through the normal
|
|
1158
|
+
* event path. Best-effort: a failed refresh is silent.
|
|
904
1159
|
*/
|
|
905
1160
|
pullShared(): Promise<void> {
|
|
906
1161
|
const session = this.snapshot.session;
|
|
907
1162
|
|
|
908
|
-
if (!session?.shareId
|
|
909
|
-
|
|
1163
|
+
if (!session?.shareId) return Promise.resolve();
|
|
1164
|
+
|
|
1165
|
+
return this.shareTransport
|
|
1166
|
+
.pull(session.shareId)
|
|
1167
|
+
.then((remote) => this.mergeShared(remote))
|
|
1168
|
+
.catch(() => {});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private mergeShared(remote: ReviewSession): Promise<void> {
|
|
1172
|
+
const session = this.snapshot.session;
|
|
1173
|
+
|
|
1174
|
+
if (!session || !this.client) return Promise.resolve();
|
|
910
1175
|
|
|
911
|
-
return
|
|
912
|
-
.
|
|
913
|
-
client.sessionMergeShared(session.id, {
|
|
914
|
-
annotations: collaboratorAnnotations(remote),
|
|
915
|
-
participants: remote.participants,
|
|
916
|
-
}),
|
|
917
|
-
)
|
|
1176
|
+
return this.client
|
|
1177
|
+
.sessionMergeShared(session.id, this.shareTransport.mergeFromShare(remote))
|
|
918
1178
|
.then(() => {})
|
|
919
1179
|
.catch(() => {});
|
|
920
1180
|
}
|
|
921
1181
|
|
|
922
1182
|
/**
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
1183
|
+
* Follow the share live while it is open: one watch stream delivers every
|
|
1184
|
+
* change as it lands; a pull on each (re)connect catches up on anything
|
|
1185
|
+
* missed while the link was down, and a dropped link reconnects with
|
|
1186
|
+
* backoff. Returns a stop handle the caller runs on leave.
|
|
926
1187
|
*/
|
|
927
|
-
|
|
928
|
-
this.
|
|
929
|
-
// per-run token so a stale run's
|
|
1188
|
+
startShareSync(): () => void {
|
|
1189
|
+
this.stopShareSync();
|
|
1190
|
+
// per-run token so a stale run's close never re-arms a newer run
|
|
930
1191
|
const run = {};
|
|
1192
|
+
let delay = SHARE_RECONNECT_MIN_MS;
|
|
931
1193
|
|
|
932
1194
|
this.shareRun = run;
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1195
|
+
const connect = (): void => {
|
|
1196
|
+
const shareId = this.snapshot.session?.shareId;
|
|
1197
|
+
|
|
1198
|
+
if (this.shareRun !== run || this.closed || !shareId) return;
|
|
1199
|
+
void this.pullShared();
|
|
1200
|
+
this.shareStop = this.shareTransport.watch(shareId, {
|
|
1201
|
+
onSession: (remote) => {
|
|
1202
|
+
delay = SHARE_RECONNECT_MIN_MS;
|
|
1203
|
+
void this.mergeShared(remote);
|
|
1204
|
+
},
|
|
1205
|
+
onClose: () => {
|
|
1206
|
+
if (this.shareRun !== run || this.closed) return;
|
|
1207
|
+
this.shareStop = null;
|
|
1208
|
+
this.shareReconnect = this.clock.setTimeout(connect, delay);
|
|
1209
|
+
delay = Math.min(delay * 2, SHARE_RECONNECT_MAX_MS);
|
|
1210
|
+
},
|
|
937
1211
|
});
|
|
938
1212
|
};
|
|
939
1213
|
|
|
940
|
-
|
|
1214
|
+
connect();
|
|
941
1215
|
|
|
942
1216
|
return () => {
|
|
943
|
-
if (this.shareRun === run) this.
|
|
1217
|
+
if (this.shareRun === run) this.stopShareSync();
|
|
944
1218
|
};
|
|
945
1219
|
}
|
|
946
1220
|
|
|
947
|
-
private
|
|
1221
|
+
private stopShareSync(): void {
|
|
948
1222
|
this.shareRun = null;
|
|
949
|
-
if (this.
|
|
950
|
-
this.
|
|
1223
|
+
if (this.shareReconnect !== undefined) this.clock.clearTimeout(this.shareReconnect);
|
|
1224
|
+
this.shareReconnect = undefined;
|
|
1225
|
+
this.shareStop?.();
|
|
1226
|
+
this.shareStop = null;
|
|
951
1227
|
}
|
|
952
1228
|
|
|
953
1229
|
// ── completion hand-back ────────────────────
|