@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35
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 +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- 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 +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- 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 +36 -14
- 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 +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- 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
|
@@ -8,13 +8,16 @@ import type { SessionClient } from "@cueloop/daemon/client";
|
|
|
8
8
|
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
9
9
|
let remote: ReviewSession;
|
|
10
10
|
const pullShare = mock(async () => remote);
|
|
11
|
-
const pushShare = mock(
|
|
11
|
+
const pushShare = mock(
|
|
12
|
+
async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
|
|
13
|
+
);
|
|
12
14
|
mock.module("./share", () => ({
|
|
13
15
|
publishShare,
|
|
14
16
|
pullShare,
|
|
15
17
|
pushShare,
|
|
16
18
|
shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
|
|
17
|
-
collaboratorAnnotations: (session: ReviewSession) =>
|
|
19
|
+
collaboratorAnnotations: (session: ReviewSession) =>
|
|
20
|
+
session.annotations.filter((annotation) => annotation.author),
|
|
18
21
|
}));
|
|
19
22
|
|
|
20
23
|
const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
|
|
@@ -35,7 +38,14 @@ function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
function annotation(id: string, author: string): Annotation {
|
|
38
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
kind: "comment",
|
|
44
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
45
|
+
body: "note",
|
|
46
|
+
author,
|
|
47
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
48
|
+
};
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
function fakeClient(session: ReviewSession): SessionClient {
|
|
@@ -45,10 +55,13 @@ function fakeClient(session: ReviewSession): SessionClient {
|
|
|
45
55
|
sessionGet: async () => session,
|
|
46
56
|
sessionList: async () => [session],
|
|
47
57
|
sessionAnnotate: mock(async () => session),
|
|
48
|
-
sessionSetShareId: mock(
|
|
58
|
+
sessionSetShareId: mock(
|
|
59
|
+
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
60
|
+
),
|
|
49
61
|
sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
|
|
50
62
|
const known = new Set(session.annotations.map((existing) => existing.id));
|
|
51
|
-
for (const note of incoming.annotations)
|
|
63
|
+
for (const note of incoming.annotations)
|
|
64
|
+
if (!known.has(note.id)) session.annotations.push(note);
|
|
52
65
|
return session;
|
|
53
66
|
}),
|
|
54
67
|
close: () => {},
|
|
@@ -62,7 +75,11 @@ async function connectedController(
|
|
|
62
75
|
clock?: ManualClock,
|
|
63
76
|
): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
|
|
64
77
|
const client = fakeClient(session);
|
|
65
|
-
const controller = createReviewController({
|
|
78
|
+
const controller = createReviewController({
|
|
79
|
+
sessionId: session.id,
|
|
80
|
+
openClient: async () => client,
|
|
81
|
+
clock,
|
|
82
|
+
});
|
|
66
83
|
controller.connect();
|
|
67
84
|
await tick();
|
|
68
85
|
return { controller, client };
|
|
@@ -90,7 +107,10 @@ describe("share", () => {
|
|
|
90
107
|
await tick();
|
|
91
108
|
|
|
92
109
|
// Assert
|
|
93
|
-
expect(controller.getSnapshot().toast).toEqual({
|
|
110
|
+
expect(controller.getSnapshot().toast).toEqual({
|
|
111
|
+
body: "ssh p_abc123xy@cueloop.dev",
|
|
112
|
+
title: "share link copied",
|
|
113
|
+
});
|
|
94
114
|
expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
|
|
95
115
|
});
|
|
96
116
|
});
|
|
@@ -102,7 +122,9 @@ describe("pullShared", () => {
|
|
|
102
122
|
annotations: [annotation("a1", "SHA256:mate")],
|
|
103
123
|
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
104
124
|
});
|
|
105
|
-
const { controller, client } = await connectedController(
|
|
125
|
+
const { controller, client } = await connectedController(
|
|
126
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
127
|
+
);
|
|
106
128
|
|
|
107
129
|
// Act
|
|
108
130
|
controller.pullShared();
|
|
@@ -135,7 +157,9 @@ describe("mirror on annotate", () => {
|
|
|
135
157
|
test("pushes an edited note up when the plan is shared", async () => {
|
|
136
158
|
// Arrange
|
|
137
159
|
pushShare.mockClear();
|
|
138
|
-
const { controller } = await connectedController(
|
|
160
|
+
const { controller } = await connectedController(
|
|
161
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
162
|
+
);
|
|
139
163
|
|
|
140
164
|
// Act
|
|
141
165
|
controller.updateAnnotation("a1", "revised body");
|
|
@@ -144,13 +168,22 @@ describe("mirror on annotate", () => {
|
|
|
144
168
|
// Assert
|
|
145
169
|
expect(pushShare).toHaveBeenCalledTimes(1);
|
|
146
170
|
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
147
|
-
expect(pushShare.mock.calls[0]?.[1]).toEqual([
|
|
171
|
+
expect(pushShare.mock.calls[0]?.[1]).toEqual([
|
|
172
|
+
{
|
|
173
|
+
id: "a1",
|
|
174
|
+
kind: "comment",
|
|
175
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
176
|
+
body: "revised body",
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
148
179
|
});
|
|
149
180
|
|
|
150
181
|
test("does not push when the plan was never shared", async () => {
|
|
151
182
|
// Arrange
|
|
152
183
|
pushShare.mockClear();
|
|
153
|
-
const { controller } = await connectedController(
|
|
184
|
+
const { controller } = await connectedController(
|
|
185
|
+
sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
|
|
186
|
+
);
|
|
154
187
|
|
|
155
188
|
// Act
|
|
156
189
|
controller.updateAnnotation("a1", "revised body");
|
|
@@ -163,7 +196,9 @@ describe("mirror on annotate", () => {
|
|
|
163
196
|
test("does not push when the local write is rejected", async () => {
|
|
164
197
|
// Arrange - the daemon write fails (e.g. a resolved session)
|
|
165
198
|
pushShare.mockClear();
|
|
166
|
-
const { controller, client } = await connectedController(
|
|
199
|
+
const { controller, client } = await connectedController(
|
|
200
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
201
|
+
);
|
|
167
202
|
(client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
|
168
203
|
throw new Error("session is resolved");
|
|
169
204
|
});
|
|
@@ -183,7 +218,10 @@ describe("startSharePoll", () => {
|
|
|
183
218
|
pullShare.mockClear();
|
|
184
219
|
remote = sessionFixture({ annotations: [] });
|
|
185
220
|
const clock = new ManualClock();
|
|
186
|
-
const { controller } = await connectedController(
|
|
221
|
+
const { controller } = await connectedController(
|
|
222
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
223
|
+
clock,
|
|
224
|
+
);
|
|
187
225
|
|
|
188
226
|
// Act - immediate pull
|
|
189
227
|
const stop = controller.startSharePoll();
|
|
@@ -213,9 +251,14 @@ describe("startSharePoll", () => {
|
|
|
213
251
|
pullShare.mockClear();
|
|
214
252
|
remote = sessionFixture({ annotations: [] });
|
|
215
253
|
let release: () => void = () => {};
|
|
216
|
-
pullShare.mockImplementationOnce(
|
|
254
|
+
pullShare.mockImplementationOnce(
|
|
255
|
+
() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
|
|
256
|
+
);
|
|
217
257
|
const clock = new ManualClock();
|
|
218
|
-
const { controller } = await connectedController(
|
|
258
|
+
const { controller } = await connectedController(
|
|
259
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
260
|
+
clock,
|
|
261
|
+
);
|
|
219
262
|
|
|
220
263
|
// Act - start (pull is in flight), leave, then let the pull settle
|
|
221
264
|
const stop = controller.startSharePoll();
|
|
@@ -236,9 +279,14 @@ describe("startSharePoll", () => {
|
|
|
236
279
|
pullShare.mockClear();
|
|
237
280
|
remote = sessionFixture({ annotations: [] });
|
|
238
281
|
let release: () => void = () => {};
|
|
239
|
-
pullShare.mockImplementationOnce(
|
|
282
|
+
pullShare.mockImplementationOnce(
|
|
283
|
+
() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
|
|
284
|
+
);
|
|
240
285
|
const clock = new ManualClock();
|
|
241
|
-
const { controller } = await connectedController(
|
|
286
|
+
const { controller } = await connectedController(
|
|
287
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
288
|
+
clock,
|
|
289
|
+
);
|
|
242
290
|
|
|
243
291
|
// Act - run A starts (pull hangs), then run B restarts while A is in flight
|
|
244
292
|
controller.startSharePoll();
|
|
@@ -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,11 +73,44 @@ 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
|
+
|
|
53
85
|
export interface ToastState {
|
|
54
86
|
title?: string;
|
|
55
87
|
body: string;
|
|
56
88
|
}
|
|
57
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
|
+
|
|
58
114
|
export interface ControllerSnapshot {
|
|
59
115
|
session: ReviewSession | null;
|
|
60
116
|
inbox: ReviewSession[] | null;
|
|
@@ -118,13 +174,29 @@ export interface ReviewController {
|
|
|
118
174
|
setSelfName(name: string): void;
|
|
119
175
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
120
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;
|
|
121
187
|
/** The $EDITOR hand-off on the working copy. */
|
|
122
188
|
edit(): void;
|
|
123
189
|
/**
|
|
124
190
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
125
191
|
* Returns the minted annotation id so the view can select the new card.
|
|
126
192
|
*/
|
|
127
|
-
annotate(
|
|
193
|
+
annotate(
|
|
194
|
+
kind: "comment",
|
|
195
|
+
displayIndex: number,
|
|
196
|
+
start: number,
|
|
197
|
+
end: number,
|
|
198
|
+
body: string,
|
|
199
|
+
): string | undefined;
|
|
128
200
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
129
201
|
updateAnnotation(id: string, body: string): void;
|
|
130
202
|
removeAnnotation(id: string): void;
|
|
@@ -185,11 +257,20 @@ class Controller implements ReviewController {
|
|
|
185
257
|
private shareRun: object | null = null;
|
|
186
258
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
187
259
|
private derivedFor: ReviewSession | null = null;
|
|
188
|
-
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
|
+
} = {
|
|
189
267
|
display: [],
|
|
190
268
|
rows: [],
|
|
191
269
|
files: [],
|
|
270
|
+
models: new Map(),
|
|
192
271
|
};
|
|
272
|
+
/** The owner's per-hunk/change reject decisions for the open diff session. */
|
|
273
|
+
private rejections: HunkRejection[] = [];
|
|
193
274
|
|
|
194
275
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
195
276
|
this.readOnly = options.readOnly ?? false;
|
|
@@ -211,7 +292,9 @@ class Controller implements ReviewController {
|
|
|
211
292
|
connect(): void {
|
|
212
293
|
void (async () => {
|
|
213
294
|
try {
|
|
214
|
-
const openClient =
|
|
295
|
+
const openClient =
|
|
296
|
+
this.options.openClient ??
|
|
297
|
+
(() => DaemonClient.connect({ home: this.options.home, autostart: true }));
|
|
215
298
|
const client = await openClient();
|
|
216
299
|
if (this.closed) return void client.close();
|
|
217
300
|
this.client = client;
|
|
@@ -270,11 +353,15 @@ class Controller implements ReviewController {
|
|
|
270
353
|
const session = this.snapshot.session;
|
|
271
354
|
if (this.derivedFor === session) return;
|
|
272
355
|
this.derivedFor = session;
|
|
273
|
-
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));
|
|
274
360
|
this.derived = {
|
|
275
361
|
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
276
362
|
rows,
|
|
277
363
|
files: walkFiles(rows),
|
|
364
|
+
models,
|
|
278
365
|
};
|
|
279
366
|
}
|
|
280
367
|
|
|
@@ -319,14 +406,17 @@ class Controller implements ReviewController {
|
|
|
319
406
|
|
|
320
407
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
321
408
|
private apply(mutation: Promise<ReviewSession>): void {
|
|
322
|
-
mutation
|
|
323
|
-
|
|
324
|
-
|
|
409
|
+
mutation
|
|
410
|
+
.then((session) => this.update({ session }))
|
|
411
|
+
.catch((error: unknown) =>
|
|
412
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
413
|
+
);
|
|
325
414
|
}
|
|
326
415
|
|
|
327
416
|
// ── verbs ───────────────────────────────────
|
|
328
417
|
open(id: string): void {
|
|
329
418
|
this.locallyViewed.clear();
|
|
419
|
+
this.rejections = [];
|
|
330
420
|
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
331
421
|
if (cached) this.update({ session: cached });
|
|
332
422
|
else void this.refreshSession(id);
|
|
@@ -336,7 +426,9 @@ class Controller implements ReviewController {
|
|
|
336
426
|
this.client
|
|
337
427
|
?.sessionDelete(id)
|
|
338
428
|
.then(() => this.setStatus("plan deleted"))
|
|
339
|
-
.catch((error: unknown) =>
|
|
429
|
+
.catch((error: unknown) =>
|
|
430
|
+
this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
431
|
+
);
|
|
340
432
|
}
|
|
341
433
|
|
|
342
434
|
setSelfName(name: string): void {
|
|
@@ -352,18 +444,185 @@ class Controller implements ReviewController {
|
|
|
352
444
|
if (!block) return;
|
|
353
445
|
const working = this.working();
|
|
354
446
|
if (block.type === "del") {
|
|
355
|
-
|
|
356
|
-
// restoreBlock returns undefined when the block structure round-trips
|
|
357
|
-
// to the submitted revision - the working copy is gone
|
|
358
|
-
const restored = restoreBlock(session.artifact.content, working, block.base!, line);
|
|
359
|
-
this.setWorkingCopy(restored);
|
|
360
|
-
this.setStatus("cut restored");
|
|
447
|
+
this.restoreDelBlock(block, displayIndex);
|
|
361
448
|
} else if (block.work) {
|
|
362
449
|
this.setWorkingCopy(cutBlock(working, block.work));
|
|
363
450
|
this.setStatus("block cut - it serializes into the diff");
|
|
364
451
|
}
|
|
365
452
|
}
|
|
366
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
|
+
|
|
367
626
|
edit(): void {
|
|
368
627
|
const session = this.snapshot.session;
|
|
369
628
|
if (!session || session.status === "resolved") return;
|
|
@@ -394,7 +653,7 @@ class Controller implements ReviewController {
|
|
|
394
653
|
}
|
|
395
654
|
|
|
396
655
|
annotate(
|
|
397
|
-
kind: "comment"
|
|
656
|
+
kind: "comment",
|
|
398
657
|
displayIndex: number,
|
|
399
658
|
start: number,
|
|
400
659
|
end: number,
|
|
@@ -408,14 +667,15 @@ class Controller implements ReviewController {
|
|
|
408
667
|
} else {
|
|
409
668
|
const display = this.display();
|
|
410
669
|
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
411
|
-
const workBlockIndex =
|
|
670
|
+
const workBlockIndex =
|
|
671
|
+
display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
412
672
|
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
413
673
|
}
|
|
414
674
|
const wire = { id: newAnnotationId(), kind, anchor, body };
|
|
415
675
|
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
416
676
|
this.apply(persisted);
|
|
417
677
|
this.mirrorAnnotation(persisted, wire);
|
|
418
|
-
this.setStatus(
|
|
678
|
+
this.setStatus("comment added");
|
|
419
679
|
return wire.id;
|
|
420
680
|
}
|
|
421
681
|
|
|
@@ -433,7 +693,10 @@ class Controller implements ReviewController {
|
|
|
433
693
|
}
|
|
434
694
|
|
|
435
695
|
// push only after the local write lands, so a rejected write never leaks to the share
|
|
436
|
-
private mirrorAnnotation(
|
|
696
|
+
private mirrorAnnotation(
|
|
697
|
+
persisted: Promise<ReviewSession>,
|
|
698
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
699
|
+
): void {
|
|
437
700
|
const shareId = this.snapshot.session?.shareId;
|
|
438
701
|
if (!shareId) return;
|
|
439
702
|
void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
|
|
@@ -509,7 +772,11 @@ class Controller implements ReviewController {
|
|
|
509
772
|
for (const exporter of this.exporters) {
|
|
510
773
|
if (!exporter.runsOn(verdict)) continue;
|
|
511
774
|
void exporter.run(resolved).then((exportResult) => {
|
|
512
|
-
this.setStatus(
|
|
775
|
+
this.setStatus(
|
|
776
|
+
exportResult.success && exportResult.path
|
|
777
|
+
? `exported to ${exportResult.path}`
|
|
778
|
+
: `export failed: ${exportResult.error ?? "unknown"}`,
|
|
779
|
+
);
|
|
513
780
|
});
|
|
514
781
|
}
|
|
515
782
|
// Hand the reviewer back to the agent. The default is a visible
|
|
@@ -520,7 +787,9 @@ class Controller implements ReviewController {
|
|
|
520
787
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
521
788
|
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
522
789
|
})
|
|
523
|
-
.catch((error: unknown) =>
|
|
790
|
+
.catch((error: unknown) =>
|
|
791
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
792
|
+
);
|
|
524
793
|
}
|
|
525
794
|
|
|
526
795
|
share(): void {
|
|
@@ -532,10 +801,12 @@ class Controller implements ReviewController {
|
|
|
532
801
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
533
802
|
const shareId = shareIdFromLine(line);
|
|
534
803
|
if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
|
|
535
|
-
this.setStatus(
|
|
804
|
+
this.setStatus("");
|
|
536
805
|
this.showToast(line, copied ? "share link copied" : "share link");
|
|
537
806
|
})
|
|
538
|
-
.catch((error: unknown) =>
|
|
807
|
+
.catch((error: unknown) =>
|
|
808
|
+
this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
809
|
+
);
|
|
539
810
|
}
|
|
540
811
|
|
|
541
812
|
/**
|
|
@@ -550,7 +821,10 @@ class Controller implements ReviewController {
|
|
|
550
821
|
const client = this.client;
|
|
551
822
|
return pullShare(session.shareId)
|
|
552
823
|
.then((remote) =>
|
|
553
|
-
client.sessionMergeShared(session.id, {
|
|
824
|
+
client.sessionMergeShared(session.id, {
|
|
825
|
+
annotations: collaboratorAnnotations(remote),
|
|
826
|
+
participants: remote.participants,
|
|
827
|
+
}),
|
|
554
828
|
)
|
|
555
829
|
.then(() => {})
|
|
556
830
|
.catch(() => {});
|
|
@@ -568,7 +842,8 @@ class Controller implements ReviewController {
|
|
|
568
842
|
this.shareRun = run;
|
|
569
843
|
const tick = (): void => {
|
|
570
844
|
void this.pullShared().finally(() => {
|
|
571
|
-
if (this.shareRun === run && !this.closed)
|
|
845
|
+
if (this.shareRun === run && !this.closed)
|
|
846
|
+
this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
|
|
572
847
|
});
|
|
573
848
|
};
|
|
574
849
|
tick();
|