@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.22
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 +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +121 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +71 -182
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +24 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
|
@@ -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:
|
|
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
|
|
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
|
+
}
|
package/src/view-diff.test.ts
CHANGED
|
@@ -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
|
|
32
|
+
for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
|
|
33
33
|
rows.push({
|
|
34
34
|
kind: "ctx",
|
|
35
|
-
text: file.additionLines[segment.additionLineIndex +
|
|
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
|
|
42
|
+
for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
|
|
43
43
|
rows.push({
|
|
44
44
|
kind: "del",
|
|
45
|
-
text: file.deletionLines[segment.deletionLineIndex +
|
|
45
|
+
text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
|
|
46
46
|
file: file.name,
|
|
47
47
|
oldLine: oldLine++,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
for (let
|
|
50
|
+
for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
|
|
51
51
|
rows.push({
|
|
52
52
|
kind: "add",
|
|
53
|
-
text: file.additionLines[segment.additionLineIndex +
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|
package/src/view-plan.test.ts
CHANGED
|
@@ -25,36 +25,57 @@ The daemon persists sessions to disk atomically.
|
|
|
25
25
|
|
|
26
26
|
describe("buildDisplay reconciliation", () => {
|
|
27
27
|
test("no working copy: everything is same", () => {
|
|
28
|
+
// Act
|
|
28
29
|
const display = buildDisplay(BASE);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
29
32
|
expect(display.every((block) => block.type === "same")).toBe(true);
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
test("reworded block pairs as mod (similar)", () => {
|
|
36
|
+
// Arrange
|
|
33
37
|
const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
|
|
38
|
+
|
|
39
|
+
// Act
|
|
34
40
|
const display = buildDisplay(BASE, working);
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
35
43
|
const mod = display.find((block) => block.type === "mod")!;
|
|
36
44
|
expect(mod.base!.text).toContain("persists");
|
|
37
45
|
expect(mod.work!.text).toContain("writes");
|
|
38
46
|
});
|
|
39
47
|
|
|
40
48
|
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
49
|
+
// Arrange
|
|
41
50
|
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
51
|
+
|
|
52
|
+
// Act
|
|
42
53
|
const display = buildDisplay(BASE, working);
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
43
56
|
expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
|
|
44
57
|
expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
|
|
45
58
|
expect(display.some((block) => block.type === "mod")).toBe(false);
|
|
46
59
|
});
|
|
47
60
|
|
|
48
61
|
test("ordered list items get running numbers", () => {
|
|
62
|
+
// Act
|
|
49
63
|
const display = buildDisplay("1. one\n2. two\n3. three\n");
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
50
66
|
expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
|
|
51
67
|
});
|
|
52
68
|
});
|
|
53
69
|
|
|
54
70
|
describe("blockRuns + overlayMarks", () => {
|
|
55
71
|
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
72
|
+
// Arrange
|
|
56
73
|
const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
|
|
74
|
+
|
|
75
|
+
// Act
|
|
57
76
|
const runs = blockRuns(block, true);
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
58
79
|
const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
|
|
59
80
|
const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
|
|
60
81
|
expect(oldSide).toBe("plain old text here");
|
|
@@ -62,9 +83,14 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
62
83
|
});
|
|
63
84
|
|
|
64
85
|
test("marks bind to working-text offsets and skip del runs", () => {
|
|
86
|
+
// Arrange
|
|
65
87
|
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
66
88
|
const runs = blockRuns(block, true);
|
|
89
|
+
|
|
90
|
+
// Act
|
|
67
91
|
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
68
94
|
const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
|
|
69
95
|
expect(markedText).toBe("words");
|
|
70
96
|
// del run text untouched
|
|
@@ -72,14 +98,20 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
72
98
|
});
|
|
73
99
|
|
|
74
100
|
test("marks never override ins emphasis", () => {
|
|
101
|
+
// Arrange
|
|
75
102
|
const block = buildDisplay("keep\n", "keep added\n")[0]!;
|
|
103
|
+
|
|
104
|
+
// Act
|
|
76
105
|
const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
77
108
|
expect(runs.some((run) => run.role === "ins")).toBe(true);
|
|
78
109
|
});
|
|
79
110
|
});
|
|
80
111
|
|
|
81
112
|
describe("marksByDisplay", () => {
|
|
82
113
|
test("annotations resolve into display coordinates", () => {
|
|
114
|
+
// Arrange
|
|
83
115
|
const working = BASE.replace("first item", "first item edited beyond recognition of it");
|
|
84
116
|
const display = buildDisplay(BASE, working);
|
|
85
117
|
const ann: Annotation = {
|
|
@@ -89,13 +121,18 @@ describe("marksByDisplay", () => {
|
|
|
89
121
|
body: "define atomically",
|
|
90
122
|
createdAt: "",
|
|
91
123
|
};
|
|
124
|
+
|
|
125
|
+
// Act
|
|
92
126
|
const map = marksByDisplay([ann], display);
|
|
127
|
+
|
|
128
|
+
// Assert
|
|
93
129
|
const [displayIndex, marks] = [...map.entries()][0]!;
|
|
94
130
|
expect(displayText(display[displayIndex]!)).toContain("atomically");
|
|
95
131
|
expect(marks[0]!.annotationId).toBe("a1");
|
|
96
132
|
});
|
|
97
133
|
|
|
98
134
|
test("orphaned annotations produce no marks", () => {
|
|
135
|
+
// Arrange
|
|
99
136
|
const display = buildDisplay(BASE);
|
|
100
137
|
const ann: Annotation = {
|
|
101
138
|
id: "a1",
|
|
@@ -104,25 +141,35 @@ describe("marksByDisplay", () => {
|
|
|
104
141
|
body: "",
|
|
105
142
|
createdAt: "",
|
|
106
143
|
};
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
107
146
|
expect(marksByDisplay([ann], display).size).toBe(0);
|
|
108
147
|
});
|
|
109
148
|
});
|
|
110
149
|
|
|
111
150
|
describe("rendered/work offset mapping", () => {
|
|
112
151
|
test("plain blocks map 1:1 in both directions", () => {
|
|
152
|
+
// Arrange
|
|
113
153
|
const block = buildDisplay("alpha beta gamma\n")[0]!;
|
|
114
154
|
const runs = blockRuns(block, true);
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
115
157
|
expect(renderedOffsetFor(runs, 6)).toBe(6);
|
|
116
158
|
expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
|
|
117
159
|
});
|
|
118
160
|
|
|
119
161
|
test("inline del runs shift rendered offsets in word-diffed blocks", () => {
|
|
162
|
+
// Arrange
|
|
120
163
|
// rendered: "old new words linger" - "old " is a del run without offsets
|
|
121
164
|
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
122
165
|
const runs = blockRuns(block, true);
|
|
123
166
|
const workText = displayText(block);
|
|
124
167
|
const wordsAt = workText.indexOf("words");
|
|
168
|
+
|
|
169
|
+
// Act
|
|
125
170
|
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
171
|
+
|
|
172
|
+
// Assert
|
|
126
173
|
const renderedText = runs.map((run) => run.text).join("");
|
|
127
174
|
expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
|
|
128
175
|
// reading a rendered selection over "words" recovers the work range
|
|
@@ -133,16 +180,24 @@ describe("rendered/work offset mapping", () => {
|
|
|
133
180
|
});
|
|
134
181
|
|
|
135
182
|
test("a rendered selection spanning a del run keeps only positioned text", () => {
|
|
183
|
+
// Arrange
|
|
136
184
|
const block = buildDisplay("old words\n", "new words\n")[0]!;
|
|
137
185
|
const runs = blockRuns(block, true);
|
|
138
186
|
const renderedText = runs.map((run) => run.text).join("");
|
|
187
|
+
|
|
188
|
+
// Act
|
|
139
189
|
// select everything rendered: the work range is the whole work text
|
|
140
190
|
const range = workRangeForRendered(runs, 0, renderedText.length)!;
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
141
193
|
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
142
194
|
});
|
|
143
195
|
|
|
144
196
|
test("offsets outside any positioned run map to null", () => {
|
|
197
|
+
// Arrange
|
|
145
198
|
const runs = blockRuns(buildDisplay("short\n")[0]!, true);
|
|
199
|
+
|
|
200
|
+
// Assert
|
|
146
201
|
expect(renderedOffsetFor(runs, 99)).toBeNull();
|
|
147
202
|
expect(workRangeForRendered(runs, 90, 99)).toBeNull();
|
|
148
203
|
});
|
|
@@ -151,28 +206,54 @@ describe("rendered/work offset mapping", () => {
|
|
|
151
206
|
describe("span mode", () => {
|
|
152
207
|
const text = "one two three four";
|
|
153
208
|
test("v selects the first word; l grows; w slides; $ to end", () => {
|
|
209
|
+
// Act
|
|
154
210
|
let span = startSpan(0, text)!;
|
|
211
|
+
|
|
212
|
+
// Assert
|
|
155
213
|
expect(text.slice(span.start, span.end)).toBe("one");
|
|
214
|
+
|
|
215
|
+
// Act
|
|
156
216
|
span = spanKey(span, "l", text);
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
157
219
|
expect(text.slice(span.start, span.end)).toBe("one two");
|
|
220
|
+
|
|
221
|
+
// Act
|
|
158
222
|
span = spanKey(span, "w", text);
|
|
223
|
+
|
|
224
|
+
// Assert
|
|
159
225
|
expect(text.slice(span.start, span.end)).toBe("two three");
|
|
226
|
+
|
|
227
|
+
// Act
|
|
160
228
|
span = spanKey(span, "$", text);
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
161
231
|
expect(text.slice(span.start, span.end)).toBe("two three four");
|
|
162
232
|
});
|
|
163
233
|
});
|
|
164
234
|
|
|
165
235
|
describe("cut / restore round-trip", () => {
|
|
166
236
|
test("cut removes the block lines; restore puts them back", () => {
|
|
237
|
+
// Arrange
|
|
167
238
|
const blocks = parseBlocks(BASE);
|
|
168
239
|
const listItem = blocks.find((candidate) => candidate.text === "second item")!;
|
|
240
|
+
|
|
241
|
+
// Act
|
|
169
242
|
const cut = cutBlock(BASE, listItem);
|
|
243
|
+
|
|
244
|
+
// Assert
|
|
170
245
|
expect(cut).not.toContain("second item");
|
|
246
|
+
|
|
247
|
+
// Arrange
|
|
171
248
|
const display = buildDisplay(BASE, cut);
|
|
172
249
|
const deletedIndex = display.findIndex((block) => block.type === "del");
|
|
173
250
|
const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
|
|
251
|
+
|
|
252
|
+
// Act
|
|
174
253
|
// undefined = the working copy round-tripped back to the submitted revision
|
|
175
254
|
const restored = restoreBlock(BASE, cut, listItem, line);
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
176
257
|
expect(restored).toBeUndefined();
|
|
177
258
|
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
178
259
|
});
|
package/src/view-plan.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
isAddressed,
|
|
11
12
|
lcsDiff,
|
|
12
13
|
parseBlocks,
|
|
13
14
|
resolveAnchor,
|
|
@@ -161,6 +162,8 @@ export function marksByDisplay(
|
|
|
161
162
|
const workBlocks = workEntries.map((entry) => entry.block.work!);
|
|
162
163
|
const marksByIndex = new Map<number, Mark[]>();
|
|
163
164
|
for (const annotation of annotations) {
|
|
165
|
+
// an addressed annotation keeps its record but paints no highlight
|
|
166
|
+
if (isAddressed(annotation)) continue;
|
|
164
167
|
const resolved = resolveAnchor(annotation.anchor, workBlocks);
|
|
165
168
|
if (!resolved) continue;
|
|
166
169
|
const entry = workEntries[resolved.blockIndex];
|
package/src/walk.test.ts
CHANGED
|
@@ -92,12 +92,16 @@ describe("noteForFile", () => {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test("finds the note anchored at the file path", () => {
|
|
95
|
+
// Arrange
|
|
95
96
|
const annotations = [note("src/a.ts", "Renames b and adds c."), note("src/b.ts", "Swaps the line.")];
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
96
99
|
expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
|
|
97
100
|
expect(noteForFile(annotations, "src/c.ts")).toBeUndefined();
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
test("ignores non-note annotations that happen to quote the path", () => {
|
|
104
|
+
// Arrange
|
|
101
105
|
const comment: Annotation = {
|
|
102
106
|
id: "a_1",
|
|
103
107
|
kind: "comment",
|
|
@@ -105,6 +109,8 @@ describe("noteForFile", () => {
|
|
|
105
109
|
body: "not a note",
|
|
106
110
|
createdAt: "now",
|
|
107
111
|
};
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
108
114
|
expect(noteForFile([comment], "src/a.ts")).toBeUndefined();
|
|
109
115
|
});
|
|
110
116
|
});
|