@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.12
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 +7 -7
- package/src/App.autoclose.test.tsx +24 -35
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -21
- package/src/App.plan-review.test.tsx +21 -52
- package/src/App.readonly.test.tsx +3 -18
- package/src/App.rendering.test.tsx +5 -13
- package/src/App.submit-confirm.test.tsx +7 -25
- package/src/App.test.tsx +13 -52
- package/src/App.tsx +197 -910
- 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 +50 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +22 -0
- package/src/components/ConfirmCard.tsx +130 -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/__snapshots__/stories.test.tsx.snap +1189 -0
- package/src/components/format.ts +6 -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/key-bindings.test.ts +103 -0
- package/src/key-bindings.ts +332 -0
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +28 -7
- package/src/test-support.ts +87 -0
- package/src/theme.ts +3 -0
- package/src/view.test.ts +35 -13
- package/src/view.ts +41 -64
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* App subscribes to snapshots and keeps only view state.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
10
11
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
11
12
|
import {
|
|
12
13
|
cutBlock,
|
|
@@ -57,6 +58,8 @@ export interface ReviewControllerOptions {
|
|
|
57
58
|
/** Observer mode: stored for the key reducer's read-only gate. */
|
|
58
59
|
readOnly?: boolean;
|
|
59
60
|
onExit?: (code: number) => void;
|
|
61
|
+
/** Timer source for the auto-close countdown; tests inject a ManualClock. */
|
|
62
|
+
clock?: Clock;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
export interface ReviewController {
|
|
@@ -118,13 +121,15 @@ class Controller implements ReviewController {
|
|
|
118
121
|
private autoClose: AutoClose = "off";
|
|
119
122
|
private obsidian: ObsidianConfig | null = null;
|
|
120
123
|
private exporters = new Map<string, Exporter>();
|
|
121
|
-
private
|
|
124
|
+
private readonly clock: Clock;
|
|
125
|
+
private countdown: TimerHandle | undefined;
|
|
122
126
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
123
127
|
private derivedFor: ReviewSession | null = null;
|
|
124
128
|
private derived: { display: DisplayBlock[]; rows: DiffRow[] } = { display: [], rows: [] };
|
|
125
129
|
|
|
126
130
|
constructor(private readonly opts: ReviewControllerOptions) {
|
|
127
131
|
this.readOnly = opts.readOnly ?? false;
|
|
132
|
+
this.clock = opts.clock ?? new SystemClock();
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -165,10 +170,15 @@ class Controller implements ReviewController {
|
|
|
165
170
|
|
|
166
171
|
close(): void {
|
|
167
172
|
this.closed = true;
|
|
168
|
-
|
|
173
|
+
this.clearCountdown();
|
|
169
174
|
this.client?.close();
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
private clearCountdown(): void {
|
|
178
|
+
if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
|
|
179
|
+
this.countdown = undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
172
182
|
applyConfig(cfg: CueloopConfig): void {
|
|
173
183
|
this.autoClose = cfg.ui.autoClose;
|
|
174
184
|
this.obsidian = cfg.integrations.obsidian;
|
|
@@ -209,12 +219,23 @@ class Controller implements ReviewController {
|
|
|
209
219
|
return s ? (s.workingCopy ?? s.artifact.content) : "";
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
// Refreshes race the connection teardown: an event can arrive while close()
|
|
223
|
+
// is rejecting in-flight requests, and a fire-and-forget refresh must never
|
|
224
|
+
// surface that as an unhandled rejection.
|
|
212
225
|
private async refreshSession(id: string): Promise<void> {
|
|
213
|
-
|
|
226
|
+
try {
|
|
227
|
+
if (this.client) this.update({ session: await this.client.sessionGet(id) });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
|
|
230
|
+
}
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
private async refreshInbox(): Promise<void> {
|
|
217
|
-
|
|
234
|
+
try {
|
|
235
|
+
if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
|
|
238
|
+
}
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
@@ -370,18 +391,18 @@ class Controller implements ReviewController {
|
|
|
370
391
|
private startCounting(remaining: number): void {
|
|
371
392
|
if (remaining <= 0) return this.finishReview();
|
|
372
393
|
this.update({ completion: { phase: "counting", remaining } });
|
|
373
|
-
this.countdown = setTimeout(() => this.startCounting(remaining - 1), 1000);
|
|
394
|
+
this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
|
|
374
395
|
}
|
|
375
396
|
|
|
376
397
|
finishReview(): void {
|
|
377
|
-
|
|
398
|
+
this.clearCountdown();
|
|
378
399
|
const pane = returnPaneFor(this.snap.session?.artifact.meta.herdrPane);
|
|
379
400
|
if (pane) focusHerdrPane(pane);
|
|
380
401
|
this.opts.onExit?.(0);
|
|
381
402
|
}
|
|
382
403
|
|
|
383
404
|
dismissCompletion(): void {
|
|
384
|
-
|
|
405
|
+
this.clearCountdown();
|
|
385
406
|
this.update({ completion: { phase: "dismissed" } });
|
|
386
407
|
}
|
|
387
408
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared driving helpers for the virtual-terminal App suites: key presses
|
|
3
|
+
* settle through a macrotask yield (input parser + React scheduler), one
|
|
4
|
+
* render pass, and the renderer's visual-idle wait - no fixed-duration
|
|
5
|
+
* sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
|
|
6
|
+
* generous pass budgets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TestRendererSetup } from "@opentui/core/testing";
|
|
10
|
+
|
|
11
|
+
/** Pass budget for waits that include daemon or subprocess round-trips. */
|
|
12
|
+
export const WAIT_PASSES = { maxPasses: 400 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One macrotask yield lets the input parser and the React scheduler run,
|
|
16
|
+
* then a render pass commits the result before the visual-idle wait.
|
|
17
|
+
*/
|
|
18
|
+
export async function settle(setup: TestRendererSetup): Promise<void> {
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
20
|
+
await setup.renderOnce();
|
|
21
|
+
await setup.waitForVisualIdle();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Drive one key press: letters type as text; named keys use KeyCodes ids. */
|
|
25
|
+
export async function press(setup: TestRendererSetup, key: string): Promise<void> {
|
|
26
|
+
if (key === "enter") setup.mockInput.pressKey("RETURN");
|
|
27
|
+
else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
28
|
+
else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
|
|
29
|
+
else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
30
|
+
else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
31
|
+
else return typeText(setup, key);
|
|
32
|
+
await settle(setup);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
|
|
36
|
+
await setup.mockInput.typeText(text);
|
|
37
|
+
await settle(setup);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const WAIT_DEADLINE_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* waitForFrame gives up as soon as the renderer is idle, but external timers
|
|
44
|
+
* (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
|
|
45
|
+
* event loop while nothing is scheduled. Between attempts, one real
|
|
46
|
+
* event-loop turn plus a render pass lets those land; the deadline bounds
|
|
47
|
+
* the whole wait.
|
|
48
|
+
*/
|
|
49
|
+
async function waitForFramePredicate(
|
|
50
|
+
setup: TestRendererSetup,
|
|
51
|
+
predicate: (frame: string) => boolean,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const deadline = Date.now() + WAIT_DEADLINE_MS;
|
|
54
|
+
for (;;) {
|
|
55
|
+
try {
|
|
56
|
+
return await setup.waitForFrame(predicate, { maxPasses: 50 });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (Date.now() > deadline) throw error;
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
60
|
+
await setup.renderOnce();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Wait until a state predicate holds (daemon round-trips included). */
|
|
66
|
+
export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
|
|
67
|
+
const deadline = Date.now() + WAIT_DEADLINE_MS;
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
return await setup.waitFor(predicate, { maxPasses: 50 });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (Date.now() > deadline) throw error;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
74
|
+
await setup.renderOnce();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
80
|
+
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
81
|
+
return waitForFramePredicate(setup, (frame) => frame.includes(needle));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Wait until the frame no longer contains the needle. */
|
|
85
|
+
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
86
|
+
return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
|
|
87
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface Theme {
|
|
|
23
23
|
markSuggestionBg: string;
|
|
24
24
|
insFg: string;
|
|
25
25
|
delFg: string;
|
|
26
|
+
/** Dimmed layer painted behind centered dialogs. */
|
|
27
|
+
backdrop: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const DARK: Theme = {
|
|
@@ -43,4 +45,5 @@ export const DARK: Theme = {
|
|
|
43
45
|
markSuggestionBg: "#20351f",
|
|
44
46
|
insFg: "#62d96b",
|
|
45
47
|
delFg: "#ff6b6b",
|
|
48
|
+
backdrop: "#14141b",
|
|
46
49
|
}
|
package/src/view.test.ts
CHANGED
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
marksByDisplay,
|
|
7
7
|
nextWorkBlock,
|
|
8
8
|
overlayMarks,
|
|
9
|
+
renderedOffsetFor,
|
|
9
10
|
spanKey,
|
|
10
11
|
startSpan,
|
|
11
|
-
|
|
12
|
+
workRangeForRendered,
|
|
12
13
|
} from "./view";
|
|
13
14
|
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
14
15
|
|
|
@@ -107,22 +108,43 @@ describe("marksByDisplay", () => {
|
|
|
107
108
|
});
|
|
108
109
|
});
|
|
109
110
|
|
|
110
|
-
describe("
|
|
111
|
-
test("
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
expect(
|
|
115
|
-
expect(
|
|
111
|
+
describe("rendered/work offset mapping", () => {
|
|
112
|
+
test("plain blocks map 1:1 in both directions", () => {
|
|
113
|
+
const d = buildDisplay("alpha beta gamma\n")[0]!;
|
|
114
|
+
const runs = blockRuns(d, 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 d = buildDisplay("old words linger\n", "new words linger\n")[0]!;
|
|
122
|
+
const runs = blockRuns(d, true);
|
|
123
|
+
const workText = displayText(d);
|
|
124
|
+
const wordsAt = workText.indexOf("words");
|
|
125
|
+
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
126
|
+
const renderedText = runs.map((r) => r.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
|
+
});
|
|
116
133
|
});
|
|
117
134
|
|
|
118
|
-
test("
|
|
119
|
-
const
|
|
120
|
-
|
|
135
|
+
test("a rendered selection spanning a del run keeps only positioned text", () => {
|
|
136
|
+
const d = buildDisplay("old words\n", "new words\n")[0]!;
|
|
137
|
+
const runs = blockRuns(d, true);
|
|
138
|
+
const renderedText = runs.map((r) => r.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(d).slice(range.start, range.end)).toBe(displayText(d));
|
|
121
142
|
});
|
|
122
143
|
|
|
123
|
-
test("
|
|
124
|
-
const
|
|
125
|
-
expect(
|
|
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();
|
|
126
148
|
});
|
|
127
149
|
});
|
|
128
150
|
|
package/src/view.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The projection pipeline (#22): parse → display blocks (base vs working
|
|
3
|
-
* copy reconciled) → styled runs
|
|
4
|
-
*
|
|
5
|
-
* rendering, navigation, and selection all derive from
|
|
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.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import {
|
|
@@ -219,77 +221,52 @@ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
|
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
224
|
+
* Rendered-text offset of a work-text offset within a block's runs. The
|
|
225
|
+
* rendered text is every run's text in order (inline del runs included);
|
|
226
|
+
* only positioned runs carry work offsets. Drives the renderer's native
|
|
227
|
+
* selection from keyboard span offsets - the quote-anchor path depends on
|
|
228
|
+
* this mapping staying exact.
|
|
225
229
|
*/
|
|
226
|
-
export function
|
|
227
|
-
|
|
228
|
-
let line: StyleRun[] = [];
|
|
229
|
-
let used = 0;
|
|
230
|
-
const pushLine = () => {
|
|
231
|
-
lines.push(line);
|
|
232
|
-
line = [];
|
|
233
|
-
used = 0;
|
|
234
|
-
};
|
|
230
|
+
export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
|
|
231
|
+
let rendered = 0;
|
|
235
232
|
for (const run of runs) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let consumed = 0;
|
|
239
|
-
const startAt = (offsetInRun: number): number | null => (run.start === null ? null : run.start + offsetInRun);
|
|
240
|
-
for (const tok of tokens) {
|
|
241
|
-
if (tok === "\n") {
|
|
242
|
-
consumed += 1;
|
|
243
|
-
pushLine();
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (used + tok.length > width && used > 0 && tok.trim() !== "") pushLine();
|
|
247
|
-
if (tok.trim() === "" && used === 0 && lines.length > 0) {
|
|
248
|
-
consumed += tok.length;
|
|
249
|
-
continue; // no leading spaces after wrap
|
|
250
|
-
}
|
|
251
|
-
let slice = tok;
|
|
252
|
-
while (slice.length > width) {
|
|
253
|
-
const piece = slice.slice(0, width - used);
|
|
254
|
-
line.push({ ...run, text: piece, start: startAt(consumed) });
|
|
255
|
-
consumed += piece.length;
|
|
256
|
-
slice = slice.slice(piece.length);
|
|
257
|
-
pushLine();
|
|
258
|
-
}
|
|
259
|
-
line.push({ ...run, text: slice, start: startAt(consumed) });
|
|
260
|
-
consumed += slice.length;
|
|
261
|
-
used += slice.length;
|
|
233
|
+
if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
|
|
234
|
+
return rendered + (workOffset - run.start);
|
|
262
235
|
}
|
|
236
|
+
rendered += run.text.length;
|
|
263
237
|
}
|
|
264
|
-
|
|
265
|
-
return lines;
|
|
238
|
+
return null;
|
|
266
239
|
}
|
|
267
240
|
|
|
268
241
|
/**
|
|
269
|
-
*
|
|
270
|
-
* and
|
|
271
|
-
*
|
|
242
|
+
* Work-text range covered by a rendered-text selection [start, end). Del runs
|
|
243
|
+
* and other unpositioned text inside the selection contribute nothing; the
|
|
244
|
+
* result is the tightest work range whose text the selection touched.
|
|
272
245
|
*/
|
|
273
|
-
export function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
246
|
+
export function workRangeForRendered(
|
|
247
|
+
runs: StyleRun[],
|
|
248
|
+
renderedStart: number,
|
|
249
|
+
renderedEnd: number,
|
|
250
|
+
): { start: number; end: number } | null {
|
|
251
|
+
let rendered = 0;
|
|
252
|
+
let range: { start: number; end: number } | null = null;
|
|
253
|
+
for (const run of runs) {
|
|
254
|
+
const runRenderedStart = rendered;
|
|
255
|
+
const runRenderedEnd = rendered + run.text.length;
|
|
256
|
+
rendered = runRenderedEnd;
|
|
257
|
+
if (run.start === null) continue;
|
|
258
|
+
const overlapStart = Math.max(renderedStart, runRenderedStart);
|
|
259
|
+
const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
|
|
260
|
+
if (overlapEnd <= overlapStart) continue;
|
|
261
|
+
const workStart = run.start + (overlapStart - runRenderedStart);
|
|
262
|
+
const workEnd = run.start + (overlapEnd - runRenderedStart);
|
|
263
|
+
if (!range) range = { start: workStart, end: workEnd };
|
|
264
|
+
else {
|
|
265
|
+
range.start = Math.min(range.start, workStart);
|
|
266
|
+
range.end = Math.max(range.end, workEnd);
|
|
281
267
|
}
|
|
282
268
|
}
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Inline compose box height, shared by layout and render so the row math and
|
|
288
|
-
* the mounted box never drift: top border (title), the single-row input, the
|
|
289
|
-
* Save/Cancel button row, bottom border.
|
|
290
|
-
*/
|
|
291
|
-
export function composeBoxHeight(): number {
|
|
292
|
-
return 4;
|
|
269
|
+
return range;
|
|
293
270
|
}
|
|
294
271
|
|
|
295
272
|
/** Line delta between two revision contents, for the sheet-header summary. */
|
package/src/syntax.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { filetypeFor, highlightCode } from "./syntax";
|
|
3
|
-
|
|
4
|
-
const TS = `export function gate(full: number) {
|
|
5
|
-
// threshold
|
|
6
|
-
return full < 0.6 ? "blocked" : "clear";
|
|
7
|
-
}`;
|
|
8
|
-
|
|
9
|
-
describe("highlightCode", () => {
|
|
10
|
-
test("token text concatenates back to the exact source (verbatim rendering)", async () => {
|
|
11
|
-
const lines = await highlightCode(TS, "ts");
|
|
12
|
-
expect(lines).not.toBeNull();
|
|
13
|
-
const rebuilt = lines!.map((l) => l.map((t) => t.content).join("")).join("\n");
|
|
14
|
-
expect(rebuilt).toBe(TS);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("keywords, strings, and comments get distinct colors", async () => {
|
|
18
|
-
const lines = await highlightCode(TS, "ts");
|
|
19
|
-
const colorOf = (needle: string) => {
|
|
20
|
-
for (const line of lines!) for (const t of line) if (t.content.includes(needle)) return t.color;
|
|
21
|
-
return undefined;
|
|
22
|
-
};
|
|
23
|
-
const kw = colorOf("export");
|
|
24
|
-
const str = colorOf("blocked");
|
|
25
|
-
const comment = colorOf("threshold");
|
|
26
|
-
expect(kw).toBeDefined();
|
|
27
|
-
expect(str).toBeDefined();
|
|
28
|
-
expect(comment).toBeDefined();
|
|
29
|
-
expect(new Set([kw, str, comment]).size).toBe(3);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("unknown languages return null and render unstyled", async () => {
|
|
33
|
-
expect(await highlightCode("whatever", "brainfog")).toBeNull();
|
|
34
|
-
expect(await highlightCode("plain", undefined)).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("fence aliases map to real grammars", async () => {
|
|
38
|
-
expect(await highlightCode("x = 1", "py")).not.toBeNull();
|
|
39
|
-
expect(await highlightCode('echo "hi"', "sh")).not.toBeNull();
|
|
40
|
-
});
|
|
41
|
-
});
|
package/src/syntax.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Code-block syntax highlighting via Shiki (#67): TextMate-grammar tokens
|
|
3
|
-
* colored by a theme built from cueloop's tokens. The highlighter loads
|
|
4
|
-
* lazily on the first code block; unknown languages render unstyled.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { DARK as T } from "./theme";
|
|
8
|
-
|
|
9
|
-
export interface CodeToken {
|
|
10
|
-
content: string;
|
|
11
|
-
color?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type Highlighter = {
|
|
15
|
-
codeToTokensBase(code: string, opts: { lang: string; theme: string }): { content: string; color?: string }[][];
|
|
16
|
-
getLoadedLanguages(): string[];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const LANGS = ["typescript", "tsx", "javascript", "jsx", "json", "bash", "python", "rust", "go", "yaml", "diff", "markdown", "toml", "sql", "html", "css"];
|
|
20
|
-
|
|
21
|
-
/** Markdown fence info → Shiki language id. */
|
|
22
|
-
export function filetypeFor(lang?: string): string {
|
|
23
|
-
const l = (lang ?? "").toLowerCase();
|
|
24
|
-
const map: Record<string, string> = {
|
|
25
|
-
ts: "typescript",
|
|
26
|
-
js: "javascript",
|
|
27
|
-
py: "python",
|
|
28
|
-
rb: "ruby",
|
|
29
|
-
rs: "rust",
|
|
30
|
-
sh: "bash",
|
|
31
|
-
shell: "bash",
|
|
32
|
-
zsh: "bash",
|
|
33
|
-
yml: "yaml",
|
|
34
|
-
};
|
|
35
|
-
return map[l] ?? l;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function cueloopTheme() {
|
|
39
|
-
const scope = (scopes: string, foreground: string, fontStyle?: string) => ({
|
|
40
|
-
scope: scopes.split(", "),
|
|
41
|
-
settings: fontStyle ? { foreground, fontStyle } : { foreground },
|
|
42
|
-
});
|
|
43
|
-
return {
|
|
44
|
-
name: "cueloop",
|
|
45
|
-
type: "dark" as const,
|
|
46
|
-
colors: { "editor.background": T.elevated, "editor.foreground": T.textMuted },
|
|
47
|
-
settings: [
|
|
48
|
-
{ settings: { foreground: T.textMuted } },
|
|
49
|
-
scope("comment, punctuation.definition.comment", T.textDim, "italic"),
|
|
50
|
-
scope("string, string.quoted, punctuation.definition.string", T.green),
|
|
51
|
-
scope("constant.numeric, constant.language, constant.character", T.blue),
|
|
52
|
-
scope("keyword, storage, storage.type, storage.modifier, keyword.control", T.accent),
|
|
53
|
-
scope("entity.name.function, support.function, meta.function-call", T.text),
|
|
54
|
-
scope("entity.name.type, entity.name.class, support.type, support.class", T.blue),
|
|
55
|
-
scope("variable, variable.other, variable.parameter", T.textMuted),
|
|
56
|
-
scope("entity.name.tag", T.accent),
|
|
57
|
-
scope("entity.other.attribute-name", T.blue),
|
|
58
|
-
scope("punctuation, meta.brace", T.textDim),
|
|
59
|
-
scope("keyword.operator", T.textDim),
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let highlighterPromise: Promise<Highlighter | null> | null = null;
|
|
65
|
-
|
|
66
|
-
function getHighlighter(): Promise<Highlighter | null> {
|
|
67
|
-
highlighterPromise ??= import("shiki")
|
|
68
|
-
.then((shiki) => shiki.createHighlighter({ themes: [cueloopTheme()], langs: LANGS }) as Promise<Highlighter>)
|
|
69
|
-
.catch(() => null); // highlighting is an enhancement, never a blocker
|
|
70
|
-
return highlighterPromise;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Tokenize code into styled lines. Token text concatenates back to the exact
|
|
75
|
-
* source line, so verbatim rendering (indentation, no wrap) is preserved.
|
|
76
|
-
* Returns null when the language is unknown or the highlighter is unavailable.
|
|
77
|
-
*/
|
|
78
|
-
export async function highlightCode(content: string, lang?: string): Promise<CodeToken[][] | null> {
|
|
79
|
-
const language = filetypeFor(lang);
|
|
80
|
-
if (!language) return null;
|
|
81
|
-
const highlighter = await getHighlighter();
|
|
82
|
-
if (!highlighter || !highlighter.getLoadedLanguages().includes(language)) return null;
|
|
83
|
-
try {
|
|
84
|
-
return highlighter.codeToTokensBase(content, { lang: language, theme: "cueloop" });
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|