@cueloop/client 0.1.0-alpha.91 → 0.1.0-alpha.93
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.deleted-scroll.test.tsx +186 -0
- package/src/App.diff.test.tsx +3 -4
- package/src/App.test.tsx +1 -0
- package/src/App.toast.test.tsx +27 -1
- package/src/App.tsx +21 -12
- package/src/App.workbench.test.tsx +208 -2
- package/src/app-screens.tsx +34 -8
- package/src/components/AnnotatableFileView.test.tsx +48 -1
- package/src/components/AppShell.zoom.test.tsx +16 -3
- package/src/components/ChangesColumn.tsx +10 -4
- package/src/components/ChangesFileTree.test.tsx +122 -4
- package/src/components/DiffContentView.scroll.test.tsx +285 -1
- package/src/components/DiffContentView.tsx +214 -56
- package/src/components/EditorGrid.split.test.tsx +118 -0
- package/src/components/EditorGrid.stories.tsx +27 -0
- package/src/components/EditorGrid.tsx +170 -50
- package/src/components/EditorGrid.zoom.test.tsx +65 -1
- package/src/components/GridTabContent.actions.test.tsx +151 -0
- package/src/components/GridTabContent.stories.tsx +2 -2
- package/src/components/GridTabContent.tsx +5 -1
- package/src/components/ProjectTreeView.test.tsx +166 -8
- package/src/components/ProjectTreeView.tsx +53 -27
- package/src/components/ScrollArea.tsx +21 -2
- package/src/components/Toast.tsx +3 -1
- package/src/components/WelcomePlayground.test.tsx +28 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
- package/src/components/editor-grid.test.ts +87 -0
- package/src/components/editor-grid.ts +142 -6
- package/src/components/primitives/Tree.tsx +51 -15
- package/src/diff-syntax.test.ts +15 -2
- package/src/diff-syntax.ts +13 -5
- package/src/thread-controller.curate.test.ts +27 -0
- package/src/thread-controller.ts +4 -6
- package/src/use-annotation-surface.tsx +86 -19
- package/src/use-changes-workbench.ts +30 -18
- package/src/use-frame-measure.ts +40 -7
- package/src/use-settings-dialog.tsx +1 -13
- package/src/use-shared-keyboard.ts +55 -0
- package/src/use-terminal-virtualizer.ts +8 -2
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
16
16
|
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
|
-
import {
|
|
17
|
+
import { flushSync } from "@opentui/react";
|
|
18
|
+
import { useSharedKeyboard } from "./use-shared-keyboard";
|
|
18
19
|
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
20
|
import type { Mark } from "./view-plan";
|
|
20
21
|
import type { QuickAction } from "./config";
|
|
@@ -97,6 +98,13 @@ export interface AnnotationSurfaceOptions {
|
|
|
97
98
|
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
98
99
|
/** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
|
|
99
100
|
onCursorChange?: (blockIndex: number) => void;
|
|
101
|
+
/** Scroll a visual row before crossing a tall gap; true defers the caret move. */
|
|
102
|
+
onVerticalStep?: (
|
|
103
|
+
fromBlock: number,
|
|
104
|
+
toBlock: number,
|
|
105
|
+
direction: -1 | 1,
|
|
106
|
+
targetY?: number,
|
|
107
|
+
) => boolean;
|
|
100
108
|
/** The rail's focused card; the discussion holding it takes focus here. */
|
|
101
109
|
focusedAnnotationId?: string;
|
|
102
110
|
/** Reports the focused discussion's root comment, so the rail follows. */
|
|
@@ -129,6 +137,10 @@ export interface AnnotationSurface {
|
|
|
129
137
|
focusedDiscussion: string | null;
|
|
130
138
|
/** The block to keep in view: an opening card, a focused discussion, else the caret. */
|
|
131
139
|
revealBlockIndex: number;
|
|
140
|
+
/** Screen row of the mounted visual line carrying the caret. */
|
|
141
|
+
headVisualY: () => number | undefined;
|
|
142
|
+
/** Capture mounted lines before a viewport scroll, then keep an exiting caret at its visible edge. */
|
|
143
|
+
prepareViewportScroll: () => (delta: number, top: number, bottom: number) => void;
|
|
132
144
|
/** The text a span covers, blocks joined by a space, for previews. */
|
|
133
145
|
spanQuote: (span: TextSpan) => string;
|
|
134
146
|
/** Ref callback for one visual line, so a drag can hit-test it. */
|
|
@@ -192,6 +204,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
192
204
|
onComposingChange,
|
|
193
205
|
onObserverBlocked,
|
|
194
206
|
onCursorChange,
|
|
207
|
+
onVerticalStep,
|
|
195
208
|
focusedAnnotationId,
|
|
196
209
|
onFocusAnnotation,
|
|
197
210
|
onAnnotate,
|
|
@@ -336,15 +349,55 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
336
349
|
x: entry.renderable.x,
|
|
337
350
|
y: entry.renderable.y,
|
|
338
351
|
}));
|
|
339
|
-
const
|
|
352
|
+
const headVisualY = (): number | undefined => {
|
|
353
|
+
const textLength = textLengthOf(head.blockIndex);
|
|
354
|
+
const cell = Math.max(0, Math.min(textLength - 1, head.char));
|
|
355
|
+
|
|
356
|
+
return allGeometry().find(
|
|
357
|
+
(line) =>
|
|
358
|
+
line.blockIndex === head.blockIndex &&
|
|
359
|
+
line.start <= cell &&
|
|
360
|
+
(cell < line.end || line.start === line.end),
|
|
361
|
+
)?.y;
|
|
362
|
+
};
|
|
363
|
+
const prepareViewportScroll = (): ((delta: number, top: number, bottom: number) => void) => {
|
|
364
|
+
const lines = allGeometry().filter((line) => source.annotatable(line.blockIndex));
|
|
365
|
+
const oldHeadY = headVisualY();
|
|
366
|
+
|
|
367
|
+
return (delta, top, bottom) => {
|
|
368
|
+
if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
|
|
369
|
+
return;
|
|
370
|
+
const shiftedHeadY = oldHeadY - delta;
|
|
371
|
+
|
|
372
|
+
if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
|
|
373
|
+
const visible = lines
|
|
374
|
+
.filter((line) => line.y - delta >= top && line.y - delta <= bottom)
|
|
375
|
+
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
376
|
+
const target = delta < 0 ? visible.at(-1) : visible[0];
|
|
377
|
+
|
|
378
|
+
if (!target) return;
|
|
379
|
+
const next = { blockIndex: target.blockIndex, char: target.start };
|
|
380
|
+
|
|
381
|
+
flushSync(() => {
|
|
382
|
+
setCaret({ head: next, anchor: next });
|
|
383
|
+
setCursor(next.blockIndex);
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
const verticalTextPosition = (
|
|
388
|
+
position: TextPosition,
|
|
389
|
+
direction: -1 | 1,
|
|
390
|
+
): { position: TextPosition; y: number } | null => {
|
|
391
|
+
const textLength = textLengthOf(position.blockIndex);
|
|
392
|
+
const cell = Math.max(0, Math.min(textLength - 1, position.char));
|
|
340
393
|
const lines = allGeometry()
|
|
341
394
|
.filter((line) => source.annotatable(line.blockIndex))
|
|
342
395
|
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
343
396
|
const currentIndex = lines.findIndex(
|
|
344
397
|
(line) =>
|
|
345
398
|
line.blockIndex === position.blockIndex &&
|
|
346
|
-
line.start <=
|
|
347
|
-
|
|
399
|
+
line.start <= cell &&
|
|
400
|
+
(cell < line.end || line.start === line.end),
|
|
348
401
|
);
|
|
349
402
|
const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
|
|
350
403
|
|
|
@@ -353,8 +406,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
353
406
|
const column = Math.max(0, position.char - current.start);
|
|
354
407
|
|
|
355
408
|
return {
|
|
356
|
-
|
|
357
|
-
|
|
409
|
+
position: {
|
|
410
|
+
blockIndex: target.blockIndex,
|
|
411
|
+
char: Math.min(target.end, target.start + column),
|
|
412
|
+
},
|
|
413
|
+
y: target.y,
|
|
358
414
|
};
|
|
359
415
|
};
|
|
360
416
|
|
|
@@ -671,22 +727,31 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
671
727
|
);
|
|
672
728
|
};
|
|
673
729
|
|
|
730
|
+
const moveCaretVertical = (key: KeyEvent, vertical: -1 | 1): void => {
|
|
731
|
+
const nextBlock = nearestAnnotatable(source, cursor, vertical);
|
|
732
|
+
const visualTarget = verticalTextPosition(head, vertical);
|
|
733
|
+
|
|
734
|
+
if (!visualTarget && nextBlock === head.blockIndex) return;
|
|
735
|
+
const nextHead = visualTarget?.position ?? {
|
|
736
|
+
blockIndex: nextBlock,
|
|
737
|
+
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
if (onVerticalStep?.(head.blockIndex, nextHead.blockIndex, vertical, visualTarget?.y)) return;
|
|
741
|
+
|
|
742
|
+
setCaret({
|
|
743
|
+
head: nextHead,
|
|
744
|
+
anchor: key.shift ? caret.anchor : nextHead,
|
|
745
|
+
});
|
|
746
|
+
setFocusedDiscussion(null);
|
|
747
|
+
setCursor(nextHead.blockIndex);
|
|
748
|
+
};
|
|
749
|
+
|
|
674
750
|
const handleCaretKey = (key: KeyEvent): boolean => {
|
|
675
751
|
const vertical = verticalCaretDelta(key);
|
|
676
752
|
|
|
677
753
|
if (vertical !== 0) {
|
|
678
|
-
|
|
679
|
-
const nextHead = verticalTextPosition(head, vertical) ?? {
|
|
680
|
-
blockIndex: nextBlock,
|
|
681
|
-
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
setCaret({
|
|
685
|
-
head: nextHead,
|
|
686
|
-
anchor: key.shift ? caret.anchor : nextHead,
|
|
687
|
-
});
|
|
688
|
-
setFocusedDiscussion(null);
|
|
689
|
-
setCursor(nextHead.blockIndex);
|
|
754
|
+
moveCaretVertical(key, vertical);
|
|
690
755
|
|
|
691
756
|
return true;
|
|
692
757
|
}
|
|
@@ -726,7 +791,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
726
791
|
openNewCompose(sequence);
|
|
727
792
|
};
|
|
728
793
|
|
|
729
|
-
|
|
794
|
+
useSharedKeyboard((key) => {
|
|
730
795
|
if (suspended) return;
|
|
731
796
|
if (key.ctrl && key.name === "q") return onExit();
|
|
732
797
|
const activeCompose = composeRef.current;
|
|
@@ -963,6 +1028,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
963
1028
|
navMode,
|
|
964
1029
|
focusedDiscussion,
|
|
965
1030
|
revealBlockIndex,
|
|
1031
|
+
headVisualY,
|
|
1032
|
+
prepareViewportScroll,
|
|
966
1033
|
spanQuote,
|
|
967
1034
|
registerLine,
|
|
968
1035
|
onLineMouseDown,
|
|
@@ -7,13 +7,14 @@ import type { ProjectPanelMode } from "./components/AppShell";
|
|
|
7
7
|
import type { LaunchLayout } from "./launch-layout";
|
|
8
8
|
import {
|
|
9
9
|
activateTab,
|
|
10
|
-
addTab,
|
|
11
10
|
changesTab,
|
|
12
11
|
closeTab,
|
|
13
12
|
containsGroup,
|
|
14
|
-
fileTab,
|
|
15
13
|
firstGroupId,
|
|
14
|
+
keepFileTab,
|
|
16
15
|
makeGroup,
|
|
16
|
+
openChangesTab,
|
|
17
|
+
openFileTab,
|
|
17
18
|
splitGroup,
|
|
18
19
|
welcomeTab,
|
|
19
20
|
type EditorNode,
|
|
@@ -73,9 +74,10 @@ export interface ChangesWorkbench {
|
|
|
73
74
|
toggleZoom: () => void;
|
|
74
75
|
focusGroup: (groupId: string) => void;
|
|
75
76
|
activate: (groupId: string, tabId: string) => void;
|
|
77
|
+
keepTab: (groupId: string, tabId: string) => void;
|
|
76
78
|
close: (groupId: string, tabId: string) => void;
|
|
77
79
|
split: (groupId: string, direction: SplitDirection) => void;
|
|
78
|
-
openFile: (path: string, fileView: "diff" | "contents") => void;
|
|
80
|
+
openFile: (path: string, fileView: "diff" | "contents", persistent?: boolean) => void;
|
|
79
81
|
/** Re-open defaults when the session changes: a diff opens the region in changed-files mode. */
|
|
80
82
|
syncSession: (sessionId: string | undefined, isDiff: boolean) => void;
|
|
81
83
|
}
|
|
@@ -121,16 +123,16 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
|
|
|
121
123
|
}
|
|
122
124
|
};
|
|
123
125
|
const toggleChanges = (): void => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
126
|
+
setChangesOpen(true);
|
|
127
|
+
setProjectOpen(true);
|
|
128
|
+
setProjectMode("changes");
|
|
129
|
+
setGrid((tree) => {
|
|
130
|
+
const result = openChangesTab(tree, focusedGroup ?? firstGroupId(tree));
|
|
131
|
+
|
|
132
|
+
setFocusedGroup(result.focusGroupId);
|
|
133
|
+
|
|
134
|
+
return result.tree;
|
|
135
|
+
});
|
|
134
136
|
};
|
|
135
137
|
const toggleProject = (): void => {
|
|
136
138
|
setProjectOpen(true);
|
|
@@ -160,13 +162,22 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
|
|
|
160
162
|
return result.tree;
|
|
161
163
|
});
|
|
162
164
|
};
|
|
163
|
-
const openFile = (path: string, fileView: "diff" | "contents"): void => {
|
|
165
|
+
const openFile = (path: string, fileView: "diff" | "contents", persistent = false): void => {
|
|
164
166
|
setProjectOpen(true);
|
|
165
167
|
setChangesOpen(true);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
setGrid((tree) => {
|
|
169
|
+
const result = openFileTab(
|
|
170
|
+
tree,
|
|
171
|
+
focusedGroup ?? firstGroupId(tree),
|
|
172
|
+
path,
|
|
173
|
+
fileView,
|
|
174
|
+
persistent,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
setFocusedGroup(result.focusGroupId);
|
|
178
|
+
|
|
179
|
+
return result.tree;
|
|
180
|
+
});
|
|
170
181
|
};
|
|
171
182
|
|
|
172
183
|
return {
|
|
@@ -182,6 +193,7 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
|
|
|
182
193
|
toggleZoom: () => setZoomed((value) => !value),
|
|
183
194
|
focusGroup: setFocusedGroup,
|
|
184
195
|
activate: (groupId, tabId) => setGrid((tree) => activateTab(tree, groupId, tabId)),
|
|
196
|
+
keepTab: (groupId, tabId) => setGrid((tree) => keepFileTab(tree, groupId, tabId)),
|
|
185
197
|
close,
|
|
186
198
|
split,
|
|
187
199
|
openFile,
|
package/src/use-frame-measure.ts
CHANGED
|
@@ -8,6 +8,45 @@
|
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
9
|
import { useRenderer } from "@opentui/react";
|
|
10
10
|
|
|
11
|
+
type Renderer = NonNullable<ReturnType<typeof useRenderer>>;
|
|
12
|
+
type Measure = () => void;
|
|
13
|
+
|
|
14
|
+
interface FrameSubscriptions {
|
|
15
|
+
measures: Set<Measure>;
|
|
16
|
+
notify: Measure;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const subscribers = new WeakMap<Renderer, FrameSubscriptions>();
|
|
20
|
+
|
|
21
|
+
export function subscribeToFrames(renderer: Renderer, measure: Measure): () => void {
|
|
22
|
+
let subscription = subscribers.get(renderer);
|
|
23
|
+
|
|
24
|
+
if (!subscription) {
|
|
25
|
+
const measures = new Set<Measure>();
|
|
26
|
+
const notify = (): void => {
|
|
27
|
+
for (const subscriber of measures) subscriber();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
subscription = { measures, notify };
|
|
31
|
+
subscribers.set(renderer, subscription);
|
|
32
|
+
renderer.on("frame", notify);
|
|
33
|
+
renderer.on("resize", notify);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { measures, notify } = subscription;
|
|
37
|
+
|
|
38
|
+
measures.add(measure);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
measures.delete(measure);
|
|
42
|
+
if (measures.size > 0) return;
|
|
43
|
+
|
|
44
|
+
renderer.off("frame", notify);
|
|
45
|
+
renderer.off("resize", notify);
|
|
46
|
+
subscribers.delete(renderer);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
export function useFrameMeasure<T>(
|
|
12
51
|
read: () => T,
|
|
13
52
|
isEqual: (left: T, right: T) => boolean,
|
|
@@ -27,13 +66,7 @@ export function useFrameMeasure<T>(
|
|
|
27
66
|
};
|
|
28
67
|
|
|
29
68
|
measure();
|
|
30
|
-
renderer
|
|
31
|
-
renderer?.on("resize", measure);
|
|
32
|
-
|
|
33
|
-
return () => {
|
|
34
|
-
renderer?.off("frame", measure);
|
|
35
|
-
renderer?.off("resize", measure);
|
|
36
|
-
};
|
|
69
|
+
if (renderer) return subscribeToFrames(renderer, measure);
|
|
37
70
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
71
|
}, [renderer, active]);
|
|
39
72
|
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
persistActions,
|
|
5
5
|
persistAutoClose,
|
|
6
6
|
persistDiffView,
|
|
7
|
-
persistReviewSkill,
|
|
8
7
|
persistReviewWorkspace,
|
|
9
8
|
persistTheme,
|
|
10
9
|
type AutoClose,
|
|
@@ -94,8 +93,6 @@ export function useSettingsDialog(params: {
|
|
|
94
93
|
onSyncGithubIdentity: () => void;
|
|
95
94
|
onRenameDisplayName: () => void;
|
|
96
95
|
reviewSkill: string;
|
|
97
|
-
reviewSkillOptions: string[];
|
|
98
|
-
setReviewSkill: Dispatch<SetStateAction<string>>;
|
|
99
96
|
reviewWorkspace: ReviewWorkspaceMode;
|
|
100
97
|
setReviewWorkspace: Dispatch<SetStateAction<ReviewWorkspaceMode>>;
|
|
101
98
|
}): SettingsDialogModel {
|
|
@@ -118,8 +115,6 @@ export function useSettingsDialog(params: {
|
|
|
118
115
|
onSyncGithubIdentity,
|
|
119
116
|
onRenameDisplayName,
|
|
120
117
|
reviewSkill,
|
|
121
|
-
reviewSkillOptions,
|
|
122
|
-
setReviewSkill,
|
|
123
118
|
reviewWorkspace,
|
|
124
119
|
setReviewWorkspace,
|
|
125
120
|
} = params;
|
|
@@ -210,8 +205,7 @@ export function useSettingsDialog(params: {
|
|
|
210
205
|
{
|
|
211
206
|
key: "reviewSkill",
|
|
212
207
|
label: "Skill",
|
|
213
|
-
kind: "
|
|
214
|
-
options: reviewSkillOptions,
|
|
208
|
+
kind: "text",
|
|
215
209
|
},
|
|
216
210
|
{
|
|
217
211
|
key: "reviewWorkspace",
|
|
@@ -281,12 +275,6 @@ export function useSettingsDialog(params: {
|
|
|
281
275
|
onSyncGithubIdentity();
|
|
282
276
|
} else if (rowKey === "displayName") {
|
|
283
277
|
onRenameDisplayName();
|
|
284
|
-
} else if (rowKey === "reviewSkill") {
|
|
285
|
-
const current = Math.max(0, reviewSkillOptions.indexOf(reviewSkill));
|
|
286
|
-
const next = reviewSkillOptions[(current + 1) % reviewSkillOptions.length]!;
|
|
287
|
-
|
|
288
|
-
setReviewSkill(next);
|
|
289
|
-
persistReviewSkill(next);
|
|
290
278
|
} else if (rowKey === "reviewWorkspace") {
|
|
291
279
|
const next: ReviewWorkspaceMode = reviewWorkspace === "worktree" ? "current" : "worktree";
|
|
292
280
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** One keypress subscription per renderer, even when several editor tiles are mounted. */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useEffectEvent } from "react";
|
|
4
|
+
import { useRenderer } from "@opentui/react";
|
|
5
|
+
import type { KeyEvent } from "@opentui/core";
|
|
6
|
+
|
|
7
|
+
type KeyInput = NonNullable<ReturnType<typeof useRenderer>>["keyInput"];
|
|
8
|
+
type KeyHandler = (key: KeyEvent) => void;
|
|
9
|
+
|
|
10
|
+
interface KeySubscriptions {
|
|
11
|
+
handlers: Set<KeyHandler>;
|
|
12
|
+
notify: KeyHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const subscribers = new WeakMap<KeyInput, KeySubscriptions>();
|
|
16
|
+
|
|
17
|
+
function subscribeToKeypress(input: KeyInput, handler: KeyHandler): () => void {
|
|
18
|
+
let subscription = subscribers.get(input);
|
|
19
|
+
|
|
20
|
+
if (!subscription) {
|
|
21
|
+
const handlers = new Set<KeyHandler>();
|
|
22
|
+
const notify = (key: KeyEvent): void => {
|
|
23
|
+
for (const subscriber of handlers) subscriber(key);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
subscription = { handlers, notify };
|
|
27
|
+
subscribers.set(input, subscription);
|
|
28
|
+
input.on("keypress", notify);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { handlers, notify } = subscription;
|
|
32
|
+
|
|
33
|
+
handlers.add(handler);
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
handlers.delete(handler);
|
|
37
|
+
if (handlers.size > 0) return;
|
|
38
|
+
|
|
39
|
+
input.off("keypress", notify);
|
|
40
|
+
subscribers.delete(input);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useSharedKeyboard(handler: KeyHandler): void {
|
|
45
|
+
const renderer = useRenderer();
|
|
46
|
+
const stableHandler = useEffectEvent(handler);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const input = renderer?.keyInput;
|
|
50
|
+
|
|
51
|
+
if (!input) return;
|
|
52
|
+
|
|
53
|
+
return subscribeToKeypress(input, stableHandler);
|
|
54
|
+
}, [renderer]);
|
|
55
|
+
}
|
|
@@ -12,6 +12,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "re
|
|
|
12
12
|
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
13
13
|
import { useRenderer } from "@opentui/react";
|
|
14
14
|
import { Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
|
|
15
|
+
import { subscribeToFrames } from "./use-frame-measure";
|
|
15
16
|
|
|
16
17
|
export interface TerminalVirtualizerOptions {
|
|
17
18
|
scrollbox: RefObject<ScrollBoxRenderable | null>;
|
|
@@ -29,6 +30,10 @@ export interface TerminalVirtualizer {
|
|
|
29
30
|
measureRef: (index: number) => (renderable: BoxRenderable | null) => void | (() => void);
|
|
30
31
|
/** Scroll the item into view; "auto" only scrolls when it is off screen. */
|
|
31
32
|
scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
|
|
33
|
+
/** Measured or estimated visual-row offset of an item. */
|
|
34
|
+
startOfIndex: (index: number) => number | undefined;
|
|
35
|
+
/** Move the native viewport to an exact visual-row offset. */
|
|
36
|
+
scrollToOffset: (offset: number) => void;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/** Visible rows and one-row scrolling for a held mouse mark at a viewport edge. */
|
|
@@ -150,9 +155,8 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
|
|
|
150
155
|
// render, and the callbacks only fire the core when the value actually changed
|
|
151
156
|
const onFrame = (read: () => void): (() => void) => {
|
|
152
157
|
read();
|
|
153
|
-
renderer?.on("frame", read);
|
|
154
158
|
|
|
155
|
-
return
|
|
159
|
+
return renderer ? subscribeToFrames(renderer, read) : () => {};
|
|
156
160
|
};
|
|
157
161
|
|
|
158
162
|
const coreOptions = () => ({
|
|
@@ -255,5 +259,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
|
|
|
255
259
|
};
|
|
256
260
|
},
|
|
257
261
|
scrollToIndex: (index, align = "auto") => virtualizer.scrollToIndex(index, { align }),
|
|
262
|
+
startOfIndex: (index) => virtualizer.getOffsetForIndex(index, "start")?.[0],
|
|
263
|
+
scrollToOffset: (offset) => virtualizer.scrollToOffset(offset, { align: "start" }),
|
|
258
264
|
};
|
|
259
265
|
}
|