@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23

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.
@@ -1,8 +1,4 @@
1
- /**
2
- * The keyboard grammar as data: every mode x key produces a known intent
3
- * list, the read-only rule is one gate, and plan and diff reviews share one
4
- * path for annotation navigation, deletion, and submit.
5
- */
1
+ /** The keyboard grammar as data: every mode x key maps to a known intent list, gated once by the read-only rule. */
6
2
 
7
3
  import { describe, expect, test } from "bun:test";
8
4
  import { DEFAULT_KEYS } from "./config";
@@ -30,6 +26,7 @@ function key(name: string, shift = false, meta = false): { name: string; shift:
30
26
  }
31
27
 
32
28
  describe("compose overlay", () => {
29
+ // Arrange
33
30
  const keyState = state({ overlay: "compose" });
34
31
  const table: [string, Intent[]][] = [
35
32
  ["escape", [{ type: "closeOverlay" }]],
@@ -57,6 +54,7 @@ describe("compose overlay", () => {
57
54
  });
58
55
 
59
56
  describe("submit overlay", () => {
57
+ // Arrange
60
58
  const keyState = state({ overlay: "submit" });
61
59
  const table: [string, Intent[]][] = [
62
60
  ["escape", [{ type: "closeOverlay" }]],
@@ -75,6 +73,7 @@ describe("submit overlay", () => {
75
73
  });
76
74
 
77
75
  describe("completion overlay", () => {
76
+ // Arrange
78
77
  const prompt = state({ overlay: "completion-prompt" });
79
78
  const counting = state({ overlay: "completion-counting" });
80
79
  const table: [KeyState, string, Intent[]][] = [
@@ -97,6 +96,7 @@ describe("completion overlay", () => {
97
96
  });
98
97
 
99
98
  describe("inbox mode", () => {
99
+ // Arrange
100
100
  const keyState = state({ view: "inbox" });
101
101
  const table: [string, Intent[]][] = [
102
102
  ["j", [{ type: "inboxMove", to: "down" }]],
@@ -116,7 +116,10 @@ describe("inbox mode", () => {
116
116
  }
117
117
 
118
118
  test("an empty inbox swallows every non-quit key", () => {
119
+ // Arrange
119
120
  const empty = state({ view: "inbox", hasInboxItems: false });
121
+
122
+ // Assert
120
123
  for (const name of ["j", "k", "down", "up", "return", "enter"]) {
121
124
  expect(reduceKey(empty, key(name))).toEqual([]);
122
125
  }
@@ -124,7 +127,63 @@ describe("inbox mode", () => {
124
127
  });
125
128
  });
126
129
 
130
+ describe("collaborator (share) capabilities", () => {
131
+ test("can annotate: comment opens the composer", () => {
132
+ // Arrange
133
+ const collab = state({ canEditPlan: false, canSubmitVerdict: false });
134
+
135
+ // Act / Assert
136
+ expect(reduceKey(collab, key("c"), "comment")).toEqual([{ type: "openCompose", kind: "comment", from: "cursor" }]);
137
+ });
138
+
139
+ test("can edit their own card: edit with a focused annotation rewrites it", () => {
140
+ // Arrange
141
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
142
+
143
+ // Act / Assert
144
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
145
+ });
146
+
147
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
148
+ // Arrange
149
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
150
+
151
+ // Act / Assert
152
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
153
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
154
+ });
155
+
156
+ test("cannot submit a verdict: there is no agent on a share", () => {
157
+ // Arrange
158
+ const collab = state({ canSubmitVerdict: false });
159
+
160
+ // Act / Assert
161
+ expect(reduceKey(collab, key("x"), "submit")).toEqual([{ type: "status", message: "shared view - your notes save as you go; q to leave" }]);
162
+ });
163
+
164
+ test("cannot re-share: only the owner shares", () => {
165
+ // Arrange
166
+ const collab = state({ canShare: false });
167
+
168
+ // Act / Assert
169
+ expect(reduceKey(collab, key("S", true), "share")).toEqual([{ type: "status", message: "only the plan owner can share" }]);
170
+ });
171
+ });
172
+
173
+ describe("share", () => {
174
+ test("the owner's share key publishes", () => {
175
+ // Act / Assert
176
+ expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
177
+ });
178
+
179
+ test("an observer cannot share", () => {
180
+ // Act / Assert
181
+ expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([{ type: "status", message: "observer - read-only" }]);
182
+ });
183
+ });
184
+
127
185
  describe("plan normal mode", () => {
186
+ // Arrange
128
187
  const keyState = state();
129
188
  const table: [string, boolean, Intent[]][] = [
130
189
  ["j", false, [{ type: "move", to: "down" }]],
@@ -154,7 +213,10 @@ describe("plan normal mode", () => {
154
213
  }
155
214
 
156
215
  test("resolved sessions guard the mutating verbs", () => {
216
+ // Arrange
157
217
  const resolvedState = state({ resolved: true });
218
+
219
+ // Assert
158
220
  for (const name of ["c", "s", "x", "e"]) {
159
221
  expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
160
222
  }
@@ -163,27 +225,37 @@ describe("plan normal mode", () => {
163
225
  });
164
226
 
165
227
  test("cut text cannot host a comment or a span", () => {
228
+ // Arrange
166
229
  const cut = state({ cursorAnnotatable: false });
230
+
231
+ // Assert
167
232
  expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
168
233
  expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
169
234
  expect(reduceKey(cut, key("v"))).toEqual([]);
170
235
  });
171
236
 
172
237
  test("annotation navigation without annotations reports, delete without focus is silent", () => {
238
+ // Arrange
173
239
  const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
240
+
241
+ // Assert
174
242
  expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
175
243
  expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
176
244
  expect(reduceKey(none, key("backspace"))).toEqual([]);
177
245
  });
178
246
 
179
247
  test("without a selected card x cuts the block and e opens the editor", () => {
248
+ // Arrange
180
249
  const unfocused = state({ hasFocusedAnnotation: false });
250
+
251
+ // Assert
181
252
  expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
182
253
  expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
183
254
  });
184
255
  });
185
256
 
186
257
  describe("span mode", () => {
258
+ // Arrange
187
259
  const keyState = state({ spanMode: true });
188
260
  const table: [string, Intent[]][] = [
189
261
  ["l", [{ type: "spanKey", name: "l" }]],
@@ -206,6 +278,7 @@ describe("span mode", () => {
206
278
  });
207
279
 
208
280
  describe("diff mode", () => {
281
+ // Arrange
209
282
  const keyState = state({ view: "diff" });
210
283
  const table: [string, Intent[]][] = [
211
284
  ["j", [{ type: "move", to: "down" }]],
@@ -229,6 +302,7 @@ describe("diff mode", () => {
229
302
  }
230
303
 
231
304
  test("comment guards: resolved first, then non-code rows", () => {
305
+ // Assert
232
306
  expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
233
307
  { type: "status", message: "review submitted - read-only" },
234
308
  ]);
@@ -240,6 +314,7 @@ describe("diff mode", () => {
240
314
 
241
315
  describe("guided walk", () => {
242
316
  test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
317
+ // Assert
243
318
  expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
244
319
  expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
245
320
  { type: "status", message: "review submitted - read-only" },
@@ -250,6 +325,7 @@ describe("guided walk", () => {
250
325
  expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
251
326
  });
252
327
 
328
+ // Arrange
253
329
  const walking = state({ view: "diff", overlay: "walk" });
254
330
  const table: [string, Intent[]][] = [
255
331
  ["]", [{ type: "walkForward" }]],
@@ -268,7 +344,10 @@ describe("guided walk", () => {
268
344
  }
269
345
 
270
346
  test("the end card's return leaves the walk and opens the submit confirm", () => {
347
+ // Arrange
271
348
  const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
349
+
350
+ // Assert
272
351
  expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
273
352
  expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
274
353
  });
@@ -289,17 +368,24 @@ describe("review panel controls", () => {
289
368
  }
290
369
 
291
370
  test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
371
+ // Arrange
292
372
  const observer = state({ readOnly: true });
373
+
374
+ // Assert
293
375
  expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
294
376
  expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
295
377
  });
296
378
 
297
379
  test("span mode still owns b as a span verb, not a panel cycle", () => {
380
+ // Assert
298
381
  expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
299
382
  });
300
383
 
301
384
  test("the walk overlay keeps the brackets for stepping", () => {
385
+ // Arrange
302
386
  const walking = state({ view: "diff", overlay: "walk" });
387
+
388
+ // Assert
303
389
  expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
304
390
  expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
305
391
  });
@@ -332,15 +418,21 @@ describe("read-only filter", () => {
332
418
  });
333
419
 
334
420
  test("navigation and annotation focus still work for observers", () => {
421
+ // Arrange
335
422
  const keyState = state({ readOnly: true });
423
+
424
+ // Assert
336
425
  expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
337
426
  expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
338
427
  expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
339
428
  });
340
429
 
341
430
  test("span-mode c/s are gated by key name even under a rebound keymap", () => {
431
+ // Arrange
342
432
  const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
343
433
  const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
434
+
435
+ // Assert
344
436
  expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
345
437
  expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
346
438
  });
package/src/keymap.ts CHANGED
@@ -18,6 +18,7 @@ export type Intent =
18
18
  | { type: "spanKey"; name: string }
19
19
  | { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
20
20
  | { type: "openSubmit" }
21
+ | { type: "share" }
21
22
  | { type: "cut" }
22
23
  | { type: "edit" }
23
24
  | { type: "editCard" }
@@ -50,6 +51,15 @@ export interface KeyState {
50
51
  /** Loaded keymap (config.ts): action -> key combos. */
51
52
  keys: Record<string, string[]>;
52
53
  readOnly: boolean;
54
+ /**
55
+ * Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
56
+ * A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
57
+ * the gateway) or submit an agent verdict (there is no agent on a share).
58
+ */
59
+ canEditPlan?: boolean;
60
+ canSubmitVerdict?: boolean;
61
+ /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
62
+ canShare?: boolean;
53
63
  /** Layer that owns keys before the grammar runs. */
54
64
  overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
55
65
  view: "inbox" | "plan" | "diff";
@@ -66,7 +76,7 @@ export interface KeyState {
66
76
  }
67
77
 
68
78
  /** Verbs that write session state; an observer never reaches their handlers. */
69
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
79
+ const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk", "share"]);
70
80
 
71
81
  const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
72
82
 
@@ -127,6 +137,12 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
127
137
  const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
128
138
  if (state.readOnly && mutating) return status("observer - read-only");
129
139
 
140
+ // share is a session-level verb: it works from any view, owner only
141
+ if (action === "share") {
142
+ if (state.canShare === false) return status("only the plan owner can share");
143
+ return [{ type: "share" }];
144
+ }
145
+
130
146
  if (state.view === "inbox") return inboxGrammar(state, name);
131
147
  // span mode owns its single-letter keys (b slides the span back) before the
132
148
  // review-panel controls claim them
@@ -201,6 +217,9 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
201
217
  // the document selects, the rail edits: with a card selected, Cut deletes
202
218
  // the annotation and edit rewrites the card body in place
203
219
  if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
220
+ // editing the plan itself is the owner's verb; a share viewer only annotates,
221
+ // and has no edit affordance, so the key is silent rather than a nag
222
+ if (state.canEditPlan === false) return [];
204
223
  return [{ type: action }];
205
224
  }
206
225
  return annotationCluster(state, action) ?? [];
@@ -224,6 +243,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
224
243
  if (state.resolved || !state.hasFocusedAnnotation) return [];
225
244
  return [{ type: "removeAnnotation" }];
226
245
  }
227
- if (action === "submit") return state.resolved ? [] : [{ type: "openSubmit" }];
246
+ if (action === "submit") {
247
+ // a collaborator's notes union back as they go; there is no verdict to submit
248
+ if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
249
+ return state.resolved ? [] : [{ type: "openSubmit" }];
250
+ }
228
251
  return null;
229
252
  }
package/src/serve.test.ts CHANGED
@@ -70,6 +70,7 @@ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs =
70
70
 
71
71
  describe("cueloop serve", () => {
72
72
  test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
73
+ // Assert
73
74
  expect(handle.host).toBe("127.0.0.1");
74
75
  expect(handle.port).toBeGreaterThan(0);
75
76
  expect(handle.fingerprints.length).toBe(1);
@@ -78,7 +79,10 @@ describe("cueloop serve", () => {
78
79
  });
79
80
 
80
81
  test("an anonymous ssh client gets the observer TUI for the session", async () => {
82
+ // Act
81
83
  const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
84
+
85
+ // Assert
82
86
  expect(bytes).toContain("cueloop");
83
87
  expect(bytes).toContain("Rollout Plan");
84
88
  // observer chrome, not the controller's mutating hint bar
@@ -86,10 +90,13 @@ describe("cueloop serve", () => {
86
90
  });
87
91
 
88
92
  test("two observers can watch at once", async () => {
93
+ // Act
89
94
  const [a, b] = await Promise.all([
90
95
  sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
91
96
  sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
92
97
  ]);
98
+
99
+ // Assert
93
100
  expect(a).toContain("Rollout Plan");
94
101
  expect(b).toContain("Rollout Plan");
95
102
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
11
- import { DaemonClient } from "@cueloop/daemon/client";
11
+ import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
12
12
  import {
13
13
  cutBlock,
14
14
  detectHerdr,
@@ -23,6 +23,7 @@ import {
23
23
  type VerdictKind,
24
24
  } from "@cueloop/schema";
25
25
  import { loadBundledExporters, type BundledExporter } from "./integrations";
26
+ import { publishShare } from "./share";
26
27
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
27
28
  import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
28
29
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
@@ -72,6 +73,12 @@ export interface ReviewControllerOptions {
72
73
  onExit?: (code: number) => void;
73
74
  /** Timer source for the auto-close countdown; tests inject a ManualClock. */
74
75
  clock?: Clock;
76
+ /**
77
+ * How the controller gets its session client. Defaults to dialing the local
78
+ * daemon; the sharing gateway injects a blob-backed client so the same <App>
79
+ * renders a decrypted share instead.
80
+ */
81
+ openClient?: () => Promise<SessionClient>;
75
82
  }
76
83
 
77
84
  export interface ReviewController {
@@ -115,6 +122,8 @@ export interface ReviewController {
115
122
  walkLeave(): void;
116
123
  /** Resolve the review, run the export, start the completion hand-back. */
117
124
  submit(verdict: VerdictKind, summary: string): void;
125
+ /** Publish the current session as a share; the ssh line lands on the clipboard. */
126
+ share(): void;
118
127
  /** Close the review and, inside herdr, bounce focus back to the agent. */
119
128
  finishReview(): void;
120
129
  dismissCompletion(): void;
@@ -134,7 +143,7 @@ export function createReviewController(options: ReviewControllerOptions): Review
134
143
 
135
144
  class Controller implements ReviewController {
136
145
  readonly readOnly: boolean;
137
- private client: DaemonClient | null = null;
146
+ private client: SessionClient | null = null;
138
147
  private closed = false;
139
148
  private snapshot: ControllerSnapshot = {
140
149
  session: null,
@@ -179,7 +188,8 @@ class Controller implements ReviewController {
179
188
  connect(): void {
180
189
  void (async () => {
181
190
  try {
182
- const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
191
+ const openClient = this.options.openClient ?? (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
192
+ const client = await openClient();
183
193
  if (this.closed) return void client.close();
184
194
  this.client = client;
185
195
  client.onEvent((event) => {
@@ -470,6 +480,15 @@ class Controller implements ReviewController {
470
480
  .catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
471
481
  }
472
482
 
483
+ share(): void {
484
+ const session = this.snapshot.session;
485
+ if (!session) return;
486
+ this.setStatus("sharing…");
487
+ publishShare(session)
488
+ .then(({ line, copied }) => this.setStatus(copied ? `✓ share link copied - ${line}` : line))
489
+ .catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
490
+ }
491
+
473
492
  // ── completion hand-back ────────────────────
474
493
  private startCounting(remaining: number): void {
475
494
  if (remaining <= 0) return this.finishReview();
package/src/share.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Publish a review session as a share: pack it, stream it to the gateway's
3
+ * `share` user over SSH, and copy the returned `ssh p_…@host` line to the
4
+ * clipboard. The planner holds no key, so all this side does is upload; the
5
+ * gateway seals and stores. Used by both `cueloop share` and the in-TUI
6
+ * Share button, so the two share exactly the same path.
7
+ */
8
+
9
+ import { DEFAULT_SHARE_HOST, DEFAULT_SHARE_PORT, SHARE_UPLOAD_USER, packSessionBlob } from "@cueloop/daemon/share-blob";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { copyToClipboard } from "./clipboard";
12
+
13
+ export interface ShareTarget {
14
+ host?: string;
15
+ port?: number;
16
+ }
17
+
18
+ export interface ShareResult {
19
+ /** The one line to paste: `ssh p_xxxxxxxx@host`. */
20
+ line: string;
21
+ /** Whether the line made it onto the system clipboard. */
22
+ copied: boolean;
23
+ }
24
+
25
+ /** Upload the session to the gateway and copy the resulting ssh line. */
26
+ export async function publishShare(session: ReviewSession, target: ShareTarget = {}): Promise<ShareResult> {
27
+ const line = await uploadOverSsh(packSessionBlob(session), target.host ?? DEFAULT_SHARE_HOST, target.port ?? DEFAULT_SHARE_PORT);
28
+ const copied = await copyToClipboard(line);
29
+ return { line, copied };
30
+ }
31
+
32
+ /** Stream the blob to `share@host:port` over the system `ssh` client. */
33
+ async function uploadOverSsh(blob: Buffer, host: string, port: number): Promise<string> {
34
+ const proc = Bun.spawn(
35
+ ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, "cueloop-share"],
36
+ { stdin: blob, stdout: "pipe", stderr: "pipe" },
37
+ );
38
+ const [stdout, stderr, code] = await Promise.all([
39
+ new Response(proc.stdout).text(),
40
+ new Response(proc.stderr).text(),
41
+ proc.exited,
42
+ ]);
43
+ if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
44
+ const line = stdout.trim();
45
+ if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
46
+ return line;
47
+ }
@@ -21,7 +21,10 @@ index 111..222 100644
21
21
 
22
22
  describe("diffRows", () => {
23
23
  test("flattens files, hunks, and signed lines with line numbers", () => {
24
+ // Act
24
25
  const rows = diffRows(PATCH);
26
+
27
+ // Assert
25
28
  expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
26
29
  expect(rows[1]!.kind).toBe("hunk");
27
30
  const del = rows.find((row) => row.kind === "del")!;
@@ -36,7 +39,10 @@ describe("diffRows", () => {
36
39
  });
37
40
 
38
41
  test("context lines carry both line numbers", () => {
42
+ // Act
39
43
  const rows = diffRows(PATCH);
44
+
45
+ // Assert
40
46
  const ctx = rows.find((row) => row.kind === "ctx")!;
41
47
  expect(ctx.oldLine).toBe(1);
42
48
  expect(ctx.newLine).toBe(1);
@@ -45,9 +51,14 @@ describe("diffRows", () => {
45
51
 
46
52
  describe("diffRowAnchor", () => {
47
53
  test("quote is the line, neighbors are the context selectors", () => {
54
+ // Arrange
48
55
  const rows = diffRows(PATCH);
49
56
  const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
57
+
58
+ // Act
50
59
  const anchor = diffRowAnchor(rows, rowIndex);
60
+
61
+ // Assert
51
62
  expect(anchor.quote).toContain("new Map()");
52
63
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
64
  expect(anchor.suffix.length).toBeGreaterThan(0);
package/src/view-diff.ts CHANGED
@@ -29,28 +29,28 @@ export function diffRows(patchText: string): DiffRow[] {
29
29
  let newLine = hunk.additionStart;
30
30
  for (const segment of hunk.hunkContent) {
31
31
  if (segment.type === "context") {
32
- for (let i = 0; i < segment.lines; i++) {
32
+ for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
33
33
  rows.push({
34
34
  kind: "ctx",
35
- text: file.additionLines[segment.additionLineIndex + i] ?? "",
35
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
36
36
  file: file.name,
37
37
  oldLine: oldLine++,
38
38
  newLine: newLine++,
39
39
  });
40
40
  }
41
41
  } else {
42
- for (let i = 0; i < segment.deletions; i++) {
42
+ for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
43
43
  rows.push({
44
44
  kind: "del",
45
- text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
45
+ text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
46
46
  file: file.name,
47
47
  oldLine: oldLine++,
48
48
  });
49
49
  }
50
- for (let i = 0; i < segment.additions; i++) {
50
+ for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
51
51
  rows.push({
52
52
  kind: "add",
53
- text: file.additionLines[segment.additionLineIndex + i] ?? "",
53
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
54
54
  file: file.name,
55
55
  newLine: newLine++,
56
56
  });