@cueloop/client 0.1.0-alpha.82 → 0.1.0-alpha.84

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.
@@ -19,7 +19,15 @@ import {
19
19
  styledRunsFor,
20
20
  workRangeForRendered,
21
21
  } from "./view-plan";
22
- import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
22
+ import {
23
+ applyTextCuts,
24
+ cutBlock,
25
+ mergeTextCut,
26
+ parseBlocks,
27
+ restoreBlock,
28
+ restoreLine,
29
+ type Annotation,
30
+ } from "@cueloop/schema";
23
31
 
24
32
  const BASE = `# Plan
25
33
 
@@ -54,6 +62,14 @@ describe("buildDisplay reconciliation", () => {
54
62
  expect(mod.work!.text).toContain("writes");
55
63
  });
56
64
 
65
+ test("a partial Cut stays one modified block even when only a short prefix survives", () => {
66
+ const base = "A seeded plan so cueloop dev always has a thread to open.";
67
+ const display = buildDisplay(base, "A seed");
68
+
69
+ expect(display).toHaveLength(1);
70
+ expect(display[0]!.type).toBe("mod");
71
+ });
72
+
57
73
  test("unrelated cut + insert do NOT merge into a mod", () => {
58
74
  // Arrange
59
75
  const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
@@ -81,6 +97,55 @@ describe("buildDisplay reconciliation", () => {
81
97
  });
82
98
 
83
99
  describe("blockRuns + overlayMarks", () => {
100
+ test("a partial-word Cut leaves surviving characters plain instead of inserted", () => {
101
+ const base = "It lands under unin Threads then in the sidebar rather than a project.";
102
+ const firstStart = base.indexOf("under");
103
+ const firstEnd = firstStart + "under".length;
104
+ const secondStart = base.indexOf("Threads");
105
+ const secondEnd = secondStart + "Threads".length;
106
+ const cuts = mergeTextCut(
107
+ base,
108
+ mergeTextCut(base, [], firstStart, firstEnd),
109
+ secondStart,
110
+ secondEnd,
111
+ );
112
+ const [block] = buildDisplay(base, applyTextCuts(base, cuts), cuts);
113
+ const runs = blockRuns(block!, true);
114
+
115
+ expect(runs.filter((run) => run.role === "del").map((run) => run.text)).toEqual([
116
+ "under",
117
+ "Threads",
118
+ ]);
119
+ expect(
120
+ runs
121
+ .filter((run) => run.role !== "del")
122
+ .map((run) => run.text)
123
+ .join(""),
124
+ ).toBe("It lands unin then in the sidebar rather than a project.");
125
+ expect(runs.some((run) => run.role === "ins")).toBe(false);
126
+ });
127
+
128
+ test("a cross-block Cut keeps every survivor plain and strikes only selected characters", () => {
129
+ const base = "Keep before remove this.\n\n- remove that keep after";
130
+ const cuts = mergeTextCut(base, [], 12, 40);
131
+ const display = buildDisplay(base, applyTextCuts(base, cuts), cuts);
132
+ const runs = display.flatMap((block) => blockRuns(block, true));
133
+
134
+ expect(runs.filter((run) => run.role === "ins")).toEqual([]);
135
+ expect(
136
+ runs
137
+ .filter((run) => run.role === "del")
138
+ .map((run) => run.text)
139
+ .join(""),
140
+ ).toBe(base.slice(12, 40).replace("\n\n- ", ""));
141
+ expect(
142
+ runs
143
+ .filter((run) => run.role !== "del")
144
+ .map((run) => run.text)
145
+ .join(""),
146
+ ).toBe("Keep before keep after");
147
+ });
148
+
84
149
  test("mod blocks emit word-diff runs that reconstruct both sides", () => {
85
150
  // Arrange
86
151
  const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
package/src/view-plan.ts CHANGED
@@ -13,8 +13,12 @@ import {
13
13
  lcsDiff,
14
14
  parseBlocks,
15
15
  resolveAnchor,
16
+ applyTextCuts,
17
+ blockTextSourceSegments,
16
18
  type Annotation,
17
19
  type Block,
20
+ type BlockTextSourceSegment,
21
+ type TextCut,
18
22
  } from "@cueloop/schema";
19
23
  import { wordLevelChanges } from "./diff-intraline";
20
24
  import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
@@ -31,6 +35,10 @@ export interface DisplayBlock {
31
35
  work?: Block;
32
36
  /** Block in the submitted revision (absent for add). */
33
37
  base?: Block;
38
+ /** Source projection for mapping a visible base-text selection back to Markdown. */
39
+ baseSourceSegments?: readonly BlockTextSourceSegment[];
40
+ /** Exact base-text ranges removed by character Cut. */
41
+ textCutRanges?: CharRange[];
34
42
  orderedItemNumber?: number;
35
43
  }
36
44
 
@@ -50,9 +58,54 @@ export function renderedText(block: DisplayBlock): string {
50
58
  .join("");
51
59
  }
52
60
 
61
+ /** Exact character runs when one contiguous insertion or deletion produced the new text. */
62
+ function singleRangeTextChanges(
63
+ oldText: string,
64
+ newText: string,
65
+ ): ReturnType<typeof wordLevelChanges> | null {
66
+ if (oldText === newText || oldText.length === newText.length) return null;
67
+ let prefixLength = 0;
68
+
69
+ while (
70
+ prefixLength < oldText.length &&
71
+ prefixLength < newText.length &&
72
+ oldText[prefixLength] === newText[prefixLength]
73
+ )
74
+ prefixLength++;
75
+ let suffixLength = 0;
76
+ const shorterLength = Math.min(oldText.length, newText.length);
77
+
78
+ while (
79
+ suffixLength < shorterLength - prefixLength &&
80
+ oldText[oldText.length - 1 - suffixLength] === newText[newText.length - 1 - suffixLength]
81
+ )
82
+ suffixLength++;
83
+ if (prefixLength + suffixLength !== shorterLength) return null;
84
+ const commonPrefix = oldText.slice(0, prefixLength);
85
+ const commonSuffix = oldText.slice(oldText.length - suffixLength);
86
+ const changed =
87
+ oldText.length > newText.length
88
+ ? {
89
+ text: oldText.slice(prefixLength, oldText.length - suffixLength),
90
+ kind: "removed" as const,
91
+ }
92
+ : {
93
+ text: newText.slice(prefixLength, newText.length - suffixLength),
94
+ kind: "added" as const,
95
+ };
96
+
97
+ return [
98
+ { text: commonPrefix, kind: "common" as const },
99
+ changed,
100
+ { text: commonSuffix, kind: "common" as const },
101
+ ].filter((change) => change.text.length > 0);
102
+ }
103
+
53
104
  /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
54
- function similar(baseBlock: Block, workBlock: Block): boolean {
105
+ function similar(baseBlock: Block, workBlock: Block, expectedText?: string): boolean {
55
106
  if (baseBlock.kind !== workBlock.kind) return false;
107
+ if (expectedText === workBlock.text) return true;
108
+ if (singleRangeTextChanges(baseBlock.text, workBlock.text)) return true;
56
109
  const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
57
110
  const baseWords = words(baseBlock.text);
58
111
  const workWords = words(workBlock.text);
@@ -65,8 +118,84 @@ function similar(baseBlock: Block, workBlock: Block): boolean {
65
118
  }
66
119
 
67
120
  /** Reconcile base blocks vs working blocks into the display list. */
68
- export function buildDisplay(baseContent: string, workingContent?: string): DisplayBlock[] {
121
+ export function buildDisplay(
122
+ baseContent: string,
123
+ workingContent?: string,
124
+ textCuts: readonly TextCut[] = [],
125
+ ): DisplayBlock[] {
69
126
  const baseBlocks = parseBlocks(baseContent);
127
+ const exactCuts =
128
+ textCuts.length > 0 && workingContent === applyTextCuts(baseContent, textCuts) ? textCuts : [];
129
+ const sourceSegments =
130
+ exactCuts.length > 0 ? blockTextSourceSegments(baseContent, baseBlocks) : [];
131
+ const cutData = new Map<Block, Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges">>();
132
+ let cutIndex = 0;
133
+
134
+ baseBlocks.forEach((block, blockIndex) => {
135
+ const segments = sourceSegments[blockIndex];
136
+
137
+ if (!segments?.length) return;
138
+ const sourceStart = segments[0]!.sourceStart;
139
+ const last = segments.at(-1)!;
140
+ const sourceEnd = last.sourceStart + last.textEnd - last.textStart;
141
+
142
+ while (cutIndex < exactCuts.length && exactCuts[cutIndex]!.end <= sourceStart) cutIndex++;
143
+ const ranges: CharRange[] = [];
144
+
145
+ for (let index = cutIndex; index < exactCuts.length; index++) {
146
+ const cut = exactCuts[index]!;
147
+
148
+ if (cut.start >= sourceEnd) break;
149
+ const start = blockTextBoundary(segments, block.text.length, cut.start);
150
+ const end = blockTextBoundary(segments, block.text.length, cut.end);
151
+
152
+ if (end > start) ranges.push({ start, end });
153
+ }
154
+ const data: Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges"> = {
155
+ baseSourceSegments: segments,
156
+ };
157
+
158
+ if (ranges.length > 0) data.textCutRanges = ranges;
159
+ cutData.set(block, data);
160
+ });
161
+ const blockCutData = (block: Block) => cutData.get(block) ?? {};
162
+ const expectedBlockText = (block: Block): string | undefined => {
163
+ const data = blockCutData(block);
164
+
165
+ if (!data.textCutRanges) return undefined;
166
+ let text = block.text;
167
+
168
+ for (let index = data.textCutRanges.length - 1; index >= 0; index--) {
169
+ const { start, end } = data.textCutRanges[index]!;
170
+
171
+ text = text.slice(0, start) + text.slice(end);
172
+ }
173
+
174
+ return text;
175
+ };
176
+
177
+ if (exactCuts.length > 0) {
178
+ const display = baseBlocks.map((base): DisplayBlock => {
179
+ const data = blockCutData(base);
180
+
181
+ if (!data.textCutRanges) {
182
+ return { type: "same", kind: base.kind, base, work: base, ...data };
183
+ }
184
+ const text = expectedBlockText(base)!;
185
+
186
+ return {
187
+ type: "mod",
188
+ kind: base.kind,
189
+ base,
190
+ work: { ...base, text },
191
+ ...data,
192
+ };
193
+ });
194
+
195
+ numberOrderedListItems(display);
196
+
197
+ return display;
198
+ }
70
199
 
71
200
  if (workingContent === undefined) {
72
201
  const display: DisplayBlock[] = baseBlocks.map((block) => ({
@@ -74,6 +203,7 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
74
203
  kind: block.kind,
75
204
  work: block,
76
205
  base: block,
206
+ ...blockCutData(block),
77
207
  }));
78
208
 
79
209
  numberOrderedListItems(display);
@@ -112,20 +242,32 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
112
242
  let addedIndex = 0;
113
243
 
114
244
  while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
115
- if (similar(deletedBlocks[deletedIndex]!, addedBlocks[addedIndex]!)) {
245
+ if (
246
+ similar(
247
+ deletedBlocks[deletedIndex]!,
248
+ addedBlocks[addedIndex]!,
249
+ expectedBlockText(deletedBlocks[deletedIndex]!),
250
+ )
251
+ ) {
252
+ const base = deletedBlocks[deletedIndex]!;
253
+
116
254
  display.push({
117
255
  type: "mod",
118
256
  kind: addedBlocks[addedIndex]!.kind,
119
- base: deletedBlocks[deletedIndex]!,
257
+ base,
120
258
  work: addedBlocks[addedIndex]!,
259
+ ...blockCutData(base),
121
260
  });
122
261
  deletedIndex++;
123
262
  addedIndex++;
124
263
  } else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
264
+ const base = deletedBlocks[deletedIndex]!;
265
+
125
266
  display.push({
126
267
  type: "del",
127
- kind: deletedBlocks[deletedIndex]!.kind,
128
- base: deletedBlocks[deletedIndex]!,
268
+ kind: base.kind,
269
+ base,
270
+ ...blockCutData(base),
129
271
  });
130
272
  deletedIndex++;
131
273
  } else {
@@ -138,11 +280,15 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
138
280
  }
139
281
  }
140
282
  while (deletedIndex < deletedBlocks.length) {
283
+ const base = deletedBlocks[deletedIndex]!;
284
+
141
285
  display.push({
142
286
  type: "del",
143
- kind: deletedBlocks[deletedIndex]!.kind,
144
- base: deletedBlocks[deletedIndex++]!,
287
+ kind: base.kind,
288
+ base,
289
+ ...blockCutData(base),
145
290
  });
291
+ deletedIndex++;
146
292
  }
147
293
  while (addedIndex < addedBlocks.length) {
148
294
  display.push({
@@ -157,6 +303,21 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
157
303
  return display;
158
304
  }
159
305
 
306
+ function blockTextBoundary(
307
+ segments: readonly BlockTextSourceSegment[],
308
+ textLength: number,
309
+ sourceOffset: number,
310
+ ): number {
311
+ for (const segment of segments) {
312
+ const sourceEnd = segment.sourceStart + segment.textEnd - segment.textStart;
313
+
314
+ if (sourceOffset < segment.sourceStart) return segment.textStart;
315
+ if (sourceOffset <= sourceEnd) return segment.textStart + sourceOffset - segment.sourceStart;
316
+ }
317
+
318
+ return textLength;
319
+ }
320
+
160
321
  function numberOrderedListItems(display: DisplayBlock[]): void {
161
322
  let runLength = 0;
162
323
 
@@ -186,6 +347,8 @@ export interface StyleRun {
186
347
  role: RunRole;
187
348
  /** Offset of this run in the block's working text; null for del runs. */
188
349
  start: number | null;
350
+ /** Offset in the submitted block when an exact Cut preserves source provenance. */
351
+ baseStart?: number;
189
352
  annotationId?: string;
190
353
  /** Link target for `link` runs. */
191
354
  href?: string;
@@ -266,6 +429,75 @@ export function marksByDisplay(
266
429
  return marksByIndex;
267
430
  }
268
431
 
432
+ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null {
433
+ if (!block.base || !block.work || !block.textCutRanges) return null;
434
+ const localCuts = block.textCutRanges;
435
+ const expected = localCuts.reduceRight(
436
+ (text, cut) => text.slice(0, cut.start) + text.slice(cut.end),
437
+ block.base.text,
438
+ );
439
+
440
+ if (expected !== block.work.text) return null;
441
+ const sourceRuns = literal
442
+ ? [{ text: block.base.text, role: "plain" as const, start: 0 }]
443
+ : inlineStyleRuns(block.base.text, 0);
444
+ const runs: StyleRun[] = [];
445
+ let cutIndex = 0;
446
+ let deletedBefore = 0;
447
+
448
+ for (const sourceRun of sourceRuns) {
449
+ if (sourceRun.start === null) continue;
450
+ const runStart = sourceRun.start;
451
+ const runEnd = runStart + sourceRun.text.length;
452
+ let position = runStart;
453
+
454
+ while (cutIndex < localCuts.length && localCuts[cutIndex]!.end <= position) {
455
+ deletedBefore += localCuts[cutIndex]!.end - localCuts[cutIndex]!.start;
456
+ cutIndex++;
457
+ }
458
+ while (position < runEnd) {
459
+ const cut = localCuts[cutIndex];
460
+
461
+ if (!cut || cut.start >= runEnd) {
462
+ runs.push({
463
+ ...sourceRun,
464
+ text: block.base.text.slice(position, runEnd),
465
+ start: position - deletedBefore,
466
+ baseStart: position,
467
+ });
468
+ break;
469
+ }
470
+ if (position < cut.start) {
471
+ const commonEnd = Math.min(runEnd, cut.start);
472
+
473
+ runs.push({
474
+ ...sourceRun,
475
+ text: block.base.text.slice(position, commonEnd),
476
+ start: position - deletedBefore,
477
+ baseStart: position,
478
+ });
479
+ position = commonEnd;
480
+ continue;
481
+ }
482
+ const removedEnd = Math.min(runEnd, cut.end);
483
+
484
+ runs.push({
485
+ text: block.base.text.slice(position, removedEnd),
486
+ role: "del",
487
+ start: null,
488
+ baseStart: position,
489
+ });
490
+ position = removedEnd;
491
+ if (position === cut.end) {
492
+ deletedBefore += cut.end - cut.start;
493
+ cutIndex++;
494
+ }
495
+ }
496
+ }
497
+
498
+ return runs;
499
+ }
500
+
269
501
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
270
502
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
271
503
  const literal =
@@ -274,8 +506,16 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
274
506
  block.kind === "table" ||
275
507
  block.kind === "frontmatter";
276
508
 
509
+ if (markup) {
510
+ const exact = exactCutRuns(block, literal);
511
+
512
+ if (exact) return exact;
513
+ }
514
+
277
515
  if (block.type === "mod" && markup) {
278
- const changes = wordLevelChanges(block.base!.text, block.work!.text);
516
+ const changes =
517
+ singleRangeTextChanges(block.base!.text, block.work!.text) ??
518
+ wordLevelChanges(block.base!.text, block.work!.text);
279
519
  const runs: StyleRun[] = [];
280
520
  let workOffset = 0;
281
521
 
@@ -572,6 +812,38 @@ export function workRangeForRendered(
572
812
  return range;
573
813
  }
574
814
 
815
+ /** Base-text range covered by a rendered selection in an exact Cut projection. */
816
+ export function baseRangeForRendered(
817
+ runs: StyleRun[],
818
+ renderedStart: number,
819
+ renderedEnd: number,
820
+ ): { start: number; end: number } | null {
821
+ let rendered = 0;
822
+ let range: { start: number; end: number } | null = null;
823
+
824
+ for (const run of runs) {
825
+ const runRenderedStart = rendered;
826
+ const runRenderedEnd = rendered + run.text.length;
827
+
828
+ rendered = runRenderedEnd;
829
+ if (run.baseStart === undefined) continue;
830
+ const overlapStart = Math.max(renderedStart, runRenderedStart);
831
+ const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
832
+
833
+ if (overlapEnd <= overlapStart) continue;
834
+ const baseStart = run.baseStart + overlapStart - runRenderedStart;
835
+ const baseEnd = run.baseStart + overlapEnd - runRenderedStart;
836
+
837
+ if (!range) range = { start: baseStart, end: baseEnd };
838
+ else {
839
+ range.start = Math.min(range.start, baseStart);
840
+ range.end = Math.max(range.end, baseEnd);
841
+ }
842
+ }
843
+
844
+ return range;
845
+ }
846
+
575
847
  /**
576
848
  * Map a rendered-text selection back to the work-text range an anchor is cut
577
849
  * from. The read-only surface yields rendered offsets (markers concealed); an