@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20
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 -11
- package/src/App.autoclose.test.tsx +109 -0
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -13
- package/src/App.plan-review.test.tsx +232 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +100 -0
- package/src/App.submit-confirm.test.tsx +165 -0
- package/src/App.test.tsx +20 -58
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -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 +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +54 -29
- package/src/config.ts +53 -13
- package/src/editor.test.ts +110 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +37 -0
- package/src/herdr.ts +21 -0
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/key-bindings.test.ts +120 -0
- package/src/key-bindings.ts +353 -0
- package/src/keymap.test.ts +307 -0
- package/src/keymap.ts +204 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +496 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +179 -0
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -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 i = 0; i <
|
|
30
|
+
for (const segment of hunk.hunkContent) {
|
|
31
|
+
if (segment.type === "context") {
|
|
32
|
+
for (let i = 0; i < segment.lines; i++) {
|
|
33
33
|
rows.push({
|
|
34
|
-
|
|
35
|
-
text: file.additionLines[
|
|
34
|
+
kind: "ctx",
|
|
35
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
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 <
|
|
42
|
+
for (let i = 0; i < segment.deletions; i++) {
|
|
43
43
|
rows.push({
|
|
44
|
-
|
|
45
|
-
text: file.deletionLines[
|
|
44
|
+
kind: "del",
|
|
45
|
+
text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
|
|
46
46
|
file: file.name,
|
|
47
47
|
oldLine: oldLine++,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
for (let i = 0; i <
|
|
50
|
+
for (let i = 0; i < segment.additions; i++) {
|
|
51
51
|
rows.push({
|
|
52
|
-
|
|
53
|
-
text: file.additionLines[
|
|
52
|
+
kind: "add",
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|
|
@@ -68,14 +68,14 @@ function fileLabel(file: FileDiffMetadata): string {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
-
export function diffRowAnchor(rows: DiffRow[],
|
|
72
|
-
const row = rows[
|
|
73
|
-
const prev = rows[
|
|
74
|
-
const next = rows[
|
|
71
|
+
export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
|
|
72
|
+
const row = rows[rowIndex]!;
|
|
73
|
+
const prev = rows[rowIndex - 1];
|
|
74
|
+
const next = rows[rowIndex + 1];
|
|
75
75
|
return {
|
|
76
76
|
quote: row.text,
|
|
77
|
-
prefix: prev && (prev.
|
|
78
|
-
suffix: next && (next.
|
|
77
|
+
prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
|
|
78
|
+
suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
spanKey,
|
|
11
|
+
startSpan,
|
|
12
|
+
workRangeForRendered,
|
|
13
|
+
} from "./view-plan";
|
|
14
|
+
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
15
|
+
|
|
16
|
+
const BASE = `# Plan
|
|
17
|
+
|
|
18
|
+
## Context
|
|
19
|
+
|
|
20
|
+
The daemon persists sessions to disk atomically.
|
|
21
|
+
|
|
22
|
+
- first item
|
|
23
|
+
- second item
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
describe("buildDisplay reconciliation", () => {
|
|
27
|
+
test("no working copy: everything is same", () => {
|
|
28
|
+
const display = buildDisplay(BASE);
|
|
29
|
+
expect(display.every((block) => block.type === "same")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("reworded block pairs as mod (similar)", () => {
|
|
33
|
+
const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
|
|
34
|
+
const display = buildDisplay(BASE, working);
|
|
35
|
+
const mod = display.find((block) => block.type === "mod")!;
|
|
36
|
+
expect(mod.base!.text).toContain("persists");
|
|
37
|
+
expect(mod.work!.text).toContain("writes");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
41
|
+
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
42
|
+
const display = buildDisplay(BASE, working);
|
|
43
|
+
expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
|
|
44
|
+
expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
|
|
45
|
+
expect(display.some((block) => block.type === "mod")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ordered list items get running numbers", () => {
|
|
49
|
+
const display = buildDisplay("1. one\n2. two\n3. three\n");
|
|
50
|
+
expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("blockRuns + overlayMarks", () => {
|
|
55
|
+
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
56
|
+
const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
|
|
57
|
+
const runs = blockRuns(block, true);
|
|
58
|
+
const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
|
|
59
|
+
const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
|
|
60
|
+
expect(oldSide).toBe("plain old text here");
|
|
61
|
+
expect(newSide).toBe("plain new text here");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("marks bind to working-text offsets and skip del runs", () => {
|
|
65
|
+
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
66
|
+
const runs = blockRuns(block, true);
|
|
67
|
+
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
68
|
+
const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
|
|
69
|
+
expect(markedText).toBe("words");
|
|
70
|
+
// del run text untouched
|
|
71
|
+
expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("marks never override ins emphasis", () => {
|
|
75
|
+
const block = buildDisplay("keep\n", "keep added\n")[0]!;
|
|
76
|
+
const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
|
|
77
|
+
expect(runs.some((run) => run.role === "ins")).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("marksByDisplay", () => {
|
|
82
|
+
test("annotations resolve into display coordinates", () => {
|
|
83
|
+
const working = BASE.replace("first item", "first item edited beyond recognition of it");
|
|
84
|
+
const display = buildDisplay(BASE, working);
|
|
85
|
+
const ann: Annotation = {
|
|
86
|
+
id: "a1",
|
|
87
|
+
kind: "comment",
|
|
88
|
+
anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
|
|
89
|
+
body: "define atomically",
|
|
90
|
+
createdAt: "",
|
|
91
|
+
};
|
|
92
|
+
const map = marksByDisplay([ann], display);
|
|
93
|
+
const [displayIndex, marks] = [...map.entries()][0]!;
|
|
94
|
+
expect(displayText(display[displayIndex]!)).toContain("atomically");
|
|
95
|
+
expect(marks[0]!.annotationId).toBe("a1");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("orphaned annotations produce no marks", () => {
|
|
99
|
+
const display = buildDisplay(BASE);
|
|
100
|
+
const ann: Annotation = {
|
|
101
|
+
id: "a1",
|
|
102
|
+
kind: "comment",
|
|
103
|
+
anchor: { quote: "text that is gone", prefix: "", suffix: "" },
|
|
104
|
+
body: "",
|
|
105
|
+
createdAt: "",
|
|
106
|
+
};
|
|
107
|
+
expect(marksByDisplay([ann], display).size).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("rendered/work offset mapping", () => {
|
|
112
|
+
test("plain blocks map 1:1 in both directions", () => {
|
|
113
|
+
const block = buildDisplay("alpha beta gamma\n")[0]!;
|
|
114
|
+
const runs = blockRuns(block, true);
|
|
115
|
+
expect(renderedOffsetFor(runs, 6)).toBe(6);
|
|
116
|
+
expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("inline del runs shift rendered offsets in word-diffed blocks", () => {
|
|
120
|
+
// rendered: "old new words linger" - "old " is a del run without offsets
|
|
121
|
+
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
122
|
+
const runs = blockRuns(block, true);
|
|
123
|
+
const workText = displayText(block);
|
|
124
|
+
const wordsAt = workText.indexOf("words");
|
|
125
|
+
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
126
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
127
|
+
expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
|
|
128
|
+
// reading a rendered selection over "words" recovers the work range
|
|
129
|
+
expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
|
|
130
|
+
start: wordsAt,
|
|
131
|
+
end: wordsAt + "words".length,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a rendered selection spanning a del run keeps only positioned text", () => {
|
|
136
|
+
const block = buildDisplay("old words\n", "new words\n")[0]!;
|
|
137
|
+
const runs = blockRuns(block, true);
|
|
138
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
139
|
+
// select everything rendered: the work range is the whole work text
|
|
140
|
+
const range = workRangeForRendered(runs, 0, renderedText.length)!;
|
|
141
|
+
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("offsets outside any positioned run map to null", () => {
|
|
145
|
+
const runs = blockRuns(buildDisplay("short\n")[0]!, true);
|
|
146
|
+
expect(renderedOffsetFor(runs, 99)).toBeNull();
|
|
147
|
+
expect(workRangeForRendered(runs, 90, 99)).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("span mode", () => {
|
|
152
|
+
const text = "one two three four";
|
|
153
|
+
test("v selects the first word; l grows; w slides; $ to end", () => {
|
|
154
|
+
let span = startSpan(0, text)!;
|
|
155
|
+
expect(text.slice(span.start, span.end)).toBe("one");
|
|
156
|
+
span = spanKey(span, "l", text);
|
|
157
|
+
expect(text.slice(span.start, span.end)).toBe("one two");
|
|
158
|
+
span = spanKey(span, "w", text);
|
|
159
|
+
expect(text.slice(span.start, span.end)).toBe("two three");
|
|
160
|
+
span = spanKey(span, "$", text);
|
|
161
|
+
expect(text.slice(span.start, span.end)).toBe("two three four");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("cut / restore round-trip", () => {
|
|
166
|
+
test("cut removes the block lines; restore puts them back", () => {
|
|
167
|
+
const blocks = parseBlocks(BASE);
|
|
168
|
+
const listItem = blocks.find((candidate) => candidate.text === "second item")!;
|
|
169
|
+
const cut = cutBlock(BASE, listItem);
|
|
170
|
+
expect(cut).not.toContain("second item");
|
|
171
|
+
const display = buildDisplay(BASE, cut);
|
|
172
|
+
const deletedIndex = display.findIndex((block) => block.type === "del");
|
|
173
|
+
const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
|
|
174
|
+
// undefined = the working copy round-tripped back to the submitted revision
|
|
175
|
+
const restored = restoreBlock(BASE, cut, listItem, line);
|
|
176
|
+
expect(restored).toBeUndefined();
|
|
177
|
+
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/view-plan.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan projection pipeline: parse → display blocks (base vs working
|
|
3
|
+
* copy reconciled) → styled runs. Pure functions - the TUI renders the runs
|
|
4
|
+
* (the native text primitive wraps them), tests assert on them directly.
|
|
5
|
+
* One planning layer: rendering, navigation, and selection all derive from
|
|
6
|
+
* this module; the rendered/work offset mappings keep the native selection
|
|
7
|
+
* addressable over word-diffed blocks.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
lcsDiff,
|
|
12
|
+
parseBlocks,
|
|
13
|
+
resolveAnchor,
|
|
14
|
+
wordDiff,
|
|
15
|
+
type Annotation,
|
|
16
|
+
type Block,
|
|
17
|
+
} from "@cueloop/schema";
|
|
18
|
+
|
|
19
|
+
// ── display model ─────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type DisplayType = "same" | "mod" | "del" | "add";
|
|
22
|
+
|
|
23
|
+
export interface DisplayBlock {
|
|
24
|
+
type: DisplayType;
|
|
25
|
+
kind: Block["kind"];
|
|
26
|
+
/** Block in the working copy (absent for del). */
|
|
27
|
+
work?: Block;
|
|
28
|
+
/** Block in the submitted revision (absent for add). */
|
|
29
|
+
base?: Block;
|
|
30
|
+
orderedItemNumber?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function displayText(block: DisplayBlock): string {
|
|
34
|
+
return (block.work ?? block.base)!.text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
|
|
38
|
+
function similar(baseBlock: Block, workBlock: Block): boolean {
|
|
39
|
+
if (baseBlock.kind !== workBlock.kind) return false;
|
|
40
|
+
const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
|
|
41
|
+
const baseWords = words(baseBlock.text);
|
|
42
|
+
const workWords = words(workBlock.text);
|
|
43
|
+
let intersection = 0;
|
|
44
|
+
for (const word of baseWords) if (workWords.has(word)) intersection++;
|
|
45
|
+
const union = baseWords.size + workWords.size - intersection;
|
|
46
|
+
return union === 0 ? true : intersection / union >= 0.3;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reconcile base blocks vs working blocks into the display list. */
|
|
50
|
+
export function buildDisplay(baseContent: string, workingContent?: string): DisplayBlock[] {
|
|
51
|
+
const baseBlocks = parseBlocks(baseContent);
|
|
52
|
+
if (workingContent === undefined) {
|
|
53
|
+
const display: DisplayBlock[] = baseBlocks.map((block) => ({
|
|
54
|
+
type: "same",
|
|
55
|
+
kind: block.kind,
|
|
56
|
+
work: block,
|
|
57
|
+
base: block,
|
|
58
|
+
}));
|
|
59
|
+
numberOrderedListItems(display);
|
|
60
|
+
return display;
|
|
61
|
+
}
|
|
62
|
+
const workBlocks = parseBlocks(workingContent);
|
|
63
|
+
const signature = (block: Block) => block.kind + "\0" + block.text;
|
|
64
|
+
const ops = lcsDiff(baseBlocks, workBlocks, (baseBlock, workBlock) => signature(baseBlock) === signature(workBlock));
|
|
65
|
+
const display: DisplayBlock[] = [];
|
|
66
|
+
let opIndex = 0;
|
|
67
|
+
while (opIndex < ops.length) {
|
|
68
|
+
const op = ops[opIndex]!;
|
|
69
|
+
if (op.kind === "ctx") {
|
|
70
|
+
display.push({ type: "same", kind: op.newValue!.kind, work: op.newValue, base: op.oldValue });
|
|
71
|
+
opIndex++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const deletedBlocks: Block[] = [];
|
|
75
|
+
const addedBlocks: Block[] = [];
|
|
76
|
+
while (opIndex < ops.length && ops[opIndex]!.kind !== "ctx") {
|
|
77
|
+
const changeOp = ops[opIndex]!;
|
|
78
|
+
if (changeOp.kind === "del") deletedBlocks.push(changeOp.oldValue!);
|
|
79
|
+
else addedBlocks.push(changeOp.newValue!);
|
|
80
|
+
opIndex++;
|
|
81
|
+
}
|
|
82
|
+
let deletedIndex = 0;
|
|
83
|
+
let addedIndex = 0;
|
|
84
|
+
while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
|
|
85
|
+
if (similar(deletedBlocks[deletedIndex]!, addedBlocks[addedIndex]!)) {
|
|
86
|
+
display.push({
|
|
87
|
+
type: "mod",
|
|
88
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
89
|
+
base: deletedBlocks[deletedIndex]!,
|
|
90
|
+
work: addedBlocks[addedIndex]!,
|
|
91
|
+
});
|
|
92
|
+
deletedIndex++;
|
|
93
|
+
addedIndex++;
|
|
94
|
+
} else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
|
|
95
|
+
display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex]! });
|
|
96
|
+
deletedIndex++;
|
|
97
|
+
} else {
|
|
98
|
+
display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex]! });
|
|
99
|
+
addedIndex++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
while (deletedIndex < deletedBlocks.length) {
|
|
103
|
+
display.push({ type: "del", kind: deletedBlocks[deletedIndex]!.kind, base: deletedBlocks[deletedIndex++]! });
|
|
104
|
+
}
|
|
105
|
+
while (addedIndex < addedBlocks.length) {
|
|
106
|
+
display.push({ type: "add", kind: addedBlocks[addedIndex]!.kind, work: addedBlocks[addedIndex++]! });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
numberOrderedListItems(display);
|
|
110
|
+
return display;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
114
|
+
let runLength = 0;
|
|
115
|
+
for (const block of display) {
|
|
116
|
+
if (block.kind === "oli") block.orderedItemNumber = ++runLength;
|
|
117
|
+
else runLength = 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── marks (annotation highlights + keyboard span) ──
|
|
122
|
+
|
|
123
|
+
export type RunRole =
|
|
124
|
+
| "plain"
|
|
125
|
+
| "ins"
|
|
126
|
+
| "del"
|
|
127
|
+
| "mark-comment"
|
|
128
|
+
| "mark-suggestion"
|
|
129
|
+
| "kspan"
|
|
130
|
+
| "mark-focus";
|
|
131
|
+
|
|
132
|
+
export interface StyleRun {
|
|
133
|
+
text: string;
|
|
134
|
+
role: RunRole;
|
|
135
|
+
/** Offset of this run in the block's working text; null for del runs. */
|
|
136
|
+
start: number | null;
|
|
137
|
+
annotationId?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface Mark {
|
|
141
|
+
start: number;
|
|
142
|
+
end: number;
|
|
143
|
+
role: RunRole;
|
|
144
|
+
annotationId?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve annotations against the working blocks and group marks per display
|
|
149
|
+
* index. Working blocks are the blocks of (workingCopy ?? artifact) - the
|
|
150
|
+
* same list buildDisplay derived work entries from.
|
|
151
|
+
*/
|
|
152
|
+
export function marksByDisplay(
|
|
153
|
+
annotations: Annotation[],
|
|
154
|
+
display: DisplayBlock[],
|
|
155
|
+
focusedId?: string,
|
|
156
|
+
): Map<number, Mark[]> {
|
|
157
|
+
// block list in "work space": display entries that carry a work block, in order
|
|
158
|
+
const workEntries = display
|
|
159
|
+
.map((block, displayIndex) => ({ block, displayIndex }))
|
|
160
|
+
.filter((entry) => entry.block.work !== undefined);
|
|
161
|
+
const workBlocks = workEntries.map((entry) => entry.block.work!);
|
|
162
|
+
const marksByIndex = new Map<number, Mark[]>();
|
|
163
|
+
for (const annotation of annotations) {
|
|
164
|
+
const resolved = resolveAnchor(annotation.anchor, workBlocks);
|
|
165
|
+
if (!resolved) continue;
|
|
166
|
+
const entry = workEntries[resolved.blockIndex];
|
|
167
|
+
if (!entry) continue;
|
|
168
|
+
const marks = marksByIndex.get(entry.displayIndex) ?? [];
|
|
169
|
+
marks.push({
|
|
170
|
+
start: resolved.start,
|
|
171
|
+
end: resolved.end,
|
|
172
|
+
role:
|
|
173
|
+
annotation.id === focusedId
|
|
174
|
+
? "mark-focus"
|
|
175
|
+
: annotation.kind === "suggestion"
|
|
176
|
+
? "mark-suggestion"
|
|
177
|
+
: "mark-comment",
|
|
178
|
+
annotationId: annotation.id,
|
|
179
|
+
});
|
|
180
|
+
marksByIndex.set(entry.displayIndex, marks);
|
|
181
|
+
}
|
|
182
|
+
return marksByIndex;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
186
|
+
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
187
|
+
if (block.type === "mod" && markup) {
|
|
188
|
+
const ops = wordDiff(block.base!.text, block.work!.text);
|
|
189
|
+
const runs: StyleRun[] = [];
|
|
190
|
+
let workOffset = 0;
|
|
191
|
+
for (const op of ops) {
|
|
192
|
+
if (op.kind === "del") {
|
|
193
|
+
runs.push({ text: op.oldValue!, role: "del", start: null });
|
|
194
|
+
} else {
|
|
195
|
+
runs.push({ text: op.newValue!, role: op.kind === "add" ? "ins" : "plain", start: workOffset });
|
|
196
|
+
workOffset += op.newValue!.length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return runs;
|
|
200
|
+
}
|
|
201
|
+
return [{ text: displayText(block), role: "plain", start: 0 }];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Split runs at mark boundaries; marks only bind to runs with offsets. */
|
|
205
|
+
export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
|
|
206
|
+
if (!marks.length) return runs;
|
|
207
|
+
const splitRuns: StyleRun[] = [];
|
|
208
|
+
for (const run of runs) {
|
|
209
|
+
if (run.start === null) {
|
|
210
|
+
splitRuns.push(run);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const bounds = new Set<number>([0, run.text.length]);
|
|
214
|
+
for (const mark of marks) {
|
|
215
|
+
const boundStart = Math.max(0, mark.start - run.start);
|
|
216
|
+
const boundEnd = Math.min(run.text.length, mark.end - run.start);
|
|
217
|
+
if (boundStart < boundEnd) {
|
|
218
|
+
bounds.add(boundStart);
|
|
219
|
+
bounds.add(boundEnd);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const runStart = run.start;
|
|
223
|
+
const boundaries = [...bounds].sort((left, right) => left - right);
|
|
224
|
+
for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
|
|
225
|
+
const sliceStart = boundaries[boundaryIndex]!;
|
|
226
|
+
const sliceEnd = boundaries[boundaryIndex + 1]!;
|
|
227
|
+
if (sliceStart >= sliceEnd) continue;
|
|
228
|
+
const absoluteStart = runStart + sliceStart;
|
|
229
|
+
const mark = marks.find(
|
|
230
|
+
(candidate) => candidate.start <= absoluteStart && runStart + sliceEnd <= candidate.end,
|
|
231
|
+
);
|
|
232
|
+
splitRuns.push({
|
|
233
|
+
text: run.text.slice(sliceStart, sliceEnd),
|
|
234
|
+
// a mark never overrides ins/del emphasis; it rides on plain text
|
|
235
|
+
role: mark && run.role === "plain" ? mark.role : run.role,
|
|
236
|
+
start: absoluteStart,
|
|
237
|
+
annotationId: mark?.annotationId ?? run.annotationId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return splitRuns;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Rendered-text offset of a work-text offset within a block's runs. The
|
|
246
|
+
* rendered text is every run's text in order (inline del runs included);
|
|
247
|
+
* only positioned runs carry work offsets. Drives the renderer's native
|
|
248
|
+
* selection from keyboard span offsets - the quote-anchor path depends on
|
|
249
|
+
* this mapping staying exact.
|
|
250
|
+
*/
|
|
251
|
+
export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
|
|
252
|
+
let rendered = 0;
|
|
253
|
+
for (const run of runs) {
|
|
254
|
+
if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
|
|
255
|
+
return rendered + (workOffset - run.start);
|
|
256
|
+
}
|
|
257
|
+
rendered += run.text.length;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Work-text range covered by a rendered-text selection [start, end). Del runs
|
|
264
|
+
* and other unpositioned text inside the selection contribute nothing; the
|
|
265
|
+
* result is the tightest work range whose text the selection touched.
|
|
266
|
+
*/
|
|
267
|
+
export function workRangeForRendered(
|
|
268
|
+
runs: StyleRun[],
|
|
269
|
+
renderedStart: number,
|
|
270
|
+
renderedEnd: number,
|
|
271
|
+
): { start: number; end: number } | null {
|
|
272
|
+
let rendered = 0;
|
|
273
|
+
let range: { start: number; end: number } | null = null;
|
|
274
|
+
for (const run of runs) {
|
|
275
|
+
const runRenderedStart = rendered;
|
|
276
|
+
const runRenderedEnd = rendered + run.text.length;
|
|
277
|
+
rendered = runRenderedEnd;
|
|
278
|
+
if (run.start === null) continue;
|
|
279
|
+
const overlapStart = Math.max(renderedStart, runRenderedStart);
|
|
280
|
+
const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
|
|
281
|
+
if (overlapEnd <= overlapStart) continue;
|
|
282
|
+
const workStart = run.start + (overlapStart - runRenderedStart);
|
|
283
|
+
const workEnd = run.start + (overlapEnd - runRenderedStart);
|
|
284
|
+
if (!range) range = { start: workStart, end: workEnd };
|
|
285
|
+
else {
|
|
286
|
+
range.start = Math.min(range.start, workStart);
|
|
287
|
+
range.end = Math.max(range.end, workEnd);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return range;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Line delta between two revision contents, for the sheet-header summary. */
|
|
294
|
+
export function revisionDelta(previousContent: string, nextContent: string): { added: number; removed: number } {
|
|
295
|
+
const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
|
|
296
|
+
let added = 0;
|
|
297
|
+
let removed = 0;
|
|
298
|
+
for (const op of ops) {
|
|
299
|
+
if (op.kind === "add") added++;
|
|
300
|
+
else if (op.kind === "del") removed++;
|
|
301
|
+
}
|
|
302
|
+
return { added, removed };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── keyboard span mode ────────────────────────
|
|
306
|
+
|
|
307
|
+
export interface SpanState {
|
|
308
|
+
displayIndex: number;
|
|
309
|
+
wordIndex: number;
|
|
310
|
+
wordEnd: number;
|
|
311
|
+
start: number;
|
|
312
|
+
end: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function wordRanges(text: string): [number, number][] {
|
|
316
|
+
const ranges: [number, number][] = [];
|
|
317
|
+
const wordPattern = /\S+/g;
|
|
318
|
+
let match: RegExpExecArray | null;
|
|
319
|
+
while ((match = wordPattern.exec(text))) ranges.push([match.index, match.index + match[0]!.length]);
|
|
320
|
+
return ranges;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function startSpan(displayIndex: number, text: string): SpanState | null {
|
|
324
|
+
const words = wordRanges(text);
|
|
325
|
+
if (!words.length) return null;
|
|
326
|
+
return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function spanKey(span: SpanState, key: string, text: string): SpanState {
|
|
330
|
+
const words = wordRanges(text);
|
|
331
|
+
const nextSpan = { ...span };
|
|
332
|
+
const width = nextSpan.wordEnd - nextSpan.wordIndex;
|
|
333
|
+
if (key === "l") nextSpan.wordEnd = Math.min(words.length - 1, nextSpan.wordEnd + 1);
|
|
334
|
+
else if (key === "h") nextSpan.wordEnd = Math.max(nextSpan.wordIndex, nextSpan.wordEnd - 1);
|
|
335
|
+
else if (key === "w") {
|
|
336
|
+
const slidTo = Math.min(words.length - 1 - width, nextSpan.wordIndex + 1);
|
|
337
|
+
nextSpan.wordIndex = slidTo;
|
|
338
|
+
nextSpan.wordEnd = slidTo + width;
|
|
339
|
+
} else if (key === "b") {
|
|
340
|
+
const slidTo = Math.max(0, nextSpan.wordIndex - 1);
|
|
341
|
+
nextSpan.wordIndex = slidTo;
|
|
342
|
+
nextSpan.wordEnd = slidTo + width;
|
|
343
|
+
} else if (key === "$") nextSpan.wordEnd = words.length - 1;
|
|
344
|
+
else if (key === "0") {
|
|
345
|
+
nextSpan.wordEnd = width;
|
|
346
|
+
nextSpan.wordIndex = 0;
|
|
347
|
+
} else return span;
|
|
348
|
+
nextSpan.start = words[nextSpan.wordIndex]![0];
|
|
349
|
+
nextSpan.end = words[nextSpan.wordEnd]![1];
|
|
350
|
+
return nextSpan;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── working-copy block operations ─────────────
|
|
354
|
+
|
|
355
|
+
/** The next work block after a display index (a del block re-enters before it). */
|
|
356
|
+
export function nextWorkBlock(display: DisplayBlock[], displayIndex: number): Block | undefined {
|
|
357
|
+
for (let candidateIndex = displayIndex + 1; candidateIndex < display.length; candidateIndex++) {
|
|
358
|
+
const workBlock = display[candidateIndex]!.work;
|
|
359
|
+
if (workBlock) return workBlock;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|