@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34
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 +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +131 -5
- package/src/App.export.test.tsx +23 -5
- package/src/App.plan-review.test.tsx +133 -32
- package/src/App.readonly.test.tsx +35 -13
- package/src/App.rendering.test.tsx +14 -4
- package/src/App.review-panel.test.tsx +27 -15
- package/src/App.submit-confirm.test.tsx +14 -13
- package/src/App.test.tsx +50 -13
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +505 -101
- package/src/App.walk.test.tsx +31 -11
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +29 -10
- package/src/components/AnnotationCard.tsx +57 -37
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +23 -5
- package/src/components/ConfirmDialog.tsx +10 -2
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +35 -1
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +213 -101
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +35 -3
- package/src/components/PlanSheet.tsx +160 -100
- package/src/components/PromptDialog.tsx +19 -3
- package/src/components/ReviewPanel.stories.tsx +17 -19
- package/src/components/ReviewPanel.tsx +17 -32
- package/src/components/ReviewRail.stories.tsx +159 -47
- package/src/components/ReviewRail.tsx +206 -62
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +11 -5
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +12 -1
- package/src/components/primitives/Tabs.tsx +40 -39
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +77 -16
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +13 -5
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +77 -16
- package/src/session-controller.ts +319 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +97 -23
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/version.ts +5 -0
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
- package/src/components/ComposeBar.stories.tsx +0 -18
- package/src/components/ComposeBar.tsx +0 -54
|
@@ -24,13 +24,36 @@ import {
|
|
|
24
24
|
type VerdictKind,
|
|
25
25
|
} from "@cueloop/schema";
|
|
26
26
|
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
collaboratorAnnotations,
|
|
29
|
+
publishShare,
|
|
30
|
+
pullShare,
|
|
31
|
+
pushShare,
|
|
32
|
+
shareIdFromLine,
|
|
33
|
+
} from "./share";
|
|
28
34
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
29
35
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
36
|
+
import {
|
|
37
|
+
changeRejectionForRow,
|
|
38
|
+
curateDiff,
|
|
39
|
+
hunkRejectionForRow,
|
|
40
|
+
isRowRejected,
|
|
41
|
+
parseFileDiff,
|
|
42
|
+
rejectsWholeHunk,
|
|
43
|
+
sameRejection,
|
|
44
|
+
type HunkRejection,
|
|
45
|
+
} from "./diff-hunk-curate";
|
|
46
|
+
import type { FileDiffMetadata } from "@pierre/diffs";
|
|
30
47
|
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
31
48
|
import { editInEditor } from "./editor";
|
|
32
49
|
import { focusHerdrPane } from "./herdr";
|
|
33
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
persistAutoClose,
|
|
52
|
+
persistReviewState,
|
|
53
|
+
persistReviewWidth,
|
|
54
|
+
type AutoClose,
|
|
55
|
+
type CueloopConfig,
|
|
56
|
+
} from "./config";
|
|
34
57
|
import type { ReviewPanelMode } from "./review-panel";
|
|
35
58
|
|
|
36
59
|
/**
|
|
@@ -50,10 +73,49 @@ export const DEFAULT_AUTO_CLOSE = 5;
|
|
|
50
73
|
/** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
|
|
51
74
|
export const SHARE_POLL_MS = 4000;
|
|
52
75
|
|
|
76
|
+
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
77
|
+
const EMPTY_REJECTED_ROWS: Set<number> = new Set();
|
|
78
|
+
|
|
79
|
+
/** Shared empty list so "nothing curated out" is a stable identity for renders. */
|
|
80
|
+
const EMPTY_CURATION_ITEMS: CurationItem[] = [];
|
|
81
|
+
|
|
82
|
+
/** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
|
|
83
|
+
const REMOVAL_RESTORED_STATUS = "removal restored";
|
|
84
|
+
|
|
85
|
+
export interface ToastState {
|
|
86
|
+
title?: string;
|
|
87
|
+
body: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One removed piece projected for the review rail, unified across both review
|
|
92
|
+
* kinds: a diff hunk/change the owner curated out, or a plan block they cut.
|
|
93
|
+
* `source` tells restore and reveal which path to take; `preview` is the removed
|
|
94
|
+
* content (a few lines) the card renders struck through; `revealIndex` is the
|
|
95
|
+
* diff row (diff) or display block (plan) to scroll to and restore against.
|
|
96
|
+
*/
|
|
97
|
+
export interface CurationItem {
|
|
98
|
+
id: string;
|
|
99
|
+
source: "diff" | "plan";
|
|
100
|
+
preview: string[];
|
|
101
|
+
revealIndex: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable id for a rejection's rail item: diff:path#hunk#change (or #hunk for a whole hunk). */
|
|
105
|
+
export function curationItemId(rejection: HunkRejection): string {
|
|
106
|
+
return `diff:${rejection.path}#${rejection.hunkIndex}#${rejection.changeIndex ?? "hunk"}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Stable id for a plan cut's rail item, keyed on its fixed base-content line range. */
|
|
110
|
+
function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
111
|
+
return `plan:${base.lineStart}-${base.lineEnd}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
53
114
|
export interface ControllerSnapshot {
|
|
54
115
|
session: ReviewSession | null;
|
|
55
116
|
inbox: ReviewSession[] | null;
|
|
56
117
|
status: string;
|
|
118
|
+
toast: ToastState | null;
|
|
57
119
|
error: string | null;
|
|
58
120
|
completion: Completion;
|
|
59
121
|
/**
|
|
@@ -96,6 +158,8 @@ export interface ReviewController {
|
|
|
96
158
|
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
97
159
|
applyConfig(config: CueloopConfig): void;
|
|
98
160
|
setStatus(message: string): void;
|
|
161
|
+
showToast(body: string, title?: string): void;
|
|
162
|
+
dismissToast(): void;
|
|
99
163
|
/** Derived projections, cached per session identity. */
|
|
100
164
|
display(): DisplayBlock[];
|
|
101
165
|
rows(): DiffRow[];
|
|
@@ -110,13 +174,29 @@ export interface ReviewController {
|
|
|
110
174
|
setSelfName(name: string): void;
|
|
111
175
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
112
176
|
cut(displayIndex: number): void;
|
|
177
|
+
/** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
|
|
178
|
+
toggleRejectHunk(rowIndex: number): void;
|
|
179
|
+
/** Toggle rejection of the single change under the diff cursor (owner curation). */
|
|
180
|
+
toggleRejectChange(rowIndex: number): void;
|
|
181
|
+
/** Rendered row indices dropped by the current reject decisions (for dimming). */
|
|
182
|
+
rejectedRows(): Set<number>;
|
|
183
|
+
/** The curated-out rejections as rail items, in the order they were rejected. */
|
|
184
|
+
curationItems(): CurationItem[];
|
|
185
|
+
/** Undo one curation item by id: drop the rejection and recompute the working copy. */
|
|
186
|
+
restoreCuration(id: string): void;
|
|
113
187
|
/** The $EDITOR hand-off on the working copy. */
|
|
114
188
|
edit(): void;
|
|
115
189
|
/**
|
|
116
190
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
117
191
|
* Returns the minted annotation id so the view can select the new card.
|
|
118
192
|
*/
|
|
119
|
-
annotate(
|
|
193
|
+
annotate(
|
|
194
|
+
kind: "comment",
|
|
195
|
+
displayIndex: number,
|
|
196
|
+
start: number,
|
|
197
|
+
end: number,
|
|
198
|
+
body: string,
|
|
199
|
+
): string | undefined;
|
|
120
200
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
121
201
|
updateAnnotation(id: string, body: string): void;
|
|
122
202
|
removeAnnotation(id: string): void;
|
|
@@ -161,6 +241,7 @@ class Controller implements ReviewController {
|
|
|
161
241
|
session: null,
|
|
162
242
|
inbox: null,
|
|
163
243
|
status: "",
|
|
244
|
+
toast: null,
|
|
164
245
|
error: null,
|
|
165
246
|
completion: { phase: "idle" },
|
|
166
247
|
editOrphanCount: 0,
|
|
@@ -176,11 +257,20 @@ class Controller implements ReviewController {
|
|
|
176
257
|
private shareRun: object | null = null;
|
|
177
258
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
178
259
|
private derivedFor: ReviewSession | null = null;
|
|
179
|
-
private derived: {
|
|
260
|
+
private derived: {
|
|
261
|
+
display: DisplayBlock[];
|
|
262
|
+
rows: DiffRow[];
|
|
263
|
+
files: WalkFile[];
|
|
264
|
+
/** Per-path parsed diff models, for hunk curation; empty when files absent. */
|
|
265
|
+
models: Map<string, FileDiffMetadata>;
|
|
266
|
+
} = {
|
|
180
267
|
display: [],
|
|
181
268
|
rows: [],
|
|
182
269
|
files: [],
|
|
270
|
+
models: new Map(),
|
|
183
271
|
};
|
|
272
|
+
/** The owner's per-hunk/change reject decisions for the open diff session. */
|
|
273
|
+
private rejections: HunkRejection[] = [];
|
|
184
274
|
|
|
185
275
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
186
276
|
this.readOnly = options.readOnly ?? false;
|
|
@@ -202,7 +292,9 @@ class Controller implements ReviewController {
|
|
|
202
292
|
connect(): void {
|
|
203
293
|
void (async () => {
|
|
204
294
|
try {
|
|
205
|
-
const openClient =
|
|
295
|
+
const openClient =
|
|
296
|
+
this.options.openClient ??
|
|
297
|
+
(() => DaemonClient.connect({ home: this.options.home, autostart: true }));
|
|
206
298
|
const client = await openClient();
|
|
207
299
|
if (this.closed) return void client.close();
|
|
208
300
|
this.client = client;
|
|
@@ -248,16 +340,28 @@ class Controller implements ReviewController {
|
|
|
248
340
|
this.update({ status: message });
|
|
249
341
|
}
|
|
250
342
|
|
|
343
|
+
showToast(body: string, title?: string): void {
|
|
344
|
+
this.update({ toast: { body, title } });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
dismissToast(): void {
|
|
348
|
+
if (this.snapshot.toast) this.update({ toast: null });
|
|
349
|
+
}
|
|
350
|
+
|
|
251
351
|
// ── derived projections ─────────────────────
|
|
252
352
|
private ensureDerived(): void {
|
|
253
353
|
const session = this.snapshot.session;
|
|
254
354
|
if (this.derivedFor === session) return;
|
|
255
355
|
this.derivedFor = session;
|
|
256
|
-
const rows =
|
|
356
|
+
const rows =
|
|
357
|
+
session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
|
|
358
|
+
const models = new Map<string, FileDiffMetadata>();
|
|
359
|
+
for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
|
|
257
360
|
this.derived = {
|
|
258
361
|
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
259
362
|
rows,
|
|
260
363
|
files: walkFiles(rows),
|
|
364
|
+
models,
|
|
261
365
|
};
|
|
262
366
|
}
|
|
263
367
|
|
|
@@ -302,14 +406,17 @@ class Controller implements ReviewController {
|
|
|
302
406
|
|
|
303
407
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
304
408
|
private apply(mutation: Promise<ReviewSession>): void {
|
|
305
|
-
mutation
|
|
306
|
-
|
|
307
|
-
|
|
409
|
+
mutation
|
|
410
|
+
.then((session) => this.update({ session }))
|
|
411
|
+
.catch((error: unknown) =>
|
|
412
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
413
|
+
);
|
|
308
414
|
}
|
|
309
415
|
|
|
310
416
|
// ── verbs ───────────────────────────────────
|
|
311
417
|
open(id: string): void {
|
|
312
418
|
this.locallyViewed.clear();
|
|
419
|
+
this.rejections = [];
|
|
313
420
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
314
421
|
if (cached) this.update({ session: cached });
|
|
315
422
|
else void this.refreshSession(id);
|
|
@@ -319,7 +426,9 @@ class Controller implements ReviewController {
|
|
|
319
426
|
this.client
|
|
320
427
|
?.sessionDelete(id)
|
|
321
428
|
.then(() => this.setStatus("plan deleted"))
|
|
322
|
-
.catch((error: unknown) =>
|
|
429
|
+
.catch((error: unknown) =>
|
|
430
|
+
this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
431
|
+
);
|
|
323
432
|
}
|
|
324
433
|
|
|
325
434
|
setSelfName(name: string): void {
|
|
@@ -335,18 +444,185 @@ class Controller implements ReviewController {
|
|
|
335
444
|
if (!block) return;
|
|
336
445
|
const working = this.working();
|
|
337
446
|
if (block.type === "del") {
|
|
338
|
-
|
|
339
|
-
// restoreBlock returns undefined when the block structure round-trips
|
|
340
|
-
// to the submitted revision - the working copy is gone
|
|
341
|
-
const restored = restoreBlock(session.artifact.content, working, block.base!, line);
|
|
342
|
-
this.setWorkingCopy(restored);
|
|
343
|
-
this.setStatus("cut restored");
|
|
447
|
+
this.restoreDelBlock(block, displayIndex);
|
|
344
448
|
} else if (block.work) {
|
|
345
449
|
this.setWorkingCopy(cutBlock(working, block.work));
|
|
346
450
|
this.setStatus("block cut - it serializes into the diff");
|
|
347
451
|
}
|
|
348
452
|
}
|
|
349
453
|
|
|
454
|
+
/** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
|
|
455
|
+
private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
|
|
456
|
+
const session = this.snapshot.session;
|
|
457
|
+
if (!session || !block.base) return;
|
|
458
|
+
const working = this.working();
|
|
459
|
+
const line = restoreLine(
|
|
460
|
+
nextWorkBlock(this.display(), displayIndex),
|
|
461
|
+
working.split("\n").length,
|
|
462
|
+
);
|
|
463
|
+
// restoreBlock returns undefined when the block structure round-trips to the
|
|
464
|
+
// submitted revision - the working copy is back to pristine and is dropped
|
|
465
|
+
const restored = restoreBlock(session.artifact.content, working, block.base, line);
|
|
466
|
+
this.setWorkingCopy(restored);
|
|
467
|
+
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── diff hunk curation ──────────────────────
|
|
471
|
+
/** The parsed model and row for a curation action, or null with a status set. */
|
|
472
|
+
private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
|
|
473
|
+
const session = this.snapshot.session;
|
|
474
|
+
if (!session || session.status === "resolved") return null;
|
|
475
|
+
if (!session.artifact.files) {
|
|
476
|
+
this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const row = this.rows()[rowIndex];
|
|
480
|
+
if (!row || row.kind === "file" || row.kind === "hunk") {
|
|
481
|
+
this.setStatus("move to a code line to curate");
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const model = this.derived.models.get(row.file);
|
|
485
|
+
if (!model) return null;
|
|
486
|
+
return { row, model };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
toggleRejectHunk(rowIndex: number): void {
|
|
490
|
+
const located = this.curationRow(rowIndex);
|
|
491
|
+
if (!located) return;
|
|
492
|
+
const target = hunkRejectionForRow(located.row.file, located.model, located.row);
|
|
493
|
+
if (!target) return this.setStatus("no hunk under the cursor");
|
|
494
|
+
const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
|
|
495
|
+
if (this.rejections.some(wholeHunk)) {
|
|
496
|
+
this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
|
|
497
|
+
this.setStatus("hunk restored");
|
|
498
|
+
} else {
|
|
499
|
+
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
500
|
+
this.rejections = this.rejections.filter(
|
|
501
|
+
(rejection) =>
|
|
502
|
+
!(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
|
|
503
|
+
);
|
|
504
|
+
this.rejections.push(target);
|
|
505
|
+
this.setStatus("hunk rejected - dropped from the working copy");
|
|
506
|
+
}
|
|
507
|
+
this.recomputeCuration();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
toggleRejectChange(rowIndex: number): void {
|
|
511
|
+
const located = this.curationRow(rowIndex);
|
|
512
|
+
if (!located) return;
|
|
513
|
+
if (located.row.kind !== "add" && located.row.kind !== "del")
|
|
514
|
+
return this.setStatus("move to a changed line to reject a change");
|
|
515
|
+
const target = changeRejectionForRow(located.row.file, located.model, located.row);
|
|
516
|
+
if (!target) return this.setStatus("no change under the cursor");
|
|
517
|
+
const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
|
|
518
|
+
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
519
|
+
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
520
|
+
this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
|
|
521
|
+
this.setStatus("change restored");
|
|
522
|
+
} else {
|
|
523
|
+
this.rejections.push(target);
|
|
524
|
+
this.setStatus("change rejected - dropped from the working copy");
|
|
525
|
+
}
|
|
526
|
+
this.recomputeCuration();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Recompute the curated patch and push it as the working copy (or clear it). */
|
|
530
|
+
private recomputeCuration(): void {
|
|
531
|
+
const files = this.snapshot.session?.artifact.files;
|
|
532
|
+
if (!files) return;
|
|
533
|
+
// no decisions left = the working copy reverts to the full submitted diff
|
|
534
|
+
this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
|
|
535
|
+
// dimming reads `rejections` directly; a bare update forces the re-read
|
|
536
|
+
this.update({});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
rejectedRows(): Set<number> {
|
|
540
|
+
this.ensureDerived();
|
|
541
|
+
if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
|
|
542
|
+
const rejected = new Set<number>();
|
|
543
|
+
this.derived.rows.forEach((row, index) => {
|
|
544
|
+
const model = this.derived.models.get(row.file);
|
|
545
|
+
if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
|
|
546
|
+
});
|
|
547
|
+
return rejected;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
curationItems(): CurationItem[] {
|
|
551
|
+
this.ensureDerived();
|
|
552
|
+
const session = this.snapshot.session;
|
|
553
|
+
if (!session) return EMPTY_CURATION_ITEMS;
|
|
554
|
+
return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
restoreCuration(id: string): void {
|
|
558
|
+
// diff rejections drop from the reject list; a plan cut re-inserts its block
|
|
559
|
+
if (id.startsWith("diff:")) {
|
|
560
|
+
const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
|
|
561
|
+
if (kept.length === this.rejections.length) return;
|
|
562
|
+
this.rejections = kept;
|
|
563
|
+
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
564
|
+
this.recomputeCuration();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const display = this.display();
|
|
568
|
+
const displayIndex = display.findIndex(
|
|
569
|
+
(block) => block.type === "del" && block.base && planCutId(block.base) === id,
|
|
570
|
+
);
|
|
571
|
+
if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The curated-out diff rejections as removal cards (newest decisions last). */
|
|
575
|
+
private diffCurationItems(): CurationItem[] {
|
|
576
|
+
if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
|
|
577
|
+
const items: CurationItem[] = [];
|
|
578
|
+
for (const rejection of this.rejections) {
|
|
579
|
+
const rows = this.rowsForRejection(rejection);
|
|
580
|
+
const firstRow = rows[0];
|
|
581
|
+
items.push({
|
|
582
|
+
id: curationItemId(rejection),
|
|
583
|
+
source: "diff",
|
|
584
|
+
preview: rows.map(
|
|
585
|
+
(row) => `${row.kind === "add" ? "+" : "-"} ${row.text.replace(/\n$/, "")}`,
|
|
586
|
+
),
|
|
587
|
+
revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return items;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** The cut plan blocks as removal cards, in document order. */
|
|
594
|
+
private planCurationItems(): CurationItem[] {
|
|
595
|
+
const items: CurationItem[] = [];
|
|
596
|
+
this.derived.display.forEach((block, displayIndex) => {
|
|
597
|
+
if (block.type !== "del" || !block.base) return;
|
|
598
|
+
items.push({
|
|
599
|
+
id: planCutId(block.base),
|
|
600
|
+
source: "plan",
|
|
601
|
+
preview: block.base.text.split("\n"),
|
|
602
|
+
revealIndex: displayIndex,
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
return items.length ? items : EMPTY_CURATION_ITEMS;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The change/deletion rows a rejection covers, for its preview and reveal row. */
|
|
609
|
+
private rowsForRejection(rejection: HunkRejection): DiffRow[] {
|
|
610
|
+
const model = this.derived.models.get(rejection.path);
|
|
611
|
+
if (!model) return [];
|
|
612
|
+
const rows: DiffRow[] = [];
|
|
613
|
+
for (const row of this.derived.rows) {
|
|
614
|
+
if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
|
|
615
|
+
if (rejection.changeIndex === undefined) {
|
|
616
|
+
const target = hunkRejectionForRow(rejection.path, model, row);
|
|
617
|
+
if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
|
|
618
|
+
} else {
|
|
619
|
+
const target = changeRejectionForRow(rejection.path, model, row);
|
|
620
|
+
if (target && sameRejection(target, rejection)) rows.push(row);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return rows;
|
|
624
|
+
}
|
|
625
|
+
|
|
350
626
|
edit(): void {
|
|
351
627
|
const session = this.snapshot.session;
|
|
352
628
|
if (!session || session.status === "resolved") return;
|
|
@@ -377,7 +653,7 @@ class Controller implements ReviewController {
|
|
|
377
653
|
}
|
|
378
654
|
|
|
379
655
|
annotate(
|
|
380
|
-
kind: "comment"
|
|
656
|
+
kind: "comment",
|
|
381
657
|
displayIndex: number,
|
|
382
658
|
start: number,
|
|
383
659
|
end: number,
|
|
@@ -391,14 +667,15 @@ class Controller implements ReviewController {
|
|
|
391
667
|
} else {
|
|
392
668
|
const display = this.display();
|
|
393
669
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
394
|
-
const workBlockIndex =
|
|
670
|
+
const workBlockIndex =
|
|
671
|
+
display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
395
672
|
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
396
673
|
}
|
|
397
674
|
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
398
675
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
399
676
|
this.apply(persisted);
|
|
400
677
|
this.mirrorAnnotation(persisted, wire);
|
|
401
|
-
this.setStatus(
|
|
678
|
+
this.setStatus("comment added");
|
|
402
679
|
return wire.id;
|
|
403
680
|
}
|
|
404
681
|
|
|
@@ -416,7 +693,10 @@ class Controller implements ReviewController {
|
|
|
416
693
|
}
|
|
417
694
|
|
|
418
695
|
// push only after the local write lands, so a rejected write never leaks to the share
|
|
419
|
-
private mirrorAnnotation(
|
|
696
|
+
private mirrorAnnotation(
|
|
697
|
+
persisted: Promise<ReviewSession>,
|
|
698
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
699
|
+
): void {
|
|
420
700
|
const shareId = this.snapshot.session?.shareId;
|
|
421
701
|
if (!shareId) return;
|
|
422
702
|
void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
|
|
@@ -492,7 +772,11 @@ class Controller implements ReviewController {
|
|
|
492
772
|
for (const exporter of this.exporters) {
|
|
493
773
|
if (!exporter.runsOn(verdict)) continue;
|
|
494
774
|
void exporter.run(resolved).then((exportResult) => {
|
|
495
|
-
this.setStatus(
|
|
775
|
+
this.setStatus(
|
|
776
|
+
exportResult.success && exportResult.path
|
|
777
|
+
? `exported to ${exportResult.path}`
|
|
778
|
+
: `export failed: ${exportResult.error ?? "unknown"}`,
|
|
779
|
+
);
|
|
496
780
|
});
|
|
497
781
|
}
|
|
498
782
|
// Hand the reviewer back to the agent. The default is a visible
|
|
@@ -503,7 +787,9 @@ class Controller implements ReviewController {
|
|
|
503
787
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
504
788
|
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
505
789
|
})
|
|
506
|
-
.catch((error: unknown) =>
|
|
790
|
+
.catch((error: unknown) =>
|
|
791
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
792
|
+
);
|
|
507
793
|
}
|
|
508
794
|
|
|
509
795
|
share(): void {
|
|
@@ -515,9 +801,12 @@ class Controller implements ReviewController {
|
|
|
515
801
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
516
802
|
const shareId = shareIdFromLine(line);
|
|
517
803
|
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
518
|
-
this.setStatus(
|
|
804
|
+
this.setStatus("");
|
|
805
|
+
this.showToast(line, copied ? "share link copied" : "share link");
|
|
519
806
|
})
|
|
520
|
-
.catch((error: unknown) =>
|
|
807
|
+
.catch((error: unknown) =>
|
|
808
|
+
this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
809
|
+
);
|
|
521
810
|
}
|
|
522
811
|
|
|
523
812
|
/**
|
|
@@ -532,7 +821,10 @@ class Controller implements ReviewController {
|
|
|
532
821
|
const client = this.client;
|
|
533
822
|
return pullShare(session.shareId)
|
|
534
823
|
.then((remote) =>
|
|
535
|
-
client.sessionMergeShared(session.id, {
|
|
824
|
+
client.sessionMergeShared(session.id, {
|
|
825
|
+
annotations: collaboratorAnnotations(remote),
|
|
826
|
+
participants: remote.participants,
|
|
827
|
+
}),
|
|
536
828
|
)
|
|
537
829
|
.then(() => {})
|
|
538
830
|
.catch(() => {});
|
|
@@ -550,7 +842,8 @@ class Controller implements ReviewController {
|
|
|
550
842
|
this.shareRun = run;
|
|
551
843
|
const tick = (): void => {
|
|
552
844
|
void this.pullShared().finally(() => {
|
|
553
|
-
if (this.shareRun === run && !this.closed)
|
|
845
|
+
if (this.shareRun === run && !this.closed)
|
|
846
|
+
this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
|
|
554
847
|
});
|
|
555
848
|
};
|
|
556
849
|
tick();
|
package/src/share.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* Share button, so the two share exactly the same path.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SHARE_HOST,
|
|
11
|
+
DEFAULT_SHARE_PORT,
|
|
12
|
+
SHARE_UPLOAD_USER,
|
|
13
|
+
packSessionBlob,
|
|
14
|
+
} from "@cueloop/daemon/share-blob";
|
|
10
15
|
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
11
16
|
import { copyToClipboard } from "./clipboard";
|
|
12
17
|
|
|
@@ -28,9 +33,17 @@ export function shareIdFromLine(line: string): string | undefined {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
31
|
-
export async function publishShare(
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
export async function publishShare(
|
|
37
|
+
session: ReviewSession,
|
|
38
|
+
target: ShareTarget = {},
|
|
39
|
+
): Promise<ShareResult> {
|
|
40
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
41
|
+
"cueloop-share",
|
|
42
|
+
packSessionBlob(session),
|
|
43
|
+
target,
|
|
44
|
+
);
|
|
45
|
+
if (code !== 0)
|
|
46
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
34
47
|
const line = stdout.trim();
|
|
35
48
|
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
36
49
|
const copied = await copyToClipboard(line);
|
|
@@ -58,17 +71,37 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
|
|
|
58
71
|
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
59
72
|
* unauthored (the planner's), unioned by id.
|
|
60
73
|
*/
|
|
61
|
-
export async function pushShare(
|
|
62
|
-
|
|
74
|
+
export async function pushShare(
|
|
75
|
+
shareId: string,
|
|
76
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
77
|
+
target: ShareTarget = {},
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const { stderr, code } = await runShareSsh(
|
|
80
|
+
"cueloop-push",
|
|
81
|
+
Buffer.from(JSON.stringify({ shareId, annotations })),
|
|
82
|
+
target,
|
|
83
|
+
);
|
|
63
84
|
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
67
|
-
async function runShareSsh(
|
|
88
|
+
async function runShareSsh(
|
|
89
|
+
command: string,
|
|
90
|
+
stdin: Buffer,
|
|
91
|
+
target: ShareTarget,
|
|
92
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
68
93
|
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
69
94
|
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
70
95
|
const proc = Bun.spawn(
|
|
71
|
-
[
|
|
96
|
+
[
|
|
97
|
+
"ssh",
|
|
98
|
+
"-p",
|
|
99
|
+
String(port),
|
|
100
|
+
"-o",
|
|
101
|
+
"StrictHostKeyChecking=accept-new",
|
|
102
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
103
|
+
command,
|
|
104
|
+
],
|
|
72
105
|
{ stdin, stdout: "pipe", stderr: "pipe" },
|
|
73
106
|
);
|
|
74
107
|
const [stdout, stderr, code] = await Promise.all([
|