@cueloop/client 0.1.0-alpha.16 → 0.1.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +138 -139
- package/src/App.walk.test.tsx +1 -1
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.tsx +1 -1
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +12 -12
- package/src/editor.ts +1 -1
- package/src/herdr.test.ts +3 -34
- package/src/herdr.ts +8 -21
- package/src/key-bindings.ts +1 -1
- package/src/keymap.test.ts +111 -111
- package/src/keymap.ts +64 -64
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +76 -73
- package/src/theme.ts +1 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.ts +4 -4
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
spanKey,
|
|
11
11
|
startSpan,
|
|
12
12
|
workRangeForRendered,
|
|
13
|
-
} from "./view";
|
|
13
|
+
} from "./view-plan";
|
|
14
14
|
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
15
15
|
|
|
16
16
|
const BASE = `# Plan
|
|
@@ -25,56 +25,56 @@ The daemon persists sessions to disk atomically.
|
|
|
25
25
|
|
|
26
26
|
describe("buildDisplay reconciliation", () => {
|
|
27
27
|
test("no working copy: everything is same", () => {
|
|
28
|
-
const
|
|
29
|
-
expect(
|
|
28
|
+
const display = buildDisplay(BASE);
|
|
29
|
+
expect(display.every((block) => block.type === "same")).toBe(true);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test("reworded block pairs as mod (similar)", () => {
|
|
33
33
|
const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
|
|
34
|
-
const
|
|
35
|
-
const mod =
|
|
34
|
+
const display = buildDisplay(BASE, working);
|
|
35
|
+
const mod = display.find((block) => block.type === "mod")!;
|
|
36
36
|
expect(mod.base!.text).toContain("persists");
|
|
37
37
|
expect(mod.work!.text).toContain("writes");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("unrelated cut + insert do NOT merge into a mod", () => {
|
|
41
41
|
const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
|
|
42
|
-
const
|
|
43
|
-
expect(
|
|
44
|
-
expect(
|
|
45
|
-
expect(
|
|
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
46
|
});
|
|
47
47
|
|
|
48
48
|
test("ordered list items get running numbers", () => {
|
|
49
|
-
const
|
|
50
|
-
expect(
|
|
49
|
+
const display = buildDisplay("1. one\n2. two\n3. three\n");
|
|
50
|
+
expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
describe("blockRuns + overlayMarks", () => {
|
|
55
55
|
test("mod blocks emit word-diff runs that reconstruct both sides", () => {
|
|
56
|
-
const
|
|
57
|
-
const runs = blockRuns(
|
|
58
|
-
const oldSide = runs.filter((
|
|
59
|
-
const newSide = runs.filter((
|
|
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
60
|
expect(oldSide).toBe("plain old text here");
|
|
61
61
|
expect(newSide).toBe("plain new text here");
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("marks bind to working-text offsets and skip del runs", () => {
|
|
65
|
-
const
|
|
66
|
-
const runs = blockRuns(
|
|
65
|
+
const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
66
|
+
const runs = blockRuns(block, true);
|
|
67
67
|
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
68
|
-
const markedText = marked.filter((
|
|
68
|
+
const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
|
|
69
69
|
expect(markedText).toBe("words");
|
|
70
70
|
// del run text untouched
|
|
71
|
-
expect(marked.some((
|
|
71
|
+
expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("marks never override ins emphasis", () => {
|
|
75
|
-
const
|
|
76
|
-
const runs = overlayMarks(blockRuns(
|
|
77
|
-
expect(runs.some((
|
|
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
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -90,8 +90,8 @@ describe("marksByDisplay", () => {
|
|
|
90
90
|
createdAt: "",
|
|
91
91
|
};
|
|
92
92
|
const map = marksByDisplay([ann], display);
|
|
93
|
-
const [
|
|
94
|
-
expect(displayText(display[
|
|
93
|
+
const [displayIndex, marks] = [...map.entries()][0]!;
|
|
94
|
+
expect(displayText(display[displayIndex]!)).toContain("atomically");
|
|
95
95
|
expect(marks[0]!.annotationId).toBe("a1");
|
|
96
96
|
});
|
|
97
97
|
|
|
@@ -110,20 +110,20 @@ describe("marksByDisplay", () => {
|
|
|
110
110
|
|
|
111
111
|
describe("rendered/work offset mapping", () => {
|
|
112
112
|
test("plain blocks map 1:1 in both directions", () => {
|
|
113
|
-
const
|
|
114
|
-
const runs = blockRuns(
|
|
113
|
+
const block = buildDisplay("alpha beta gamma\n")[0]!;
|
|
114
|
+
const runs = blockRuns(block, true);
|
|
115
115
|
expect(renderedOffsetFor(runs, 6)).toBe(6);
|
|
116
116
|
expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
test("inline del runs shift rendered offsets in word-diffed blocks", () => {
|
|
120
120
|
// rendered: "old new words linger" - "old " is a del run without offsets
|
|
121
|
-
const
|
|
122
|
-
const runs = blockRuns(
|
|
123
|
-
const workText = displayText(
|
|
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
124
|
const wordsAt = workText.indexOf("words");
|
|
125
125
|
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
126
|
-
const renderedText = runs.map((
|
|
126
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
127
127
|
expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
|
|
128
128
|
// reading a rendered selection over "words" recovers the work range
|
|
129
129
|
expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
|
|
@@ -133,12 +133,12 @@ describe("rendered/work offset mapping", () => {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
test("a rendered selection spanning a del run keeps only positioned text", () => {
|
|
136
|
-
const
|
|
137
|
-
const runs = blockRuns(
|
|
138
|
-
const renderedText = runs.map((
|
|
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
139
|
// select everything rendered: the work range is the whole work text
|
|
140
140
|
const range = workRangeForRendered(runs, 0, renderedText.length)!;
|
|
141
|
-
expect(displayText(
|
|
141
|
+
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
test("offsets outside any positioned run map to null", () => {
|
|
@@ -151,29 +151,29 @@ describe("rendered/work offset mapping", () => {
|
|
|
151
151
|
describe("span mode", () => {
|
|
152
152
|
const text = "one two three four";
|
|
153
153
|
test("v selects the first word; l grows; w slides; $ to end", () => {
|
|
154
|
-
let
|
|
155
|
-
expect(text.slice(
|
|
156
|
-
|
|
157
|
-
expect(text.slice(
|
|
158
|
-
|
|
159
|
-
expect(text.slice(
|
|
160
|
-
|
|
161
|
-
expect(text.slice(
|
|
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
162
|
});
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
describe("cut / restore round-trip", () => {
|
|
166
166
|
test("cut removes the block lines; restore puts them back", () => {
|
|
167
167
|
const blocks = parseBlocks(BASE);
|
|
168
|
-
const
|
|
169
|
-
const cut = cutBlock(BASE,
|
|
168
|
+
const listItem = blocks.find((candidate) => candidate.text === "second item")!;
|
|
169
|
+
const cut = cutBlock(BASE, listItem);
|
|
170
170
|
expect(cut).not.toContain("second item");
|
|
171
171
|
const display = buildDisplay(BASE, cut);
|
|
172
|
-
const
|
|
173
|
-
const line = restoreLine(nextWorkBlock(display,
|
|
172
|
+
const deletedIndex = display.findIndex((block) => block.type === "del");
|
|
173
|
+
const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
|
|
174
174
|
// undefined = the working copy round-tripped back to the submitted revision
|
|
175
|
-
const restored = restoreBlock(BASE, cut,
|
|
175
|
+
const restored = restoreBlock(BASE, cut, listItem, line);
|
|
176
176
|
expect(restored).toBeUndefined();
|
|
177
|
-
expect(buildDisplay(BASE, restored).every((
|
|
177
|
+
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
178
178
|
});
|
|
179
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
|
+
}
|
package/src/walk.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Guided-walk projections
|
|
2
|
+
* Guided-walk projections: pure helpers over the diff row model - no
|
|
3
3
|
* IO, no React. The walk steps through the changed files in patch order;
|
|
4
4
|
* which files were seen lives on the session record (viewedPaths), which
|
|
5
5
|
* file is on screen is view state in the session controller.
|
|
@@ -31,11 +31,11 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
|
31
31
|
byPath.set(row.file, file);
|
|
32
32
|
files.push(file);
|
|
33
33
|
}
|
|
34
|
-
if (row.
|
|
35
|
-
if (row.
|
|
34
|
+
if (row.kind !== "add" && row.kind !== "del") continue;
|
|
35
|
+
if (row.kind === "add") file.added += 1;
|
|
36
36
|
else file.removed += 1;
|
|
37
37
|
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
38
|
-
file.preview.push({ sign: row.
|
|
38
|
+
file.preview.push({ sign: row.kind === "add" ? "+" : "-", text: row.text.replace(/\n$/, "") });
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
return files;
|
package/src/components/format.ts
DELETED