@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +290 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The Changes/Project right-region state machine: the Project pane is the right sidebar and is always
|
|
2
|
+
// present when the region is on, the Changes editor rides on it and never stands alone, and the two
|
|
3
|
+
// toggles are mutually-exclusive file-tree modes. Owns the editor grid (tabs and splits) too.
|
|
4
|
+
|
|
5
|
+
import { useRef, useState } from "react";
|
|
6
|
+
import type { ProjectPanelMode } from "./components/AppShell";
|
|
7
|
+
import {
|
|
8
|
+
activateTab,
|
|
9
|
+
addTab,
|
|
10
|
+
changesTab,
|
|
11
|
+
closeTab,
|
|
12
|
+
containsGroup,
|
|
13
|
+
fileTab,
|
|
14
|
+
firstGroupId,
|
|
15
|
+
makeGroup,
|
|
16
|
+
splitGroup,
|
|
17
|
+
welcomeTab,
|
|
18
|
+
type EditorNode,
|
|
19
|
+
type SplitDirection,
|
|
20
|
+
} from "./components/editor-grid";
|
|
21
|
+
|
|
22
|
+
/** Seed the workbench for a bare launch: the region opens on a disposable Welcome tab. */
|
|
23
|
+
export interface ChangesWorkbenchOptions {
|
|
24
|
+
seed?: "changes" | "welcome";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChangesWorkbench {
|
|
28
|
+
projectOpen: boolean;
|
|
29
|
+
changesOpen: boolean;
|
|
30
|
+
projectMode: ProjectPanelMode;
|
|
31
|
+
zoomed: boolean;
|
|
32
|
+
grid: EditorNode;
|
|
33
|
+
activeGroup: string;
|
|
34
|
+
toggleRight: () => void;
|
|
35
|
+
toggleChanges: () => void;
|
|
36
|
+
toggleProject: () => void;
|
|
37
|
+
toggleZoom: () => void;
|
|
38
|
+
focusGroup: (groupId: string) => void;
|
|
39
|
+
activate: (groupId: string, tabId: string) => void;
|
|
40
|
+
close: (groupId: string, tabId: string) => void;
|
|
41
|
+
split: (groupId: string, direction: SplitDirection) => void;
|
|
42
|
+
openFile: (path: string, fileView: "diff" | "contents") => void;
|
|
43
|
+
/** Re-open defaults when the session changes: a diff opens the region in changed-files mode. */
|
|
44
|
+
syncSession: (sessionId: string | undefined, isDiff: boolean) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesWorkbench {
|
|
48
|
+
const welcomeSeed = options?.seed === "welcome";
|
|
49
|
+
const [projectOpen, setProjectOpen] = useState(welcomeSeed);
|
|
50
|
+
const [changesOpen, setChangesOpen] = useState(welcomeSeed);
|
|
51
|
+
const [projectMode, setProjectMode] = useState<ProjectPanelMode>("changes");
|
|
52
|
+
const [zoomed, setZoomed] = useState(false);
|
|
53
|
+
const [grid, setGrid] = useState<EditorNode>(() =>
|
|
54
|
+
makeGroup([welcomeSeed ? welcomeTab() : changesTab()]),
|
|
55
|
+
);
|
|
56
|
+
const [focusedGroup, setFocusedGroup] = useState<string | null>(null);
|
|
57
|
+
const rememberedChanges = useRef(welcomeSeed);
|
|
58
|
+
const seenSession = useRef<string | undefined>(undefined);
|
|
59
|
+
|
|
60
|
+
const syncSession = (sessionId: string | undefined, isDiff: boolean): void => {
|
|
61
|
+
if (sessionId === undefined || sessionId === seenSession.current) return;
|
|
62
|
+
seenSession.current = sessionId;
|
|
63
|
+
setProjectOpen(isDiff);
|
|
64
|
+
setChangesOpen(isDiff);
|
|
65
|
+
setProjectMode(isDiff ? "changes" : "tree");
|
|
66
|
+
rememberedChanges.current = isDiff;
|
|
67
|
+
// a non-diff thread has no Changes editor; leaving zoom on would strand the hidden Thread pane
|
|
68
|
+
if (!isDiff) setZoomed(false);
|
|
69
|
+
setGrid(makeGroup([changesTab()]));
|
|
70
|
+
setFocusedGroup(null);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const toggleRight = (): void => {
|
|
74
|
+
if (projectOpen) {
|
|
75
|
+
rememberedChanges.current = changesOpen;
|
|
76
|
+
setChangesOpen(false);
|
|
77
|
+
setProjectOpen(false);
|
|
78
|
+
// zoom focuses the Changes editor; collapsing the region returns the Thread pane full width
|
|
79
|
+
setZoomed(false);
|
|
80
|
+
} else {
|
|
81
|
+
setProjectOpen(true);
|
|
82
|
+
setChangesOpen(rememberedChanges.current);
|
|
83
|
+
setProjectMode(rememberedChanges.current ? "changes" : "tree");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const toggleChanges = (): void => {
|
|
87
|
+
if (projectMode === "changes") {
|
|
88
|
+
setChangesOpen(false);
|
|
89
|
+
setProjectMode("tree");
|
|
90
|
+
// never leave zoom on with the Changes editor gone - the Thread pane would stay hidden
|
|
91
|
+
setZoomed(false);
|
|
92
|
+
} else {
|
|
93
|
+
setChangesOpen(true);
|
|
94
|
+
setProjectOpen(true);
|
|
95
|
+
setProjectMode("changes");
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const toggleProject = (): void => {
|
|
99
|
+
setProjectOpen(true);
|
|
100
|
+
setProjectMode("tree");
|
|
101
|
+
};
|
|
102
|
+
const close = (groupId: string, tabId: string): void => {
|
|
103
|
+
setGrid((tree) => {
|
|
104
|
+
const pruned = closeTab(tree, groupId, tabId);
|
|
105
|
+
if (pruned === null) {
|
|
106
|
+
setChangesOpen(false);
|
|
107
|
+
setProjectMode("tree");
|
|
108
|
+
// leaving zoom on would hide the Thread pane with no editor to fill the gap
|
|
109
|
+
setZoomed(false);
|
|
110
|
+
return makeGroup([changesTab()]);
|
|
111
|
+
}
|
|
112
|
+
// re-home focus when the closed group was pruned away, so the next open has a live target
|
|
113
|
+
setFocusedGroup((current) =>
|
|
114
|
+
current !== null && containsGroup(pruned, current) ? current : firstGroupId(pruned),
|
|
115
|
+
);
|
|
116
|
+
return pruned;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const split = (groupId: string, direction: SplitDirection): void => {
|
|
120
|
+
setGrid((tree) => {
|
|
121
|
+
const result = splitGroup(tree, groupId, direction);
|
|
122
|
+
setFocusedGroup(result.focusGroupId);
|
|
123
|
+
return result.tree;
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const openFile = (path: string, fileView: "diff" | "contents"): void => {
|
|
127
|
+
setProjectOpen(true);
|
|
128
|
+
setChangesOpen(true);
|
|
129
|
+
const label = path.split("/").pop() ?? path;
|
|
130
|
+
setGrid((tree) =>
|
|
131
|
+
addTab(tree, focusedGroup ?? firstGroupId(tree), fileTab(label, path, fileView)),
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
projectOpen,
|
|
137
|
+
changesOpen,
|
|
138
|
+
projectMode,
|
|
139
|
+
zoomed,
|
|
140
|
+
grid,
|
|
141
|
+
activeGroup: focusedGroup ?? firstGroupId(grid),
|
|
142
|
+
toggleRight,
|
|
143
|
+
toggleChanges,
|
|
144
|
+
toggleProject,
|
|
145
|
+
toggleZoom: () => setZoomed((value) => !value),
|
|
146
|
+
focusGroup: setFocusedGroup,
|
|
147
|
+
activate: (groupId, tabId) => setGrid((tree) => activateTab(tree, groupId, tabId)),
|
|
148
|
+
close,
|
|
149
|
+
split,
|
|
150
|
+
openFile,
|
|
151
|
+
syncSession,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a layout-dependent value after every rendered frame (and resize),
|
|
3
|
+
* committing only on change. Layout lands after the commit, so a timer
|
|
4
|
+
* would race the visual-idle wait; measuring on the frame event converges
|
|
5
|
+
* deterministically within a frame or two.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { useRenderer } from "@opentui/react";
|
|
10
|
+
|
|
11
|
+
export function useFrameMeasure<T>(
|
|
12
|
+
read: () => T,
|
|
13
|
+
isEqual: (left: T, right: T) => boolean,
|
|
14
|
+
initial: T,
|
|
15
|
+
): T {
|
|
16
|
+
const renderer = useRenderer();
|
|
17
|
+
const [value, setValue] = useState(initial);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const measure = (): void => {
|
|
21
|
+
const next = read();
|
|
22
|
+
|
|
23
|
+
setValue((current) => (isEqual(current, next) ? current : next));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
measure();
|
|
27
|
+
renderer?.on("frame", measure);
|
|
28
|
+
renderer?.on("resize", measure);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
renderer?.off("frame", measure);
|
|
32
|
+
renderer?.off("resize", measure);
|
|
33
|
+
};
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, [renderer]);
|
|
36
|
+
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import React, { useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_QUICK_ACTIONS,
|
|
4
|
+
persistActions,
|
|
5
|
+
persistAutoClose,
|
|
6
|
+
persistDiffView,
|
|
7
|
+
persistTheme,
|
|
8
|
+
type AutoClose,
|
|
9
|
+
type DiffViewMode,
|
|
10
|
+
type QuickAction,
|
|
11
|
+
} from "./config";
|
|
12
|
+
import {
|
|
13
|
+
composeTheme,
|
|
14
|
+
THEME_LABELS,
|
|
15
|
+
THEME_NAMES,
|
|
16
|
+
type Appearance,
|
|
17
|
+
type ThemeName,
|
|
18
|
+
} from "./theme-presets";
|
|
19
|
+
import type { Theme } from "./theme";
|
|
20
|
+
import type { SettingsCategory } from "./components/SettingsDialog";
|
|
21
|
+
import { QuickActionsEditor } from "./components/quick-actions-editor";
|
|
22
|
+
|
|
23
|
+
export interface SettingsNav {
|
|
24
|
+
categoryId: string;
|
|
25
|
+
rowIndex: number;
|
|
26
|
+
zone: "nav" | "body";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SettingsDialogModel {
|
|
30
|
+
settingsNav: SettingsNav;
|
|
31
|
+
actionsExpandedIndex: number | null;
|
|
32
|
+
setActionsExpandedIndex: Dispatch<SetStateAction<number | null>>;
|
|
33
|
+
settingsCategories: SettingsCategory[];
|
|
34
|
+
settingsValues: Record<string, string>;
|
|
35
|
+
cycleSetting: (rowKey: string) => void;
|
|
36
|
+
handleSettingsKey: (name: string) => void;
|
|
37
|
+
onCategorySelect: (categoryId: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DOWN_KEYS = new Set(["j", "down"]);
|
|
41
|
+
const UP_KEYS = new Set(["k", "up"]);
|
|
42
|
+
const ENTER_BODY_KEYS = new Set(["l", "tab", "return"]);
|
|
43
|
+
const ACTIVATE_KEYS = new Set(["return", "space", "l"]);
|
|
44
|
+
|
|
45
|
+
function moveNavZone(
|
|
46
|
+
name: string,
|
|
47
|
+
categoryIndex: number,
|
|
48
|
+
categories: SettingsCategory[],
|
|
49
|
+
): SettingsNav | null {
|
|
50
|
+
if (DOWN_KEYS.has(name))
|
|
51
|
+
return {
|
|
52
|
+
categoryId: categories[Math.min(categories.length - 1, categoryIndex + 1)]!.id,
|
|
53
|
+
rowIndex: 0,
|
|
54
|
+
zone: "nav",
|
|
55
|
+
};
|
|
56
|
+
if (UP_KEYS.has(name))
|
|
57
|
+
return { categoryId: categories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" };
|
|
58
|
+
if (ENTER_BODY_KEYS.has(name))
|
|
59
|
+
return { categoryId: categories[categoryIndex]!.id, rowIndex: 0, zone: "body" };
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
|
|
65
|
+
if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
|
|
66
|
+
if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
|
|
67
|
+
if (name === "h" || name === "tab") return { ...nav, zone: "nav" };
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useSettingsDialog(params: {
|
|
73
|
+
theme: Theme;
|
|
74
|
+
appearance: Appearance;
|
|
75
|
+
autoClose: AutoClose;
|
|
76
|
+
setAutoClose: Dispatch<SetStateAction<AutoClose>>;
|
|
77
|
+
diffView: DiffViewMode;
|
|
78
|
+
setDiffView: Dispatch<SetStateAction<DiffViewMode>>;
|
|
79
|
+
themeName: ThemeName;
|
|
80
|
+
setThemeName: Dispatch<SetStateAction<ThemeName>>;
|
|
81
|
+
themeOverrides: Partial<Theme>;
|
|
82
|
+
setTheme: Dispatch<SetStateAction<Theme>>;
|
|
83
|
+
quickActions: QuickAction[];
|
|
84
|
+
setQuickActions: Dispatch<SetStateAction<QuickAction[]>>;
|
|
85
|
+
setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
|
|
86
|
+
}): SettingsDialogModel {
|
|
87
|
+
const {
|
|
88
|
+
theme,
|
|
89
|
+
appearance,
|
|
90
|
+
autoClose,
|
|
91
|
+
setAutoClose,
|
|
92
|
+
diffView,
|
|
93
|
+
setDiffView,
|
|
94
|
+
themeName,
|
|
95
|
+
setThemeName,
|
|
96
|
+
themeOverrides,
|
|
97
|
+
setTheme,
|
|
98
|
+
quickActions,
|
|
99
|
+
setQuickActions,
|
|
100
|
+
setMenuDialog,
|
|
101
|
+
} = params;
|
|
102
|
+
|
|
103
|
+
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
104
|
+
categoryId: "general",
|
|
105
|
+
rowIndex: 0,
|
|
106
|
+
zone: "body",
|
|
107
|
+
});
|
|
108
|
+
const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
|
|
109
|
+
|
|
110
|
+
const commitActions = (next: QuickAction[]): void => {
|
|
111
|
+
setQuickActions(next);
|
|
112
|
+
persistActions(next);
|
|
113
|
+
};
|
|
114
|
+
const editActionMetadata = (index: number, metadata: string): void =>
|
|
115
|
+
commitActions(
|
|
116
|
+
quickActions.map((action, actionIndex) =>
|
|
117
|
+
actionIndex === index
|
|
118
|
+
? { ...action, metadata: metadata.trim() ? metadata : undefined }
|
|
119
|
+
: action,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
const resetActions = (): void => {
|
|
123
|
+
setActionsExpandedIndex(null);
|
|
124
|
+
commitActions(DEFAULT_QUICK_ACTIONS.map((action) => ({ ...action })));
|
|
125
|
+
};
|
|
126
|
+
const addAction = (): void => {
|
|
127
|
+
setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: quickActions.length }));
|
|
128
|
+
commitActions([...quickActions, { prompt: "New action" }]);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const settingsCategories: SettingsCategory[] = [
|
|
132
|
+
{
|
|
133
|
+
id: "general",
|
|
134
|
+
name: "General",
|
|
135
|
+
description: "submission behaviour",
|
|
136
|
+
rows: [
|
|
137
|
+
{
|
|
138
|
+
key: "autoClose",
|
|
139
|
+
label: "Auto-close on submit",
|
|
140
|
+
kind: "cycle",
|
|
141
|
+
options: ["off", "3s", "10s"],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "diffView",
|
|
145
|
+
label: "Diff view (when wide)",
|
|
146
|
+
kind: "cycle",
|
|
147
|
+
options: ["Split", "Stacked"],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "appearance",
|
|
153
|
+
name: "Appearance",
|
|
154
|
+
description: "the color theme",
|
|
155
|
+
rows: [
|
|
156
|
+
{
|
|
157
|
+
key: "theme",
|
|
158
|
+
label: "Theme",
|
|
159
|
+
kind: "cycle",
|
|
160
|
+
options: THEME_NAMES.map((name) => THEME_LABELS[name]),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "actions",
|
|
166
|
+
name: "Actions",
|
|
167
|
+
description: "quick-action comments",
|
|
168
|
+
rows: [],
|
|
169
|
+
customBody: (
|
|
170
|
+
<QuickActionsEditor
|
|
171
|
+
actions={quickActions}
|
|
172
|
+
selectedIndex={settingsNav.categoryId === "actions" ? settingsNav.rowIndex : -1}
|
|
173
|
+
expandedIndex={actionsExpandedIndex}
|
|
174
|
+
onToggleExpand={(index) => {
|
|
175
|
+
setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
|
|
176
|
+
setActionsExpandedIndex((current) => (current === index ? null : index));
|
|
177
|
+
}}
|
|
178
|
+
onEditMetadata={editActionMetadata}
|
|
179
|
+
onReset={resetActions}
|
|
180
|
+
onAdd={addAction}
|
|
181
|
+
theme={theme}
|
|
182
|
+
/>
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "keybinds",
|
|
187
|
+
name: "Keybinds",
|
|
188
|
+
description: "keyboard reference",
|
|
189
|
+
rows: [],
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
const settingsValues = {
|
|
193
|
+
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
194
|
+
diffView: diffView === "split" ? "Split" : "Stacked",
|
|
195
|
+
theme: THEME_LABELS[themeName],
|
|
196
|
+
};
|
|
197
|
+
const cycleSetting = (rowKey: string): void => {
|
|
198
|
+
if (rowKey === "autoClose") {
|
|
199
|
+
const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
|
|
200
|
+
|
|
201
|
+
setAutoClose(next);
|
|
202
|
+
persistAutoClose(next);
|
|
203
|
+
} else if (rowKey === "diffView") {
|
|
204
|
+
const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
|
|
205
|
+
|
|
206
|
+
setDiffView(next);
|
|
207
|
+
persistDiffView(next);
|
|
208
|
+
} else if (rowKey === "theme") {
|
|
209
|
+
const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
|
|
210
|
+
|
|
211
|
+
setThemeName(next);
|
|
212
|
+
setTheme(composeTheme(next, themeOverrides, appearance));
|
|
213
|
+
persistTheme(next);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const onCategorySelect = (categoryId: string): void => {
|
|
218
|
+
// the nav tree carries a synthetic Settings group folder; clicking it must
|
|
219
|
+
// not become the active category, or the key handler dereferences nothing
|
|
220
|
+
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
221
|
+
setActionsExpandedIndex(null);
|
|
222
|
+
setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleSettingsKey = (name: string): void => {
|
|
226
|
+
// an open system-prompt input owns typing; only esc (close it) escapes here
|
|
227
|
+
if (actionsExpandedIndex !== null) {
|
|
228
|
+
if (name === "escape") setActionsExpandedIndex(null);
|
|
229
|
+
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (name === "escape") return void setMenuDialog(null);
|
|
233
|
+
const categoryIndex = settingsCategories.findIndex(
|
|
234
|
+
(category) => category.id === settingsNav.categoryId,
|
|
235
|
+
);
|
|
236
|
+
const category = settingsCategories[categoryIndex]!;
|
|
237
|
+
|
|
238
|
+
if (settingsNav.zone === "nav") {
|
|
239
|
+
const next = moveNavZone(name, categoryIndex, settingsCategories);
|
|
240
|
+
|
|
241
|
+
if (next) setSettingsNav(next);
|
|
242
|
+
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// the Actions category is a list of quick actions plus a trailing "add" row
|
|
246
|
+
if (category.id === "actions") {
|
|
247
|
+
const moved = moveBodyRow(name, settingsNav, quickActions.length + 1);
|
|
248
|
+
|
|
249
|
+
if (moved) return void setSettingsNav(moved);
|
|
250
|
+
if (ACTIVATE_KEYS.has(name)) {
|
|
251
|
+
if (settingsNav.rowIndex === quickActions.length) addAction();
|
|
252
|
+
else setActionsExpandedIndex(settingsNav.rowIndex);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const moved = moveBodyRow(name, settingsNav, category.rows.length);
|
|
258
|
+
|
|
259
|
+
if (moved) return void setSettingsNav(moved);
|
|
260
|
+
if (name === "return" || name === "space")
|
|
261
|
+
cycleSetting(category.rows[settingsNav.rowIndex]!.key);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
settingsNav,
|
|
266
|
+
actionsExpandedIndex,
|
|
267
|
+
setActionsExpandedIndex,
|
|
268
|
+
settingsCategories,
|
|
269
|
+
settingsValues,
|
|
270
|
+
cycleSetting,
|
|
271
|
+
handleSettingsKey,
|
|
272
|
+
onCategorySelect,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row virtualization for an OpenTUI scrollbox, driven by @tanstack/virtual-core.
|
|
3
|
+
* The core is DOM-free at heart: it only reaches the scroll container through the
|
|
4
|
+
* observe/scroll callbacks given here, so a terminal scrollbox stands in for the
|
|
5
|
+
* element - offsets and sizes are terminal rows, the "scroll event" is the
|
|
6
|
+
* renderer's frame event, and a mounted row's height is read off its renderable.
|
|
7
|
+
* Callers render `items` with a spacer of `items[0].start` rows above and
|
|
8
|
+
* `totalSize - last.end` below, and hand each row's box to `measureRef(index)`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
|
12
|
+
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
13
|
+
import { useRenderer } from "@opentui/react";
|
|
14
|
+
import { Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
|
|
15
|
+
|
|
16
|
+
export interface TerminalVirtualizerOptions {
|
|
17
|
+
scrollbox: RefObject<ScrollBoxRenderable | null>;
|
|
18
|
+
count: number;
|
|
19
|
+
/** Rows an item occupies before it is measured (its wrapped visual lines). */
|
|
20
|
+
estimateSize: (index: number) => number;
|
|
21
|
+
/** Items this many rows beyond the viewport stay mounted, so a scroll step never shows a gap. */
|
|
22
|
+
overscan: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TerminalVirtualizer {
|
|
26
|
+
items: VirtualItem[];
|
|
27
|
+
totalSize: number;
|
|
28
|
+
/** Ref callback for an item's row box, so its real height (cards included) replaces the estimate. */
|
|
29
|
+
measureRef: (index: number) => (renderable: BoxRenderable | null) => void | (() => void);
|
|
30
|
+
/** Scroll the item into view; "auto" only scrolls when it is off screen. */
|
|
31
|
+
scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The scroll container as the core reads it: the offset and the extents it clamps a scroll
|
|
36
|
+
* target with. These are the `Element` members of the same names, so the view passes as one.
|
|
37
|
+
*/
|
|
38
|
+
interface ScrollContainerView {
|
|
39
|
+
readonly scrollTop: number;
|
|
40
|
+
readonly scrollHeight: number;
|
|
41
|
+
readonly clientHeight: number;
|
|
42
|
+
readonly scrollWidth: number;
|
|
43
|
+
readonly clientWidth: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A mounted row as the core measures it: its height under the `Element` member name. */
|
|
47
|
+
interface RowView {
|
|
48
|
+
readonly clientHeight: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type CoreVirtualizer = Virtualizer<Element, Element>;
|
|
52
|
+
|
|
53
|
+
function scrollContainerView(scrollbox: ScrollBoxRenderable): ScrollContainerView {
|
|
54
|
+
return {
|
|
55
|
+
get scrollTop() {
|
|
56
|
+
return scrollbox.scrollTop;
|
|
57
|
+
},
|
|
58
|
+
get scrollHeight() {
|
|
59
|
+
return scrollbox.scrollHeight;
|
|
60
|
+
},
|
|
61
|
+
get clientHeight() {
|
|
62
|
+
return scrollbox.viewport.height;
|
|
63
|
+
},
|
|
64
|
+
get scrollWidth() {
|
|
65
|
+
return scrollbox.scrollWidth;
|
|
66
|
+
},
|
|
67
|
+
get clientWidth() {
|
|
68
|
+
return scrollbox.viewport.width;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rowView(renderable: BoxRenderable): RowView {
|
|
74
|
+
return {
|
|
75
|
+
get clientHeight() {
|
|
76
|
+
return renderable.height;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The core's element generics are DOM-typed, but every way it touches the container or a row
|
|
83
|
+
* (rect, offset, scroll, measurement, index lookup) is replaced here with a renderable-aware
|
|
84
|
+
* callback, so a view exposing just the members it reads stands in for the element.
|
|
85
|
+
*/
|
|
86
|
+
function asElement(view: ScrollContainerView | RowView): Element {
|
|
87
|
+
// SAFETY: the core never calls DOM methods on this value; its DOM defaults are all overridden.
|
|
88
|
+
return view as Element;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): TerminalVirtualizer {
|
|
92
|
+
const renderer = useRenderer();
|
|
93
|
+
const [, setRevision] = useState(0);
|
|
94
|
+
// one stable view per mounted row box, so the core's element cache keeps its identity
|
|
95
|
+
const viewOfRenderable = useRef(new WeakMap<BoxRenderable, Element>());
|
|
96
|
+
const indexOfView = useRef(new WeakMap<Element, number>());
|
|
97
|
+
const container = useRef<{ scrollbox: ScrollBoxRenderable; view: Element } | null>(null);
|
|
98
|
+
|
|
99
|
+
const scrollContainer = (): Element | null => {
|
|
100
|
+
const scrollbox = options.scrollbox.current;
|
|
101
|
+
|
|
102
|
+
if (!scrollbox) return null;
|
|
103
|
+
if (container.current?.scrollbox !== scrollbox) {
|
|
104
|
+
container.current = { scrollbox, view: asElement(scrollContainerView(scrollbox)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return container.current.view;
|
|
108
|
+
};
|
|
109
|
+
const viewFor = (renderable: BoxRenderable): Element => {
|
|
110
|
+
const known = viewOfRenderable.current.get(renderable);
|
|
111
|
+
|
|
112
|
+
if (known) return known;
|
|
113
|
+
const view = asElement(rowView(renderable));
|
|
114
|
+
|
|
115
|
+
viewOfRenderable.current.set(renderable, view);
|
|
116
|
+
|
|
117
|
+
return view;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// the offset reporter the core registered, so a scroll the core asks for reports at once
|
|
121
|
+
const reportOffset = useRef<(() => void) | null>(null);
|
|
122
|
+
|
|
123
|
+
// a frame-event poll stands in for scroll and resize events: the renderer fires it after every
|
|
124
|
+
// render, and the callbacks only fire the core when the value actually changed
|
|
125
|
+
const onFrame = (read: () => void): (() => void) => {
|
|
126
|
+
read();
|
|
127
|
+
renderer?.on("frame", read);
|
|
128
|
+
|
|
129
|
+
return () => renderer?.off("frame", read);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const coreOptions = () => ({
|
|
133
|
+
count: options.count,
|
|
134
|
+
overscan: options.overscan,
|
|
135
|
+
estimateSize: options.estimateSize,
|
|
136
|
+
getScrollElement: scrollContainer,
|
|
137
|
+
observeElementRect: (
|
|
138
|
+
_instance: CoreVirtualizer,
|
|
139
|
+
callback: (rect: { width: number; height: number }) => void,
|
|
140
|
+
) => {
|
|
141
|
+
let last = { width: -1, height: -1 };
|
|
142
|
+
|
|
143
|
+
return onFrame(() => {
|
|
144
|
+
const scrollbox = options.scrollbox.current;
|
|
145
|
+
|
|
146
|
+
if (!scrollbox) return;
|
|
147
|
+
const next = { width: scrollbox.viewport.width, height: scrollbox.viewport.height };
|
|
148
|
+
|
|
149
|
+
if (next.width === last.width && next.height === last.height) return;
|
|
150
|
+
last = next;
|
|
151
|
+
callback(next);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
observeElementOffset: (
|
|
155
|
+
_instance: CoreVirtualizer,
|
|
156
|
+
callback: (offset: number, isScrolling: boolean) => void,
|
|
157
|
+
) => {
|
|
158
|
+
let last = -1;
|
|
159
|
+
const report = (): void => {
|
|
160
|
+
const scrollbox = options.scrollbox.current;
|
|
161
|
+
|
|
162
|
+
if (!scrollbox || scrollbox.scrollTop === last) return;
|
|
163
|
+
last = scrollbox.scrollTop;
|
|
164
|
+
callback(last, false);
|
|
165
|
+
};
|
|
166
|
+
const stop = onFrame(report);
|
|
167
|
+
|
|
168
|
+
reportOffset.current = report;
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
reportOffset.current = null;
|
|
172
|
+
stop();
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
// report the new offset in the same tick: the item window then follows the scroll at once,
|
|
176
|
+
// instead of one frame later with the target rows still unmounted
|
|
177
|
+
scrollToFn: (offset: number) => {
|
|
178
|
+
options.scrollbox.current?.scrollTo({ x: 0, y: Math.max(0, Math.round(offset)) });
|
|
179
|
+
reportOffset.current?.();
|
|
180
|
+
},
|
|
181
|
+
// a box measures 0 until Yoga has laid it out; keep the estimate rather than collapse the row
|
|
182
|
+
measureElement: (element: Element) =>
|
|
183
|
+
element.clientHeight > 0
|
|
184
|
+
? element.clientHeight
|
|
185
|
+
: options.estimateSize(indexOfView.current.get(element) ?? -1),
|
|
186
|
+
onChange: () => setRevision((revision) => revision + 1),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const [virtualizer] = useState<CoreVirtualizer>(() => {
|
|
190
|
+
const instance = new Virtualizer<Element, Element>(coreOptions());
|
|
191
|
+
|
|
192
|
+
// the core looks an item's index up through a DOM attribute; ours is remembered per row view
|
|
193
|
+
instance.indexFromElement = (element) => indexOfView.current.get(element) ?? -1;
|
|
194
|
+
|
|
195
|
+
return instance;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
virtualizer.setOptions(coreOptions());
|
|
199
|
+
useLayoutEffect(() => {
|
|
200
|
+
virtualizer._willUpdate();
|
|
201
|
+
});
|
|
202
|
+
useEffect(() => virtualizer._didMount(), [virtualizer]);
|
|
203
|
+
// no ResizeObserver here: a mounted row's height settles after layout and grows when a card
|
|
204
|
+
// opens under it, so every frame re-reads the mounted rows (the core only notifies on change)
|
|
205
|
+
useEffect(
|
|
206
|
+
() =>
|
|
207
|
+
onFrame(() => {
|
|
208
|
+
for (const element of virtualizer.elementsCache.values()) {
|
|
209
|
+
virtualizer.measureElement(element);
|
|
210
|
+
}
|
|
211
|
+
}),
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
[virtualizer, renderer],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
items: virtualizer.getVirtualItems(),
|
|
218
|
+
totalSize: virtualizer.getTotalSize(),
|
|
219
|
+
measureRef: (index) => (renderable) => {
|
|
220
|
+
if (!renderable) return;
|
|
221
|
+
const view = viewFor(renderable);
|
|
222
|
+
|
|
223
|
+
indexOfView.current.set(view, index);
|
|
224
|
+
virtualizer.measureElement(view);
|
|
225
|
+
|
|
226
|
+
// the row unmounted: forget its view so the frame re-measure stops reading it
|
|
227
|
+
return () => {
|
|
228
|
+
if (virtualizer.elementsCache.get(index) === view) virtualizer.elementsCache.delete(index);
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
scrollToIndex: (index, align = "auto") => virtualizer.scrollToIndex(index, { align }),
|
|
232
|
+
};
|
|
233
|
+
}
|