@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +32 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- 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 +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +316 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- 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 +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- 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 +143 -0
- package/src/editor.ts +123 -9
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/view-diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Diff artifact projection
|
|
2
|
+
* Diff artifact projection: flatten @pierre/diffs' parsed
|
|
3
3
|
* patch model into render rows. Line-anchored annotations use the same
|
|
4
4
|
* quote-primary anchors as plans: quote = the line content, prefix/suffix =
|
|
5
5
|
* the neighbor lines - so the whole anchor/feedback pipeline is shared.
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
9
|
|
|
10
|
-
export type
|
|
10
|
+
export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
|
|
11
11
|
|
|
12
12
|
export interface DiffRow {
|
|
13
|
-
|
|
13
|
+
kind: DiffRowKind;
|
|
14
14
|
text: string;
|
|
15
15
|
file: string;
|
|
16
16
|
oldLine?: number;
|
|
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
|
|
|
22
22
|
const patches = parsePatchFiles(patchText);
|
|
23
23
|
for (const patch of patches) {
|
|
24
24
|
for (const file of patch.files) {
|
|
25
|
-
rows.push({
|
|
25
|
+
rows.push({ kind: "file", text: fileLabel(file), file: file.name });
|
|
26
26
|
for (const hunk of file.hunks) {
|
|
27
|
-
rows.push({
|
|
27
|
+
rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
|
|
28
28
|
let oldLine = hunk.deletionStart;
|
|
29
29
|
let newLine = hunk.additionStart;
|
|
30
|
-
for (const
|
|
31
|
-
if (
|
|
32
|
-
for (let
|
|
30
|
+
for (const segment of hunk.hunkContent) {
|
|
31
|
+
if (segment.type === "context") {
|
|
32
|
+
for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
|
|
33
33
|
rows.push({
|
|
34
|
-
|
|
35
|
-
text: file.additionLines[
|
|
34
|
+
kind: "ctx",
|
|
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
|
-
|
|
45
|
-
text: file.deletionLines[
|
|
44
|
+
kind: "del",
|
|
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
|
-
|
|
53
|
-
text: file.additionLines[
|
|
52
|
+
kind: "add",
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|
|
@@ -64,18 +64,29 @@ export function diffRows(patchText: string): DiffRow[] {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function fileLabel(file: FileDiffMetadata): string {
|
|
67
|
-
return file.prevName && file.prevName !== file.name
|
|
67
|
+
return file.prevName && file.prevName !== file.name
|
|
68
|
+
? `${file.prevName} → ${file.name}`
|
|
69
|
+
: file.name;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
-
export function diffRowAnchor(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
export function diffRowAnchor(
|
|
74
|
+
rows: DiffRow[],
|
|
75
|
+
rowIndex: number,
|
|
76
|
+
): { quote: string; prefix: string; suffix: string } {
|
|
77
|
+
const row = rows[rowIndex]!;
|
|
78
|
+
const prev = rows[rowIndex - 1];
|
|
79
|
+
const next = rows[rowIndex + 1];
|
|
75
80
|
return {
|
|
76
81
|
quote: row.text,
|
|
77
|
-
prefix:
|
|
78
|
-
|
|
82
|
+
prefix:
|
|
83
|
+
prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del")
|
|
84
|
+
? prev.text.slice(-24)
|
|
85
|
+
: "",
|
|
86
|
+
suffix:
|
|
87
|
+
next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del")
|
|
88
|
+
? next.text.slice(0, 24)
|
|
89
|
+
: "",
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
blockRuns,
|
|
4
|
+
buildDisplay,
|
|
5
|
+
displayText,
|
|
6
|
+
marksByDisplay,
|
|
7
|
+
nextWorkBlock,
|
|
8
|
+
overlayMarks,
|
|
9
|
+
renderedOffsetFor,
|
|
10
|
+
spanFromRange,
|
|
11
|
+
spanKey,
|
|
12
|
+
startSpan,
|
|
13
|
+
workRangeForRendered,
|
|
14
|
+
} from "./view-plan";
|
|
15
|
+
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
16
|
+
|
|
17
|
+
const BASE = `# Plan
|
|
18
|
+
|
|
19
|
+
## Context
|
|
20
|
+
|
|
21
|
+
The daemon persists sessions to disk atomically.
|
|
22
|
+
|
|
23
|
+
- first item
|
|
24
|
+
- second item
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
describe("buildDisplay reconciliation", () => {
|
|
28
|
+
test("no working copy: everything is same", () => {
|
|
29
|
+
// Act
|
|
30
|
+
const display = buildDisplay(BASE);
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(display.every((block) => block.type === "same")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("reworded block pairs as mod (similar)", () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const display = buildDisplay(BASE, working);
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
const mod = display.find((block) => block.type === "mod")!;
|
|
45
|
+
expect(mod.base!.text).toContain("persists");
|
|
46
|
+
expect(mod.work!.text).toContain("writes");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
const display = buildDisplay(BASE, working);
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(
|
|
58
|
+
display.some((block) => block.type === "del" && block.base!.text === "second item"),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
expect(
|
|
61
|
+
display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
expect(display.some((block) => block.type === "mod")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("ordered list items get running numbers", () => {
|
|
67
|
+
// Act
|
|
68
|
+
const display = buildDisplay("1. one\n2. two\n3. three\n");
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("blockRuns + overlayMarks", () => {
|
|
76
|
+
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
|
|
79
|
+
|
|
80
|
+
// Act
|
|
81
|
+
const runs = blockRuns(block, true);
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
const oldSide = runs
|
|
85
|
+
.filter((run) => run.role !== "ins")
|
|
86
|
+
.map((run) => run.text)
|
|
87
|
+
.join("");
|
|
88
|
+
const newSide = runs
|
|
89
|
+
.filter((run) => run.role !== "del")
|
|
90
|
+
.map((run) => run.text)
|
|
91
|
+
.join("");
|
|
92
|
+
expect(oldSide).toBe("plain old text here");
|
|
93
|
+
expect(newSide).toBe("plain new text here");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("marks bind to working-text offsets and skip del runs", () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
99
|
+
const runs = blockRuns(block, true);
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
105
|
+
const markedText = marked
|
|
106
|
+
.filter((run) => run.role === "mark-comment")
|
|
107
|
+
.map((run) => run.text)
|
|
108
|
+
.join("");
|
|
109
|
+
expect(markedText).toBe("words");
|
|
110
|
+
// del run text untouched
|
|
111
|
+
expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("marks never override ins emphasis", () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const block = buildDisplay("keep\n", "keep added\n")[0]!;
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
const runs = overlayMarks(blockRuns(block, true), [
|
|
120
|
+
{ start: 0, end: 10, role: "mark-comment" },
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(runs.some((run) => run.role === "ins")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("marksByDisplay", () => {
|
|
129
|
+
test("annotations resolve into display coordinates", () => {
|
|
130
|
+
// Arrange
|
|
131
|
+
const working = BASE.replace("first item", "first item edited beyond recognition of it");
|
|
132
|
+
const display = buildDisplay(BASE, working);
|
|
133
|
+
const ann: Annotation = {
|
|
134
|
+
id: "a1",
|
|
135
|
+
kind: "comment",
|
|
136
|
+
anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
|
|
137
|
+
body: "define atomically",
|
|
138
|
+
createdAt: "",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Act
|
|
142
|
+
const map = marksByDisplay([ann], display);
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
145
|
+
const [displayIndex, marks] = [...map.entries()][0]!;
|
|
146
|
+
expect(displayText(display[displayIndex]!)).toContain("atomically");
|
|
147
|
+
expect(marks[0]!.annotationId).toBe("a1");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("orphaned annotations produce no marks", () => {
|
|
151
|
+
// Arrange
|
|
152
|
+
const display = buildDisplay(BASE);
|
|
153
|
+
const ann: Annotation = {
|
|
154
|
+
id: "a1",
|
|
155
|
+
kind: "comment",
|
|
156
|
+
anchor: { quote: "text that is gone", prefix: "", suffix: "" },
|
|
157
|
+
body: "",
|
|
158
|
+
createdAt: "",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Assert
|
|
162
|
+
expect(marksByDisplay([ann], display).size).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("rendered/work offset mapping", () => {
|
|
167
|
+
test("plain blocks map 1:1 in both directions", () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const block = buildDisplay("alpha beta gamma\n")[0]!;
|
|
170
|
+
const runs = blockRuns(block, true);
|
|
171
|
+
|
|
172
|
+
// Assert
|
|
173
|
+
expect(renderedOffsetFor(runs, 6)).toBe(6);
|
|
174
|
+
expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("inline del runs shift rendered offsets in word-diffed blocks", () => {
|
|
178
|
+
// Arrange
|
|
179
|
+
// rendered: "old new words linger" - "old " is a del run without offsets
|
|
180
|
+
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
181
|
+
const runs = blockRuns(block, true);
|
|
182
|
+
const workText = displayText(block);
|
|
183
|
+
const wordsAt = workText.indexOf("words");
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
190
|
+
expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
|
|
191
|
+
// reading a rendered selection over "words" recovers the work range
|
|
192
|
+
expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
|
|
193
|
+
start: wordsAt,
|
|
194
|
+
end: wordsAt + "words".length,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a rendered selection spanning a del run keeps only positioned text", () => {
|
|
199
|
+
// Arrange
|
|
200
|
+
const block = buildDisplay("old words\n", "new words\n")[0]!;
|
|
201
|
+
const runs = blockRuns(block, true);
|
|
202
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
203
|
+
|
|
204
|
+
// Act
|
|
205
|
+
// select everything rendered: the work range is the whole work text
|
|
206
|
+
const range = workRangeForRendered(runs, 0, renderedText.length)!;
|
|
207
|
+
|
|
208
|
+
// Assert
|
|
209
|
+
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("offsets outside any positioned run map to null", () => {
|
|
213
|
+
// Arrange
|
|
214
|
+
const runs = blockRuns(buildDisplay("short\n")[0]!, true);
|
|
215
|
+
|
|
216
|
+
// Assert
|
|
217
|
+
expect(renderedOffsetFor(runs, 99)).toBeNull();
|
|
218
|
+
expect(workRangeForRendered(runs, 90, 99)).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("span mode", () => {
|
|
223
|
+
const text = "one two three four";
|
|
224
|
+
test("v selects the first word; l grows; w slides; $ to end", () => {
|
|
225
|
+
// Act
|
|
226
|
+
let span = startSpan(0, text)!;
|
|
227
|
+
|
|
228
|
+
// Assert
|
|
229
|
+
expect(text.slice(span.start, span.end)).toBe("one");
|
|
230
|
+
|
|
231
|
+
// Act
|
|
232
|
+
span = spanKey(span, "l", text);
|
|
233
|
+
|
|
234
|
+
// Assert
|
|
235
|
+
expect(text.slice(span.start, span.end)).toBe("one two");
|
|
236
|
+
|
|
237
|
+
// Act
|
|
238
|
+
span = spanKey(span, "w", text);
|
|
239
|
+
|
|
240
|
+
// Assert
|
|
241
|
+
expect(text.slice(span.start, span.end)).toBe("two three");
|
|
242
|
+
|
|
243
|
+
// Act
|
|
244
|
+
span = spanKey(span, "$", text);
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
expect(text.slice(span.start, span.end)).toBe("two three four");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
|
|
251
|
+
// Arrange - a drag from inside "two" to inside "three"
|
|
252
|
+
const dragStart = text.indexOf("two") + 1;
|
|
253
|
+
const dragEnd = text.indexOf("three") + 2;
|
|
254
|
+
|
|
255
|
+
// Act
|
|
256
|
+
const span = spanFromRange(0, text, dragStart, dragEnd)!;
|
|
257
|
+
|
|
258
|
+
// Assert - snaps outward to cover both touched words, ready for w/b/l/h
|
|
259
|
+
expect(text.slice(span.start, span.end)).toBe("two three");
|
|
260
|
+
expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("spanFromRange returns null when the range covers no word", () => {
|
|
264
|
+
// Assert - a range past the last word has nothing to select
|
|
265
|
+
expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("cut / restore round-trip", () => {
|
|
270
|
+
test("cut removes the block lines; restore puts them back", () => {
|
|
271
|
+
// Arrange
|
|
272
|
+
const blocks = parseBlocks(BASE);
|
|
273
|
+
const listItem = blocks.find((candidate) => candidate.text === "second item")!;
|
|
274
|
+
|
|
275
|
+
// Act
|
|
276
|
+
const cut = cutBlock(BASE, listItem);
|
|
277
|
+
|
|
278
|
+
// Assert
|
|
279
|
+
expect(cut).not.toContain("second item");
|
|
280
|
+
|
|
281
|
+
// Arrange
|
|
282
|
+
const display = buildDisplay(BASE, cut);
|
|
283
|
+
const deletedIndex = display.findIndex((block) => block.type === "del");
|
|
284
|
+
const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
|
|
285
|
+
|
|
286
|
+
// Act
|
|
287
|
+
// undefined = the working copy round-tripped back to the submitted revision
|
|
288
|
+
const restored = restoreBlock(BASE, cut, listItem, line);
|
|
289
|
+
|
|
290
|
+
// Assert
|
|
291
|
+
expect(restored).toBeUndefined();
|
|
292
|
+
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|