@cueloop/client 0.1.0-alpha.109 → 0.1.0-alpha.110
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 +9 -9
- package/src/annotations/components/AnnotationCards.tsx +4 -3
- package/src/app/App.prototype-markdown.test.tsx +1 -0
- package/src/app/App.readonly.test.tsx +1 -0
- package/src/app/App.tsx +15 -6
- package/src/app/App.workbench.test.tsx +5 -3
- package/src/diff/components/AnnotatableFileView.test.tsx +4 -1
- package/src/diff/components/DiffContentView.syntax.test.tsx +2 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +4 -2
- package/src/markdown/components/ThreadView.tsx +9 -5
- package/src/markdown/markdown-editor-syntax-style.ts +21 -6
- package/src/prototype/components/PrototypeContentView.tsx +1 -1
- package/src/testing/test-support.ts +42 -1
- package/src/ui/components/CodeBlock.tsx +3 -1
- package/src/ui/components/Tooltip.tsx +24 -3
- package/src/ui/components/primitives/IconButton.tsx +2 -2
- package/src/ui/components/syntax-highlight.ts +26 -10
- package/src/ui/use-frame-measure.ts +13 -2
- package/src/ui/use-terminal-virtualizer.ts +19 -2
- package/src/workbench/components/EditorGrid.split.test.tsx +1 -0
- package/src/workbench/components/FileContentsView.tsx +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.110",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,14 +27,14 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
34
|
-
"@opentui/core": "^0.
|
|
35
|
-
"@opentui/keymap": "0.
|
|
36
|
-
"@opentui/react": "^0.
|
|
37
|
-
"@opentui/ssh": "0.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.110",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.110",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.110",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.110",
|
|
34
|
+
"@opentui/core": "^0.6.8",
|
|
35
|
+
"@opentui/keymap": "0.6.8",
|
|
36
|
+
"@opentui/react": "^0.6.8",
|
|
37
|
+
"@opentui/ssh": "0.6.8",
|
|
38
38
|
"@pierre/diffs": "^1.6.1",
|
|
39
39
|
"@tanstack/virtual-core": "^3.19.7",
|
|
40
40
|
"diff": "^9",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useContext, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
-
import { usePaste } from "@opentui/react";
|
|
10
|
+
import { usePaste, useRenderer } from "@opentui/react";
|
|
11
11
|
import type {
|
|
12
12
|
BoxRenderable,
|
|
13
13
|
KeyBinding,
|
|
@@ -69,6 +69,7 @@ export function Composer({
|
|
|
69
69
|
agentEnabled?: boolean;
|
|
70
70
|
focusRequest?: number;
|
|
71
71
|
}): React.ReactNode {
|
|
72
|
+
const renderer = useRenderer();
|
|
72
73
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
73
74
|
const pastedImageCount = useRef(0);
|
|
74
75
|
const [rows, setRows] = useState(1);
|
|
@@ -86,7 +87,7 @@ export function Composer({
|
|
|
86
87
|
});
|
|
87
88
|
|
|
88
89
|
const paintReferences = (editor: TextareaRenderable): void => {
|
|
89
|
-
const { styleId } = referenceStyleFor(tokens);
|
|
90
|
+
const { styleId } = referenceStyleFor(tokens, renderer.nativeScene);
|
|
90
91
|
|
|
91
92
|
editor.editBuffer.clearAllHighlights();
|
|
92
93
|
for (const range of skillReferenceRanges(editor.plainText, referenceNames)) {
|
|
@@ -103,7 +104,7 @@ export function Composer({
|
|
|
103
104
|
if (!editor) return;
|
|
104
105
|
|
|
105
106
|
editor.cursorOffset = seed.length;
|
|
106
|
-
editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens).style);
|
|
107
|
+
editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens, renderer.nativeScene).style);
|
|
107
108
|
paintReferences(editor);
|
|
108
109
|
setRows(composeRowCount(editor.plainText, editor.width));
|
|
109
110
|
onReady(() => editorRef.current?.plainText ?? "");
|
|
@@ -66,6 +66,7 @@ describe("markdown prototype review", () => {
|
|
|
66
66
|
for (let attempt = 0; attempt < 60 && !keywordHighlighted; attempt++) {
|
|
67
67
|
await setup.renderOnce();
|
|
68
68
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
69
|
+
await setup.renderOnce();
|
|
69
70
|
const accentText = setup
|
|
70
71
|
.captureSpans()
|
|
71
72
|
.lines.flatMap((line) =>
|
|
@@ -263,6 +263,7 @@ for (const role of ["collaborator", "observer"] as const) {
|
|
|
263
263
|
|
|
264
264
|
expect(row).toBeGreaterThanOrEqual(0);
|
|
265
265
|
await setup.mockMouse.moveTo(12, row);
|
|
266
|
+
await setup.renderOnce();
|
|
266
267
|
expect(setup.captureCharFrame()).not.toContain(NERD.kebab);
|
|
267
268
|
expect(setup.captureCharFrame()).not.toContain(" edit ");
|
|
268
269
|
} finally {
|
package/src/app/App.tsx
CHANGED
|
@@ -944,12 +944,21 @@ export function App({
|
|
|
944
944
|
return;
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
947
|
+
void (async () => {
|
|
948
|
+
let suspended = false;
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
await renderer?.suspend();
|
|
952
|
+
suspended = true;
|
|
953
|
+
} catch {
|
|
954
|
+
suspended = false;
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
controller.edit();
|
|
958
|
+
} finally {
|
|
959
|
+
if (suspended) await renderer?.resume();
|
|
960
|
+
}
|
|
961
|
+
})();
|
|
953
962
|
};
|
|
954
963
|
|
|
955
964
|
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
@@ -415,6 +415,7 @@ describe("the four-pane workbench", () => {
|
|
|
415
415
|
return header.includes("util.ts") && header.includes("README.md");
|
|
416
416
|
});
|
|
417
417
|
await setup.mockMouse.click(util.column, util.row);
|
|
418
|
+
await setup.renderOnce();
|
|
418
419
|
const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
419
420
|
|
|
420
421
|
expect(header.match(/util\.ts/g)).toHaveLength(1);
|
|
@@ -694,6 +695,7 @@ describe("the bare-launch welcome shell", () => {
|
|
|
694
695
|
const skill = locateText(setup, "custom-review");
|
|
695
696
|
|
|
696
697
|
await setup.mockMouse.click(skill.column, skill.row);
|
|
698
|
+
await setup.renderOnce();
|
|
697
699
|
expect(setup.captureCharFrame()).toContain("custom-review");
|
|
698
700
|
setup.renderer.destroy();
|
|
699
701
|
});
|
|
@@ -788,12 +790,12 @@ describe("the bare-launch welcome shell", () => {
|
|
|
788
790
|
|
|
789
791
|
expect(frame).toContain("README.md");
|
|
790
792
|
expect(frame).toContain(NERD.copy);
|
|
791
|
-
const title = locateText(setup, "README.md");
|
|
793
|
+
const title = locateText(setup, "│ README.md");
|
|
792
794
|
|
|
793
|
-
await setup.mockMouse.click(title.column, title.row);
|
|
795
|
+
await setup.mockMouse.click(title.column + 2, title.row);
|
|
794
796
|
await waitForTextGone(setup, "edited in the working tree");
|
|
795
797
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
796
|
-
await setup.mockMouse.click(title.column, title.row);
|
|
798
|
+
await setup.mockMouse.click(title.column + 2, title.row);
|
|
797
799
|
await waitForText(setup, "edited in the working tree");
|
|
798
800
|
setup.renderer.destroy();
|
|
799
801
|
});
|
|
@@ -48,7 +48,6 @@ describe("AnnotatableFileView", () => {
|
|
|
48
48
|
{ path: "src/add.js", source: "const add = 1;", keyword: "const" },
|
|
49
49
|
]) {
|
|
50
50
|
test(`highlights ${path} by its detected language`, async () => {
|
|
51
|
-
allowEventLoopUpdates();
|
|
52
51
|
const setup = await testRender(
|
|
53
52
|
<AnnotatableFileView
|
|
54
53
|
path={path}
|
|
@@ -64,11 +63,14 @@ describe("AnnotatableFileView", () => {
|
|
|
64
63
|
/>,
|
|
65
64
|
{ width: 64, height: 12 },
|
|
66
65
|
);
|
|
66
|
+
|
|
67
|
+
allowEventLoopUpdates();
|
|
67
68
|
let highlighted = false;
|
|
68
69
|
|
|
69
70
|
for (let attempt = 0; attempt < 60 && !highlighted; attempt++) {
|
|
70
71
|
await setup.renderOnce();
|
|
71
72
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
73
|
+
await setup.renderOnce();
|
|
72
74
|
highlighted = setup.captureSpans().lines.some((line) =>
|
|
73
75
|
line.spans
|
|
74
76
|
.filter((span) => colorHex(span.fg) === DARK.accent)
|
|
@@ -147,6 +149,7 @@ describe("AnnotatableFileView", () => {
|
|
|
147
149
|
{ width: 64, height: 12 },
|
|
148
150
|
);
|
|
149
151
|
|
|
152
|
+
allowEventLoopUpdates();
|
|
150
153
|
await waitForText(setup, "return a + b");
|
|
151
154
|
|
|
152
155
|
// Assert - the current file shows, but the head-side note is not attached to it
|
|
@@ -47,11 +47,13 @@ test("renders tree-sitter colors, with the changed word in the diff color on top
|
|
|
47
47
|
|
|
48
48
|
// Act
|
|
49
49
|
// syntax highlighting resolves off the render path; pump until a keyword paints
|
|
50
|
+
allowEventLoopUpdates();
|
|
50
51
|
let keywordAccent = false;
|
|
51
52
|
|
|
52
53
|
for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
|
|
53
54
|
await setup.renderOnce();
|
|
54
55
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
56
|
+
await setup.renderOnce();
|
|
55
57
|
// the caret cell splits the keyword's first character into its own span, so read the
|
|
56
58
|
// accent-colored text of a whole line rather than one span
|
|
57
59
|
for (const line of setup.captureSpans().lines) {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
15
15
|
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
16
|
+
import { useRenderer } from "@opentui/react";
|
|
16
17
|
import { type Theme } from "../../appearance/theme";
|
|
17
18
|
import { markdownHighlightRanges } from "../markdown-highlight";
|
|
18
19
|
import { markdownEditorStyle } from "../markdown-editor-syntax-style";
|
|
@@ -63,6 +64,7 @@ function newlinePrefixCounts(text: string): number[] {
|
|
|
63
64
|
|
|
64
65
|
export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThreadEditorProps>(
|
|
65
66
|
function MarkdownThreadEditor({ initialText, theme, onExitEditor }, handleRef): React.ReactNode {
|
|
67
|
+
const renderer = useRenderer();
|
|
66
68
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
67
69
|
const [position, setPosition] = useState<MarkdownEditorPosition>({
|
|
68
70
|
line: 1,
|
|
@@ -76,7 +78,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
|
|
|
76
78
|
useImperativeHandle(handleRef, () => ({ requestExit: exitWithCurrentText }));
|
|
77
79
|
|
|
78
80
|
const paintMarkdown = (editor: TextareaRenderable): void => {
|
|
79
|
-
const { styleIdFor } = markdownEditorStyle(theme);
|
|
81
|
+
const { styleIdFor } = markdownEditorStyle(theme, renderer.nativeScene);
|
|
80
82
|
const text = editor.plainText;
|
|
81
83
|
// addHighlightByCharRange counts offsets with newlines excluded, so map source offsets down by the
|
|
82
84
|
// newlines before them - otherwise a highlight drifts one cell per preceding line
|
|
@@ -98,7 +100,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
|
|
|
98
100
|
|
|
99
101
|
if (!editor) return;
|
|
100
102
|
|
|
101
|
-
editor.editBuffer.setSyntaxStyle(markdownEditorStyle(theme).style);
|
|
103
|
+
editor.editBuffer.setSyntaxStyle(markdownEditorStyle(theme, renderer.nativeScene).style);
|
|
102
104
|
paintMarkdown(editor);
|
|
103
105
|
setPosition((prior) => ({ ...prior, lineCount: editor.editBuffer.getLineCount() }));
|
|
104
106
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -296,7 +296,7 @@ export function ThreadView({
|
|
|
296
296
|
const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
|
|
297
297
|
const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
|
|
298
298
|
|
|
299
|
-
return lines +
|
|
299
|
+
return lines + 1;
|
|
300
300
|
};
|
|
301
301
|
const virtual = useTerminalVirtualizer({
|
|
302
302
|
scrollbox: scrollRef,
|
|
@@ -400,7 +400,12 @@ export function ThreadView({
|
|
|
400
400
|
|
|
401
401
|
if (custom !== undefined)
|
|
402
402
|
return (
|
|
403
|
-
<box
|
|
403
|
+
<box
|
|
404
|
+
key={`custom-${blockIndex}`}
|
|
405
|
+
ref={virtual.measureRef(blockIndex)}
|
|
406
|
+
style={{ flexDirection: "column" }}
|
|
407
|
+
>
|
|
408
|
+
{blockIndex === 0 ? <box key="lead-gap" style={{ height: 1 }} /> : null}
|
|
404
409
|
{custom}
|
|
405
410
|
</box>
|
|
406
411
|
);
|
|
@@ -415,8 +420,7 @@ export function ThreadView({
|
|
|
415
420
|
(block.kind === "li" || block.kind === "oli");
|
|
416
421
|
// the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
|
|
417
422
|
// virtualizer's spacer math counts it
|
|
418
|
-
const leadingGap =
|
|
419
|
-
blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
|
|
423
|
+
const leadingGap = tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
|
|
420
424
|
const measuredBox = (children: React.ReactNode): React.ReactNode => (
|
|
421
425
|
<box
|
|
422
426
|
key={isPromptBlock?.(blockIndex) ? "prompt-block" : `discussion-block-${blockIndex}`}
|
|
@@ -550,7 +554,7 @@ export function ThreadView({
|
|
|
550
554
|
{/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
|
|
551
555
|
<scrollbox
|
|
552
556
|
ref={scrollRef}
|
|
553
|
-
style={{ flexGrow: 1
|
|
557
|
+
style={{ flexGrow: 1 }}
|
|
554
558
|
focused={false}
|
|
555
559
|
verticalScrollbarOptions={{ visible: false }}
|
|
556
560
|
>
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* hidden, since the editor shows raw source.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
SyntaxStyle,
|
|
11
|
+
type NativeResourceOwner,
|
|
12
|
+
type ResourceContext,
|
|
13
|
+
type StyleDefinitionInput,
|
|
14
|
+
} from "@opentui/core";
|
|
10
15
|
import { type Theme } from "../appearance/theme";
|
|
11
16
|
import { MARKDOWN_HIGHLIGHT_GROUPS, type MarkdownHighlightGroup } from "./markdown-highlight";
|
|
12
17
|
|
|
@@ -25,16 +30,26 @@ function markdownGroupStyles(theme: Theme): Record<MarkdownHighlightGroup, Style
|
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const markdownEditorStyleCache = new WeakMap<
|
|
33
|
+
const markdownEditorStyleCache = new WeakMap<
|
|
34
|
+
ResourceContext,
|
|
35
|
+
WeakMap<Theme, MarkdownEditorStyle>
|
|
36
|
+
>();
|
|
29
37
|
|
|
30
38
|
/** Build (or reuse) the markdown editor's SyntaxStyle for a theme, with a group-to-styleId lookup. */
|
|
31
|
-
export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
|
|
32
|
-
|
|
39
|
+
export function markdownEditorStyle(theme: Theme, owner: NativeResourceOwner): MarkdownEditorStyle {
|
|
40
|
+
let byTheme = markdownEditorStyleCache.get(owner.resourceContext);
|
|
41
|
+
|
|
42
|
+
if (!byTheme) {
|
|
43
|
+
byTheme = new WeakMap<Theme, MarkdownEditorStyle>();
|
|
44
|
+
markdownEditorStyleCache.set(owner.resourceContext, byTheme);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const cached = byTheme.get(theme);
|
|
33
48
|
|
|
34
49
|
if (cached) return cached;
|
|
35
50
|
|
|
36
51
|
const groupStyles = markdownGroupStyles(theme);
|
|
37
|
-
const style = SyntaxStyle.fromStyles(groupStyles);
|
|
52
|
+
const style = SyntaxStyle.fromStyles(groupStyles, owner);
|
|
38
53
|
const styleIds = new Map<MarkdownHighlightGroup, number>();
|
|
39
54
|
|
|
40
55
|
for (const group of MARKDOWN_HIGHLIGHT_GROUPS) {
|
|
@@ -45,7 +60,7 @@ export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
|
|
|
45
60
|
styleIdFor: (group) => styleIds.get(group) ?? 0,
|
|
46
61
|
};
|
|
47
62
|
|
|
48
|
-
|
|
63
|
+
byTheme.set(theme, entry);
|
|
49
64
|
|
|
50
65
|
return entry;
|
|
51
66
|
}
|
|
@@ -248,7 +248,7 @@ function PrototypeContentViewImpl({
|
|
|
248
248
|
|
|
249
249
|
if (launchedRef.current || !region) return;
|
|
250
250
|
|
|
251
|
-
if (caps && caps.kitty_graphics === false) {
|
|
251
|
+
if (caps && Boolean(caps.terminal.name) && caps.kitty_graphics === false) {
|
|
252
252
|
setStatus("unsupported");
|
|
253
253
|
|
|
254
254
|
return;
|
|
@@ -45,6 +45,7 @@ export async function renderReadyApp(
|
|
|
45
45
|
const probe = appReadyProbe();
|
|
46
46
|
const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
|
|
47
47
|
|
|
48
|
+
allowEventLoopUpdates();
|
|
48
49
|
await probe.ready;
|
|
49
50
|
|
|
50
51
|
return setup;
|
|
@@ -117,9 +118,21 @@ export async function settle(setup: TestRendererSetup): Promise<void> {
|
|
|
117
118
|
let quietTurns = 0;
|
|
118
119
|
|
|
119
120
|
for (let turn = 0; turn < SETTLE_MAX_TURNS; turn++) {
|
|
121
|
+
if (setup.renderer.isDestroyed) return;
|
|
122
|
+
|
|
120
123
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
124
|
+
|
|
125
|
+
if (setup.renderer.isDestroyed) return;
|
|
126
|
+
|
|
121
127
|
await setup.renderOnce();
|
|
122
128
|
await setup.waitForVisualIdle();
|
|
129
|
+
|
|
130
|
+
if (setup.renderer.isDestroyed) return;
|
|
131
|
+
|
|
132
|
+
if (setup.renderer.getSchedulerState().isRendering) {
|
|
133
|
+
await setup.renderOnce();
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
const frame = paintedFrame(setup);
|
|
124
137
|
|
|
125
138
|
quietTurns = frame === previous ? quietTurns + 1 : 0;
|
|
@@ -281,7 +294,20 @@ async function waitForFramePredicate(
|
|
|
281
294
|
let lastLog = start;
|
|
282
295
|
|
|
283
296
|
for (;;) {
|
|
297
|
+
if (setup.renderer.isDestroyed) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
284
303
|
await setup.renderOnce();
|
|
304
|
+
|
|
305
|
+
if (setup.renderer.isDestroyed) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
285
311
|
const frame = setup.captureCharFrame();
|
|
286
312
|
|
|
287
313
|
polls++;
|
|
@@ -329,9 +355,24 @@ export async function waitForState(
|
|
|
329
355
|
let lastLog = start;
|
|
330
356
|
|
|
331
357
|
for (;;) {
|
|
332
|
-
if (
|
|
358
|
+
if (setup.renderer.isDestroyed) {
|
|
359
|
+
if (predicate()) return;
|
|
360
|
+
|
|
361
|
+
throw new Error(
|
|
362
|
+
`waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
333
365
|
|
|
334
366
|
await setup.renderOnce();
|
|
367
|
+
|
|
368
|
+
if (setup.renderer.isDestroyed) {
|
|
369
|
+
if (predicate()) return;
|
|
370
|
+
|
|
371
|
+
throw new Error(
|
|
372
|
+
`waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
335
376
|
const frame = setup.captureCharFrame();
|
|
336
377
|
|
|
337
378
|
polls++;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
9
|
import { createTextAttributes } from "@opentui/core";
|
|
10
|
+
import { useRenderer } from "@opentui/react";
|
|
10
11
|
import type { Theme } from "../../appearance/theme";
|
|
11
12
|
import { useComponentTheme } from "../../appearance/components/theme-context";
|
|
12
13
|
import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
|
|
@@ -38,6 +39,7 @@ export function CodeBlock({
|
|
|
38
39
|
theme,
|
|
39
40
|
}: CodeBlockProps): React.ReactNode {
|
|
40
41
|
const tokens = useComponentTheme(theme);
|
|
42
|
+
const renderer = useRenderer();
|
|
41
43
|
const tagColor = changeTag === "new" ? tokens.green : tokens.accent;
|
|
42
44
|
|
|
43
45
|
return (
|
|
@@ -69,7 +71,7 @@ export function CodeBlock({
|
|
|
69
71
|
<code
|
|
70
72
|
content={content}
|
|
71
73
|
filetype={filetypeFor(language)}
|
|
72
|
-
syntaxStyle={syntaxStyleFor(tokens)}
|
|
74
|
+
syntaxStyle={syntaxStyleFor(tokens, renderer.nativeScene)}
|
|
73
75
|
selectable={false}
|
|
74
76
|
style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
|
|
75
77
|
/>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// surfaced at the screen root instead: controls report the hovered label and cursor cell, and this layer
|
|
3
3
|
// paints it one row below, nudged left when it would run past the right edge.
|
|
4
4
|
|
|
5
|
-
import React, { createContext, useContext, useMemo, useState } from "react";
|
|
5
|
+
import React, { createContext, useContext, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { useTerminalDimensions } from "@opentui/react";
|
|
7
7
|
import { DARK, type Theme } from "../../appearance/theme";
|
|
8
8
|
|
|
@@ -15,11 +15,13 @@ interface TooltipTarget {
|
|
|
15
15
|
interface TooltipApi {
|
|
16
16
|
showTooltip: (label: string, x: number, y: number) => void;
|
|
17
17
|
hideTooltip: () => void;
|
|
18
|
+
dismissTooltip: (x: number, y: number) => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const TooltipContext = createContext<TooltipApi>({
|
|
21
22
|
showTooltip: () => {},
|
|
22
23
|
hideTooltip: () => {},
|
|
24
|
+
dismissTooltip: () => {},
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
/** Report or clear the hovered control's tooltip; a no-op outside a TooltipProvider. */
|
|
@@ -38,10 +40,29 @@ export function TooltipProvider({
|
|
|
38
40
|
const tokens = theme ?? DARK;
|
|
39
41
|
const { width, height } = useTerminalDimensions();
|
|
40
42
|
const [target, setTarget] = useState<TooltipTarget | null>(null);
|
|
43
|
+
const suppressedCell = useRef<{ x: number; y: number } | null>(null);
|
|
41
44
|
const api = useMemo<TooltipApi>(
|
|
42
45
|
() => ({
|
|
43
|
-
showTooltip: (label, x, y) =>
|
|
44
|
-
|
|
46
|
+
showTooltip: (label, x, y) => {
|
|
47
|
+
if (
|
|
48
|
+
suppressedCell.current &&
|
|
49
|
+
suppressedCell.current.x === x &&
|
|
50
|
+
suppressedCell.current.y === y
|
|
51
|
+
) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
suppressedCell.current = null;
|
|
56
|
+
setTarget({ label, x, y });
|
|
57
|
+
},
|
|
58
|
+
hideTooltip: () => {
|
|
59
|
+
suppressedCell.current = null;
|
|
60
|
+
setTarget(null);
|
|
61
|
+
},
|
|
62
|
+
dismissTooltip: (x, y) => {
|
|
63
|
+
suppressedCell.current = { x, y };
|
|
64
|
+
setTarget(null);
|
|
65
|
+
},
|
|
45
66
|
}),
|
|
46
67
|
[],
|
|
47
68
|
);
|
|
@@ -32,11 +32,11 @@ export function IconButton({
|
|
|
32
32
|
}: IconButtonProps): React.ReactNode {
|
|
33
33
|
const tokens = theme ?? DARK;
|
|
34
34
|
const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
|
|
35
|
-
const { showTooltip, hideTooltip } = useTooltip();
|
|
35
|
+
const { showTooltip, hideTooltip, dismissTooltip } = useTooltip();
|
|
36
36
|
// a button press is its own action - never let it bubble to a clickable row behind it
|
|
37
37
|
const handleMouseUp = (event: TerminalMouseEvent): void => {
|
|
38
38
|
event.stopPropagation();
|
|
39
|
-
|
|
39
|
+
dismissTooltip(event.x, event.y);
|
|
40
40
|
onPress?.();
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
infoStringToFiletype,
|
|
12
12
|
extensionToFiletype,
|
|
13
13
|
basenameToFiletype,
|
|
14
|
+
type NativeResourceOwner,
|
|
15
|
+
type ResourceContext,
|
|
14
16
|
} from "@opentui/core";
|
|
15
17
|
import type { Theme } from "../../appearance/theme";
|
|
16
18
|
|
|
@@ -86,16 +88,23 @@ function syntaxGroupStyles(theme: Theme): SyntaxGroupStyles {
|
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle
|
|
91
|
+
const syntaxStyleCache = new WeakMap<ResourceContext, WeakMap<Theme, SyntaxStyle>>();
|
|
90
92
|
|
|
91
|
-
export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|
92
|
-
|
|
93
|
+
export function syntaxStyleFor(theme: Theme, owner: NativeResourceOwner): SyntaxStyle {
|
|
94
|
+
let byTheme = syntaxStyleCache.get(owner.resourceContext);
|
|
95
|
+
|
|
96
|
+
if (!byTheme) {
|
|
97
|
+
byTheme = new WeakMap<Theme, SyntaxStyle>();
|
|
98
|
+
syntaxStyleCache.set(owner.resourceContext, byTheme);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cached = byTheme.get(theme);
|
|
93
102
|
|
|
94
103
|
if (cached) return cached;
|
|
95
104
|
|
|
96
|
-
const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme));
|
|
105
|
+
const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme), owner);
|
|
97
106
|
|
|
98
|
-
|
|
107
|
+
byTheme.set(theme, style);
|
|
99
108
|
|
|
100
109
|
return style;
|
|
101
110
|
}
|
|
@@ -106,20 +115,27 @@ export interface ReferenceStyle {
|
|
|
106
115
|
styleId: number;
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
const referenceStyleCache = new WeakMap<Theme, ReferenceStyle
|
|
118
|
+
const referenceStyleCache = new WeakMap<ResourceContext, WeakMap<Theme, ReferenceStyle>>();
|
|
119
|
+
|
|
120
|
+
export function referenceStyleFor(theme: Theme, owner: NativeResourceOwner): ReferenceStyle {
|
|
121
|
+
let byTheme = referenceStyleCache.get(owner.resourceContext);
|
|
122
|
+
|
|
123
|
+
if (!byTheme) {
|
|
124
|
+
byTheme = new WeakMap<Theme, ReferenceStyle>();
|
|
125
|
+
referenceStyleCache.set(owner.resourceContext, byTheme);
|
|
126
|
+
}
|
|
110
127
|
|
|
111
|
-
|
|
112
|
-
const cached = referenceStyleCache.get(theme);
|
|
128
|
+
const cached = byTheme.get(theme);
|
|
113
129
|
|
|
114
130
|
if (cached) return cached;
|
|
115
131
|
|
|
116
132
|
const entry: ReferenceStyle = {
|
|
117
|
-
style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }),
|
|
133
|
+
style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }, owner),
|
|
118
134
|
styleId: 0,
|
|
119
135
|
};
|
|
120
136
|
|
|
121
137
|
entry.styleId = entry.style.getStyleId("reference") ?? 0;
|
|
122
|
-
|
|
138
|
+
byTheme.set(theme, entry);
|
|
123
139
|
|
|
124
140
|
return entry;
|
|
125
141
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
|
-
import { useRenderer } from "@opentui/react";
|
|
9
|
+
import { flushSync, useRenderer } from "@opentui/react";
|
|
10
10
|
|
|
11
11
|
type Renderer = NonNullable<ReturnType<typeof useRenderer>>;
|
|
12
12
|
type Measure = () => void;
|
|
@@ -18,13 +18,24 @@ interface FrameSubscriptions {
|
|
|
18
18
|
|
|
19
19
|
const subscribers = new WeakMap<Renderer, FrameSubscriptions>();
|
|
20
20
|
|
|
21
|
+
declare global {
|
|
22
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function flushFrameMeasures(measures: ReadonlySet<Measure>): void {
|
|
26
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
|
27
|
+
flushSync(() => {
|
|
28
|
+
for (const subscriber of measures) subscriber();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export function subscribeToFrames(renderer: Renderer, measure: Measure): () => void {
|
|
22
33
|
let subscription = subscribers.get(renderer);
|
|
23
34
|
|
|
24
35
|
if (!subscription) {
|
|
25
36
|
const measures = new Set<Measure>();
|
|
26
37
|
const notify = (): void => {
|
|
27
|
-
|
|
38
|
+
flushFrameMeasures(measures);
|
|
28
39
|
};
|
|
29
40
|
|
|
30
41
|
subscription = { measures, notify };
|
|
@@ -150,6 +150,10 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
|
|
|
150
150
|
|
|
151
151
|
// the offset reporter the core registered, so a scroll the core asks for reports at once
|
|
152
152
|
const reportOffset = useRef<(() => void) | null>(null);
|
|
153
|
+
const pendingScroll = useRef<{
|
|
154
|
+
index: number;
|
|
155
|
+
align: "auto" | "start" | "center" | "end";
|
|
156
|
+
} | null>(null);
|
|
153
157
|
|
|
154
158
|
// a frame-event poll stands in for scroll and resize events: the renderer fires it after every
|
|
155
159
|
// render, and the callbacks only fire the core when the value actually changed
|
|
@@ -241,6 +245,13 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
|
|
|
241
245
|
for (const element of virtualizer.elementsCache.values()) {
|
|
242
246
|
virtualizer.measureElement(element);
|
|
243
247
|
}
|
|
248
|
+
|
|
249
|
+
if (pendingScroll.current) {
|
|
250
|
+
const target = pendingScroll.current;
|
|
251
|
+
|
|
252
|
+
pendingScroll.current = null;
|
|
253
|
+
virtualizer.scrollToIndex(target.index, { align: target.align });
|
|
254
|
+
}
|
|
244
255
|
}),
|
|
245
256
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
257
|
[virtualizer, renderer],
|
|
@@ -262,8 +273,14 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
|
|
|
262
273
|
if (virtualizer.elementsCache.get(index) === view) virtualizer.elementsCache.delete(index);
|
|
263
274
|
};
|
|
264
275
|
},
|
|
265
|
-
scrollToIndex: (index, align = "auto") =>
|
|
276
|
+
scrollToIndex: (index, align = "auto") => {
|
|
277
|
+
pendingScroll.current = { index, align };
|
|
278
|
+
virtualizer.scrollToIndex(index, { align });
|
|
279
|
+
},
|
|
266
280
|
startOfIndex: (index) => virtualizer.getOffsetForIndex(index, "start")?.[0],
|
|
267
|
-
scrollToOffset: (offset) =>
|
|
281
|
+
scrollToOffset: (offset) => {
|
|
282
|
+
pendingScroll.current = null;
|
|
283
|
+
virtualizer.scrollToOffset(offset, { align: "start" });
|
|
284
|
+
},
|
|
268
285
|
};
|
|
269
286
|
}
|
|
@@ -111,6 +111,7 @@ test("Split is disabled at eight tiles and when a tile is too small", async () =
|
|
|
111
111
|
|
|
112
112
|
expect(button).toBeDefined();
|
|
113
113
|
await setup.mockMouse.click(button!.x, button!.y);
|
|
114
|
+
await setup.renderOnce();
|
|
114
115
|
expect(setup.captureCharFrame()).not.toContain("right →");
|
|
115
116
|
expect(splitCalls).toBe(0);
|
|
116
117
|
setup.renderer.destroy();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { ScrollArea } from "../../ui/components/ScrollArea";
|
|
8
8
|
import React, { useEffect, useRef, useState } from "react";
|
|
9
9
|
import type { CodeRenderable } from "@opentui/core";
|
|
10
|
+
import { useRenderer } from "@opentui/react";
|
|
10
11
|
import type { Theme } from "../../appearance/theme";
|
|
11
12
|
import { useComponentTheme } from "../../appearance/components/theme-context";
|
|
12
13
|
import { filetypeForPath, syntaxStyleFor } from "../../ui/components/syntax-highlight";
|
|
@@ -29,6 +30,7 @@ export function FileContentsView({
|
|
|
29
30
|
theme,
|
|
30
31
|
}: FileContentsViewProps): React.ReactNode {
|
|
31
32
|
const tokens = useComponentTheme(theme);
|
|
33
|
+
const renderer = useRenderer();
|
|
32
34
|
const [loaded, setLoaded] = useState<FileLoad | null>(null);
|
|
33
35
|
// The gutter mirrors the code renderable's line info, so it needs the mounted
|
|
34
36
|
// instance; a state-backed ref rebinds the target once the code renderable exists.
|
|
@@ -92,7 +94,7 @@ export function FileContentsView({
|
|
|
92
94
|
ref={setCodeTarget}
|
|
93
95
|
content={content}
|
|
94
96
|
filetype={filetypeForPath(path)}
|
|
95
|
-
syntaxStyle={syntaxStyleFor(tokens)}
|
|
97
|
+
syntaxStyle={syntaxStyleFor(tokens, renderer.nativeScene)}
|
|
96
98
|
selectable={false}
|
|
97
99
|
style={{ wrapMode: "none", fg: tokens.text }}
|
|
98
100
|
/>
|