@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +17 -17
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +378 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +224 -54
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +132 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +425 -0
- package/src/index.ts +9 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +333 -84
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +206 -0
- package/src/path-view.ts +90 -0
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +66 -5
- package/src/types.ts +346 -24
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
package/src/working-copy.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyTextCuts,
|
|
4
|
+
blockCutSourceRange,
|
|
5
|
+
cutBlock,
|
|
6
|
+
cutTextRange,
|
|
7
|
+
mergeTextCut,
|
|
8
|
+
restoreTextCut,
|
|
9
|
+
restoreBlock,
|
|
10
|
+
restoreLine,
|
|
11
|
+
sourceChunk,
|
|
12
|
+
} from "./working-copy";
|
|
3
13
|
import { parseBlocks, type Block } from "./markdown";
|
|
4
14
|
|
|
5
15
|
const BASE = `# Plan
|
|
@@ -18,92 +28,267 @@ const y = 2;
|
|
|
18
28
|
\`\`\`
|
|
19
29
|
`;
|
|
20
30
|
|
|
21
|
-
function block(
|
|
22
|
-
return parseBlocks(
|
|
31
|
+
function block(markdown: string, text: string): Block {
|
|
32
|
+
return parseBlocks(markdown).find((block) => block.text.includes(text))!;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
describe("sourceChunk", () => {
|
|
26
36
|
test("returns the exact source lines a block occupies", () => {
|
|
27
37
|
expect(sourceChunk(BASE, block(BASE, "first item"))).toBe("- first item");
|
|
28
|
-
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
38
|
+
expect(sourceChunk(BASE, block(BASE, "spans two lines"))).toBe(
|
|
39
|
+
"A paragraph that\nspans two lines.",
|
|
40
|
+
);
|
|
29
41
|
});
|
|
30
42
|
|
|
31
43
|
test("code block chunk includes both fence lines", () => {
|
|
32
|
-
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
44
|
+
expect(sourceChunk(BASE, block(BASE, "const x"))).toBe(
|
|
45
|
+
"```ts\nconst x = 1;\nconst y = 2;\n```",
|
|
46
|
+
);
|
|
33
47
|
});
|
|
34
48
|
});
|
|
35
49
|
|
|
36
50
|
describe("cutBlock", () => {
|
|
37
51
|
test("removes the block lines and the blank line before it", () => {
|
|
52
|
+
// Act
|
|
38
53
|
const cut = cutBlock(BASE, block(BASE, "spans two lines"));
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
39
56
|
expect(cut).not.toContain("spans two lines");
|
|
40
57
|
expect(cut).toContain("## Context\n\n- first item");
|
|
41
58
|
});
|
|
42
59
|
|
|
43
60
|
test("cut at the start of the document leaves the leading blank line", () => {
|
|
61
|
+
// Act
|
|
44
62
|
const cut = cutBlock(BASE, block(BASE, "Plan"));
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
45
65
|
expect(cut).not.toContain("# Plan");
|
|
46
66
|
expect(cut.trimStart().startsWith("## Context")).toBe(true);
|
|
47
67
|
});
|
|
48
68
|
|
|
49
69
|
test("cut at the end of the document keeps the rest intact", () => {
|
|
70
|
+
// Act
|
|
50
71
|
const cut = cutBlock(BASE, block(BASE, "const x"));
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
51
74
|
expect(cut).not.toContain("const x");
|
|
52
75
|
expect(cut).toContain("- second item");
|
|
53
76
|
});
|
|
77
|
+
|
|
78
|
+
test.each(["Plan", "spans two lines", "first item", "const x"])(
|
|
79
|
+
"reports the exact source interval cut from %s",
|
|
80
|
+
(text) => {
|
|
81
|
+
const target = block(BASE, text);
|
|
82
|
+
const range = blockCutSourceRange(BASE, target);
|
|
83
|
+
|
|
84
|
+
expect(BASE.slice(0, range.start) + BASE.slice(range.end)).toBe(cutBlock(BASE, target));
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("cutTextRange", () => {
|
|
90
|
+
test("cuts only the selected characters inside a block", () => {
|
|
91
|
+
const paragraph = block(BASE, "spans two lines");
|
|
92
|
+
const start = paragraph.text.indexOf("paragraph");
|
|
93
|
+
const cut = cutTextRange(BASE, paragraph, start, paragraph, start + "paragraph".length);
|
|
94
|
+
|
|
95
|
+
expect(cut).toContain("A that\nspans two lines.");
|
|
96
|
+
expect(cut).toContain("## Context");
|
|
97
|
+
expect(cut).toContain("- first item");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("preserves a list marker around a partial cut", () => {
|
|
101
|
+
const item = block(BASE, "first item");
|
|
102
|
+
const cut = cutTextRange(BASE, item, 0, item, "first ".length);
|
|
103
|
+
|
|
104
|
+
expect(cut).toContain("- item");
|
|
105
|
+
expect(cut).not.toContain("- first item");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test.each([
|
|
109
|
+
["heading", "# remove keep", "# keep"],
|
|
110
|
+
["ordered list", "12. remove keep", "12. keep"],
|
|
111
|
+
["fenced code", "```ts\nremove keep\n```", "```ts\n keep\n```"],
|
|
112
|
+
["frontmatter", "---\nname: remove keep\n---", "---\nname: keep\n---"],
|
|
113
|
+
["table", "| remove | keep |\n| --- | --- |", "| | keep |\n| --- | --- |"],
|
|
114
|
+
])("preserves %s source markers", (_label, source, expected) => {
|
|
115
|
+
const target = block(source, "remove");
|
|
116
|
+
const start = target.text.indexOf("remove");
|
|
117
|
+
|
|
118
|
+
expect(cutTextRange(source, target, start, target, start + "remove".length)).toBe(expected);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cuts an exact range across marked quote lines", () => {
|
|
122
|
+
const source = "> keep alpha\n> remove beta\n> keep gamma";
|
|
123
|
+
const quote = parseBlocks(source)[0]!;
|
|
124
|
+
const start = quote.text.indexOf("alpha");
|
|
125
|
+
const end = quote.text.indexOf("keep gamma");
|
|
126
|
+
|
|
127
|
+
const cut = cutTextRange(source, quote, start, quote, end);
|
|
128
|
+
|
|
129
|
+
expect(cut).toBe("> keep keep gamma");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("cuts an exact range across separate blocks", () => {
|
|
133
|
+
const source = "Keep before remove this.\n\n- remove that keep after";
|
|
134
|
+
const blocks = parseBlocks(source);
|
|
135
|
+
const first = blocks[0]!;
|
|
136
|
+
const second = blocks[1]!;
|
|
137
|
+
const start = first.text.indexOf("remove");
|
|
138
|
+
const end = second.text.indexOf("keep after");
|
|
139
|
+
|
|
140
|
+
const cut = cutTextRange(source, first, start, second, end);
|
|
141
|
+
|
|
142
|
+
expect(cut).toBe("Keep before keep after");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("exact text Cuts", () => {
|
|
147
|
+
test("stores and applies the selected source characters exactly", () => {
|
|
148
|
+
const source = "It lands under unin Threads then in the sidebar.";
|
|
149
|
+
const start = source.indexOf("under");
|
|
150
|
+
const end = source.indexOf(" in the sidebar");
|
|
151
|
+
const cuts = mergeTextCut(source, [], start, end);
|
|
152
|
+
|
|
153
|
+
expect(cuts).toEqual([{ start, end, quote: "under unin Threads then" }]);
|
|
154
|
+
expect(applyTextCuts(source, cuts)).toBe("It lands in the sidebar.");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("merges overlapping and adjacent ranges in one linear source interval", () => {
|
|
158
|
+
const source = "alpha beta gamma delta";
|
|
159
|
+
const first = mergeTextCut(source, [], 6, 10);
|
|
160
|
+
const merged = mergeTextCut(source, first, 10, 16);
|
|
161
|
+
|
|
162
|
+
expect(merged).toEqual([{ start: 6, end: 16, quote: "beta gamma" }]);
|
|
163
|
+
expect(applyTextCuts(source, merged)).toBe("alpha delta");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns the original references for no-op ranges", () => {
|
|
167
|
+
const source = "unchanged";
|
|
168
|
+
const cuts = [{ start: 0, end: 2, quote: "un" }];
|
|
169
|
+
|
|
170
|
+
expect(mergeTextCut(source, cuts, 4, 4)).toBe(cuts);
|
|
171
|
+
expect(applyTextCuts(source, [])).toBe(source);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("applies out-of-order Cuts and ignores stale or overlapping records", () => {
|
|
175
|
+
const source = "alpha beta gamma";
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
applyTextCuts(source, [
|
|
179
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
180
|
+
{ start: 0, end: 5, quote: "alpha" },
|
|
181
|
+
{ start: 2, end: 8, quote: "stale" },
|
|
182
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
183
|
+
]),
|
|
184
|
+
).toBe(" ");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("restores an exact subrange without disturbing the rest of its Cut", () => {
|
|
188
|
+
const source = "alpha beta gamma delta";
|
|
189
|
+
const cuts = mergeTextCut(source, [], 6, 16);
|
|
190
|
+
const restored = restoreTextCut(source, cuts, 11, 16);
|
|
191
|
+
|
|
192
|
+
expect(restored).toEqual([{ start: 6, end: 11, quote: "beta " }]);
|
|
193
|
+
expect(applyTextCuts(source, restored ?? [])).toBe("alpha gamma delta");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not restore across separate Cuts", () => {
|
|
197
|
+
const source = "alpha beta gamma delta";
|
|
198
|
+
const cuts = [
|
|
199
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
200
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
expect(restoreTextCut(source, cuts, 6, 16)).toBeNull();
|
|
204
|
+
});
|
|
54
205
|
});
|
|
55
206
|
|
|
56
207
|
describe("restoreBlock", () => {
|
|
57
208
|
test("restoring the only cut returns undefined - back to pristine", () => {
|
|
58
|
-
|
|
59
|
-
const
|
|
209
|
+
// Arrange
|
|
210
|
+
const listItem = block(BASE, "second item");
|
|
211
|
+
const cut = cutBlock(BASE, listItem);
|
|
60
212
|
// the cut block re-enters before the code fence, the next surviving block
|
|
61
|
-
const next = parseBlocks(cut).find((
|
|
62
|
-
|
|
213
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
214
|
+
|
|
215
|
+
// Act
|
|
216
|
+
const restored = restoreBlock(BASE, cut, listItem, restoreLine(next, cut.split("\n").length));
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
63
219
|
expect(restored).toBeUndefined();
|
|
64
220
|
});
|
|
65
221
|
|
|
66
222
|
test("restore after another edit returns the merged working copy", () => {
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
223
|
+
// Arrange
|
|
224
|
+
const listItem = block(BASE, "second item");
|
|
225
|
+
const edited = cutBlock(BASE, listItem).replace("first item", "first item reworded");
|
|
226
|
+
const next = parseBlocks(edited).find((candidate) => candidate.kind === "code");
|
|
227
|
+
|
|
228
|
+
// Act
|
|
229
|
+
const restored = restoreBlock(
|
|
230
|
+
BASE,
|
|
231
|
+
edited,
|
|
232
|
+
listItem,
|
|
233
|
+
restoreLine(next, edited.split("\n").length),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
71
237
|
expect(restored).toContain("- second item");
|
|
72
238
|
expect(restored).toContain("first item reworded");
|
|
73
239
|
});
|
|
74
240
|
|
|
75
241
|
test("restore at the end of the document (no next block)", () => {
|
|
242
|
+
// Arrange
|
|
76
243
|
const code = block(BASE, "const x");
|
|
77
244
|
const cut = cutBlock(BASE, code);
|
|
245
|
+
|
|
246
|
+
// Act
|
|
78
247
|
const restored = restoreBlock(BASE, cut, code, restoreLine(undefined, cut.split("\n").length));
|
|
248
|
+
|
|
249
|
+
// Assert
|
|
79
250
|
expect(restored).toBeUndefined();
|
|
80
251
|
});
|
|
81
252
|
|
|
82
253
|
test("restore a multi-line block round-trips to pristine", () => {
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
254
|
+
// Arrange
|
|
255
|
+
const paragraph = block(BASE, "spans two lines");
|
|
256
|
+
const cut = cutBlock(BASE, paragraph);
|
|
257
|
+
const next = parseBlocks(cut).find((block) => block.text === "first item");
|
|
258
|
+
|
|
259
|
+
// Act
|
|
260
|
+
const restored = restoreBlock(BASE, cut, paragraph, restoreLine(next, cut.split("\n").length));
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
87
263
|
expect(restored).toBeUndefined();
|
|
88
264
|
});
|
|
89
265
|
|
|
90
266
|
test("signature rule ignores blank-line layout but not text changes", () => {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
267
|
+
// Arrange
|
|
268
|
+
const listItem = block(BASE, "second item");
|
|
269
|
+
const cut = cutBlock(BASE, listItem);
|
|
270
|
+
const next = parseBlocks(cut).find((candidate) => candidate.kind === "code");
|
|
94
271
|
const line = restoreLine(next, cut.split("\n").length);
|
|
272
|
+
|
|
273
|
+
// Assert
|
|
95
274
|
// extra blank lines elsewhere do not block pristine detection
|
|
96
|
-
expect(
|
|
275
|
+
expect(
|
|
276
|
+
restoreBlock(BASE, cut.replace("## Context", "## Context\n"), listItem, line),
|
|
277
|
+
).toBeUndefined();
|
|
97
278
|
// a real text change keeps the working copy alive
|
|
98
|
-
const changed = restoreBlock(BASE, cut.replace("first", "1st"),
|
|
279
|
+
const changed = restoreBlock(BASE, cut.replace("first", "1st"), listItem, line);
|
|
280
|
+
|
|
99
281
|
expect(changed).toContain("1st item");
|
|
100
282
|
});
|
|
101
283
|
});
|
|
102
284
|
|
|
103
285
|
describe("restoreLine", () => {
|
|
104
286
|
test("next surviving block's start, or the line count at the end", () => {
|
|
105
|
-
|
|
106
|
-
|
|
287
|
+
// Arrange
|
|
288
|
+
const listItem = block(BASE, "first item");
|
|
289
|
+
|
|
290
|
+
// Assert
|
|
291
|
+
expect(restoreLine(listItem, 99)).toBe(listItem.lineStart);
|
|
107
292
|
expect(restoreLine(undefined, 99)).toBe(99);
|
|
108
293
|
});
|
|
109
294
|
});
|
package/src/working-copy.ts
CHANGED
|
@@ -1,15 +1,95 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
1
|
+
/** Working-copy source surgery for block and character cuts plus block restoration. */
|
|
2
|
+
|
|
3
|
+
import { parseBlocks, sourceOffsetAt, type Block } from "./markdown";
|
|
4
|
+
import type { TextCut } from "./types";
|
|
5
|
+
|
|
6
|
+
/** Add one source range and normalize overlaps without copying on a no-op. */
|
|
7
|
+
export function mergeTextCut(
|
|
8
|
+
source: string,
|
|
9
|
+
cuts: readonly TextCut[],
|
|
10
|
+
start: number,
|
|
11
|
+
end: number,
|
|
12
|
+
): TextCut[] {
|
|
13
|
+
const boundedStart = Math.max(0, Math.min(start, source.length));
|
|
14
|
+
const boundedEnd = Math.max(boundedStart, Math.min(end, source.length));
|
|
15
|
+
|
|
16
|
+
if (boundedEnd === boundedStart) {
|
|
17
|
+
// SAFETY: a no-op returns the caller's array unchanged; callers never mutate the result.
|
|
18
|
+
return cuts as TextCut[];
|
|
19
|
+
}
|
|
20
|
+
const ranges = [
|
|
21
|
+
...cuts.map((cut) => ({ start: cut.start, end: cut.end })),
|
|
22
|
+
{ start: boundedStart, end: boundedEnd },
|
|
23
|
+
]
|
|
24
|
+
.map((range) => ({
|
|
25
|
+
start: Math.max(0, Math.min(range.start, source.length)),
|
|
26
|
+
end: Math.max(0, Math.min(range.end, source.length)),
|
|
27
|
+
}))
|
|
28
|
+
.filter((range) => range.end > range.start)
|
|
29
|
+
.toSorted((left, right) => left.start - right.start || left.end - right.end);
|
|
30
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
31
|
+
|
|
32
|
+
for (const range of ranges) {
|
|
33
|
+
const previous = merged.at(-1);
|
|
34
|
+
|
|
35
|
+
if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end);
|
|
36
|
+
else merged.push(range);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return merged.map((range) => ({
|
|
40
|
+
...range,
|
|
41
|
+
quote: source.slice(range.start, range.end),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Restore a selected part of one contiguous character Cut. */
|
|
46
|
+
export function restoreTextCut(
|
|
47
|
+
source: string,
|
|
48
|
+
cuts: readonly TextCut[],
|
|
49
|
+
start: number,
|
|
50
|
+
end: number,
|
|
51
|
+
): TextCut[] | null {
|
|
52
|
+
const cutIndex = cuts.findIndex((cut) => cut.start <= start && end <= cut.end);
|
|
7
53
|
|
|
8
|
-
|
|
54
|
+
if (cutIndex === -1 || end <= start) return null;
|
|
55
|
+
const cut = cuts[cutIndex]!;
|
|
56
|
+
const replacements: TextCut[] = [];
|
|
57
|
+
|
|
58
|
+
if (cut.start < start) {
|
|
59
|
+
replacements.push({ start: cut.start, end: start, quote: source.slice(cut.start, start) });
|
|
60
|
+
}
|
|
61
|
+
if (end < cut.end) {
|
|
62
|
+
replacements.push({ start: end, end: cut.end, quote: source.slice(end, cut.end) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [...cuts.slice(0, cutIndex), ...replacements, ...cuts.slice(cutIndex + 1)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Apply valid source ranges in source order, ignoring stale or overlapping records. */
|
|
69
|
+
export function applyTextCuts(source: string, cuts: readonly TextCut[]): string {
|
|
70
|
+
if (cuts.length === 0) return source;
|
|
71
|
+
let cursor = 0;
|
|
72
|
+
const pieces: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const cut of cuts.toSorted(
|
|
75
|
+
(left, right) => left.start - right.start || left.end - right.end,
|
|
76
|
+
)) {
|
|
77
|
+
if (cut.start < cursor || cut.start < 0 || cut.end > source.length || cut.end <= cut.start)
|
|
78
|
+
continue;
|
|
79
|
+
if (source.slice(cut.start, cut.end) !== cut.quote) continue;
|
|
80
|
+
|
|
81
|
+
pieces.push(source.slice(cursor, cut.start));
|
|
82
|
+
cursor = cut.end;
|
|
83
|
+
}
|
|
84
|
+
pieces.push(source.slice(cursor));
|
|
85
|
+
|
|
86
|
+
return pieces.join("");
|
|
87
|
+
}
|
|
9
88
|
|
|
10
89
|
/** Chunk of the base source a block occupies (for restore and display). */
|
|
11
90
|
export function sourceChunk(base: string, block: Block): string {
|
|
12
91
|
const lines = base.split("\n");
|
|
92
|
+
|
|
13
93
|
return lines.slice(block.lineStart, block.lineEnd + 1).join("\n");
|
|
14
94
|
}
|
|
15
95
|
|
|
@@ -18,10 +98,50 @@ export function cutBlock(working: string, block: Block): string {
|
|
|
18
98
|
const lines = working.split("\n");
|
|
19
99
|
const before = lines.slice(0, block.lineStart);
|
|
20
100
|
const after = lines.slice(block.lineEnd + 1);
|
|
101
|
+
|
|
21
102
|
while (before.length && before[before.length - 1]!.trim() === "") before.pop();
|
|
103
|
+
|
|
22
104
|
return [...before, ...after].join("\n");
|
|
23
105
|
}
|
|
24
106
|
|
|
107
|
+
/** Source interval removed by a whole-block Cut, including its leading blank-line padding. */
|
|
108
|
+
export function blockCutSourceRange(source: string, block: Block) {
|
|
109
|
+
const lines = source.split("\n");
|
|
110
|
+
let startLine = block.lineStart;
|
|
111
|
+
|
|
112
|
+
while (startLine > 0 && lines[startLine - 1]!.trim() === "") startLine--;
|
|
113
|
+
const lineStarts = [0];
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < source.length; index++) {
|
|
116
|
+
if (source.charCodeAt(index) === 10) lineStarts.push(index + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
start: lineStarts[startLine] ?? source.length,
|
|
121
|
+
end: lineStarts[block.lineEnd + 1] ?? source.length,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove exactly the selected block-text range while retaining surrounding
|
|
127
|
+
* Markdown markers. A cross-block range also removes the source between its
|
|
128
|
+
* endpoint characters.
|
|
129
|
+
*/
|
|
130
|
+
export function cutTextRange(
|
|
131
|
+
working: string,
|
|
132
|
+
startBlock: Block,
|
|
133
|
+
start: number,
|
|
134
|
+
endBlock: Block,
|
|
135
|
+
end: number,
|
|
136
|
+
): string {
|
|
137
|
+
const sourceStart = sourceOffsetAt(working, startBlock, start) ?? -1;
|
|
138
|
+
const sourceEnd = sourceOffsetAt(working, endBlock, end) ?? -1;
|
|
139
|
+
|
|
140
|
+
if (sourceStart < 0 || sourceEnd <= sourceStart) return working;
|
|
141
|
+
|
|
142
|
+
return working.slice(0, sourceStart) + working.slice(sourceEnd);
|
|
143
|
+
}
|
|
144
|
+
|
|
25
145
|
/** The line a restored block re-enters at: the next surviving block's start. */
|
|
26
146
|
export function restoreLine(next: Block | undefined, workingLineCount: number): number {
|
|
27
147
|
return next ? next.lineStart : workingLineCount;
|
|
@@ -34,10 +154,24 @@ export function restoreLine(next: Block | undefined, workingLineCount: number):
|
|
|
34
154
|
*/
|
|
35
155
|
function signature(text: string): string {
|
|
36
156
|
return parseBlocks(text)
|
|
37
|
-
.map((
|
|
157
|
+
.map((block) => block.kind + " " + block.text)
|
|
38
158
|
.join("\0");
|
|
39
159
|
}
|
|
40
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Whether a base block is missing from the working source: the working copy
|
|
163
|
+
* holds fewer blocks with its kind and text than the base does. Only such a
|
|
164
|
+
* block can be restored without duplicating content.
|
|
165
|
+
*/
|
|
166
|
+
export function isBlockCut(base: string, working: string, block: Block): boolean {
|
|
167
|
+
const matches = (text: string) =>
|
|
168
|
+
parseBlocks(text).filter(
|
|
169
|
+
(candidate) => candidate.kind === block.kind && candidate.text === block.text,
|
|
170
|
+
).length;
|
|
171
|
+
|
|
172
|
+
return matches(working) < matches(base);
|
|
173
|
+
}
|
|
174
|
+
|
|
41
175
|
/**
|
|
42
176
|
* Re-insert a cut base block into the working source before `beforeLine`.
|
|
43
177
|
* Returns undefined when the block structure round-trips to the base content
|
|
@@ -53,9 +187,11 @@ export function restoreBlock(
|
|
|
53
187
|
const lines = working.split("\n");
|
|
54
188
|
const before = lines.slice(0, beforeLine);
|
|
55
189
|
const after = lines.slice(beforeLine);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
190
|
+
const insertedLines = sourceChunk(base, block).split("\n");
|
|
191
|
+
|
|
192
|
+
if (before.length && before[before.length - 1]!.trim() !== "") insertedLines.unshift("");
|
|
193
|
+
if (after.length && after[0]!.trim() !== "") insertedLines.push("");
|
|
194
|
+
const restored = [...before, ...insertedLines, ...after].join("\n");
|
|
195
|
+
|
|
60
196
|
return signature(restored) === signature(base) ? undefined : restored;
|
|
61
197
|
}
|