gloomberb 0.9.4 → 0.9.6
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/README.md +49 -8
- package/package.json +4 -3
- package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
- package/patches/react-reconciler@0.33.0.patch +10 -0
- package/src/app/global-shortcuts.test.tsx +20 -1
- package/src/app/global-shortcuts.ts +2 -0
- package/src/app/pane-runtime/index.tsx +11 -4
- package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
- package/src/app.tsx +41 -17
- package/src/cli/commands/remote.ts +206 -0
- package/src/cli/entry.ts +8 -1
- package/src/cli/index.ts +2 -0
- package/src/cli/native-smoke.ts +5 -0
- package/src/components/chart/core/keyboard.ts +10 -1
- package/src/components/chart/native/bitmap-support.test.ts +27 -0
- package/src/components/chart/native/bitmap-support.ts +55 -0
- package/src/components/chart/native/kitty/index.test.ts +47 -0
- package/src/components/chart/native/surface/visibility.ts +8 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
- package/src/components/chart/static/chart/bitmap.ts +16 -9
- package/src/components/chart/static/chart/surface.test.tsx +59 -4
- package/src/components/chart/static/chart/surface.tsx +4 -0
- package/src/components/chart/stock-chart/index.test.ts +7 -0
- package/src/components/command-bar/list/view.tsx +48 -1
- package/src/components/command-bar/multi-select-picker.tsx +7 -0
- package/src/components/command-bar/routing/navigation-state.ts +10 -1
- package/src/components/command-bar/surface/index.test.tsx +14 -0
- package/src/components/command-bar/theme-picker.tsx +91 -79
- package/src/components/command-bar/workflow/field-row.tsx +30 -5
- package/src/components/data-table/view.tsx +0 -12
- package/src/components/index.ts +7 -0
- package/src/components/layout/floating-pane.tsx +3 -3
- package/src/components/layout/pane/footer/index.tsx +23 -0
- package/src/components/layout/pane/header.tsx +5 -0
- package/src/components/layout/pane/index.tsx +3 -3
- package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
- package/src/components/layout/shell/cursor-occlusion.ts +9 -1
- package/src/components/layout/shell/index.test.tsx +2 -0
- package/src/components/layout/shell/index.tsx +16 -4
- package/src/components/layout/shell/pane/actions.ts +21 -7
- package/src/components/layout/shell/pane/layers.tsx +2 -2
- package/src/components/layout/status-bar.tsx +5 -5
- package/src/components/metric-treemap/index.tsx +317 -0
- package/src/components/metric-treemap/layout.test.ts +77 -0
- package/src/components/metric-treemap/layout.ts +476 -0
- package/src/components/pane-settings-dialog/desktop.tsx +1 -1
- package/src/components/price-performance.tsx +1 -1
- package/src/components/price-sparkline/view.tsx +15 -14
- package/src/components/speedometer-gauge/terminal.tsx +18 -15
- package/src/components/table-view-shared.tsx +0 -10
- package/src/components/ticker/list-table-view.tsx +1 -7
- package/src/components/ticker/list-table.test.tsx +0 -4
- package/src/components/toggle-list.tsx +10 -0
- package/src/components/ui/button.tsx +12 -0
- package/src/components/ui/data-table/index.tsx +51 -0
- package/src/components/ui/data-table/opentui/index.tsx +4 -10
- package/src/components/ui/data-table/types.ts +6 -4
- package/src/components/ui/fields.tsx +18 -0
- package/src/components/ui/list-view.tsx +84 -4
- package/src/components/ui/message-composer.tsx +22 -0
- package/src/components/ui/tabs.tsx +46 -2
- package/src/components/ui/ui.test.tsx +0 -9
- package/src/core/state/app/layout-reducer.ts +7 -1
- package/src/core/state/app/layout.ts +6 -0
- package/src/core/state/app/state.ts +23 -1
- package/src/core/state/app/types.ts +4 -2
- package/src/plugins/builtin/account-management/form-components.tsx +2 -2
- package/src/plugins/builtin/alerts/pane.tsx +1 -1
- package/src/plugins/builtin/buildout/cells.tsx +1 -1
- package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
- package/src/plugins/builtin/chat/channels.ts +5 -18
- package/src/plugins/builtin/chat/chat.test.tsx +1 -7
- package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
- package/src/plugins/builtin/chat/sidebar.tsx +1 -1
- package/src/plugins/builtin/chat/status-widget.tsx +2 -8
- package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
- package/src/plugins/builtin/cloud/plugin.tsx +4 -13
- package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
- package/src/plugins/builtin/debug/index.tsx +3 -4
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/holders/treemap.tsx +28 -297
- package/src/plugins/builtin/holders/types.ts +0 -34
- package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
- package/src/plugins/builtin/market-heatmap/data.ts +296 -0
- package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
- package/src/plugins/builtin/options/table.ts +5 -5
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +41 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +78 -22
- package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
- package/src/plugins/ibkr/gateway/service/index.ts +6 -9
- package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
- package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
- package/src/remote/app-host.tsx +77 -0
- package/src/remote/client.ts +34 -0
- package/src/remote/command-bar.ts +98 -0
- package/src/remote/controller-utils.ts +111 -0
- package/src/remote/controller.test.ts +365 -0
- package/src/remote/controller.ts +434 -0
- package/src/remote/endpoint.ts +41 -0
- package/src/remote/json-patch.test.ts +31 -0
- package/src/remote/json-patch.ts +89 -0
- package/src/remote/layout-helpers.ts +62 -0
- package/src/remote/resources.ts +186 -0
- package/src/remote/revision.ts +18 -0
- package/src/remote/schema.ts +143 -0
- package/src/remote/semantic-helpers.ts +161 -0
- package/src/remote/semantic-tree.tsx +112 -0
- package/src/remote/server.test.ts +50 -0
- package/src/remote/server.ts +121 -0
- package/src/remote/types.ts +119 -0
- package/src/renderers/electrobun/bun/index.ts +55 -1
- package/src/renderers/electrobun/shared/protocol.ts +11 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
- package/src/renderers/electrobun/view/build-assets.ts +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +0 -8
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -12
- package/src/renderers/electrobun/view/host/box.tsx +11 -1
- package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
- package/src/renderers/electrobun/view/host/mouse.ts +2 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -0
- package/src/renderers/electrobun/view/main.tsx +5 -1
- package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
- package/src/renderers/electrobun/view/styles.css +7 -0
- package/src/renderers/opentui/chart-surface.test.tsx +101 -0
- package/src/renderers/opentui/chart-surface.tsx +15 -20
- package/src/renderers/opentui/native-smoke.ts +1 -0
- package/src/renderers/opentui/start.tsx +32 -0
- package/src/sources/yahoo-finance/http.ts +31 -4
- package/src/state/app/context/index.test.tsx +47 -0
- package/src/state/app/context/index.tsx +48 -18
- package/src/ui/host.tsx +1 -0
- package/src/ui/index.tsx +183 -7
- package/src/version.ts +1 -1
- package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Dispatch } from "react";
|
|
2
|
+
import type { PluginRegistry } from "../plugins/registry";
|
|
3
|
+
import type { AppAction, AppState } from "../state/app/context";
|
|
4
|
+
import type { PaneRuntimeState } from "../core/state/app/state";
|
|
5
|
+
import { setPaneSettings } from "../pane-settings";
|
|
6
|
+
import type { LayoutConfig } from "../types/config";
|
|
7
|
+
import { commandBarResultsFromNodes, commandBarSnapshot } from "./command-bar";
|
|
8
|
+
import type { RemoteUiRegistry } from "./semantic-tree";
|
|
9
|
+
import { REMOTE_AGENT_HELP, remoteControlSchema } from "./schema";
|
|
10
|
+
import type { RemoteIncludedState, RemoteStateInclude } from "./types";
|
|
11
|
+
import {
|
|
12
|
+
activeLayoutRev,
|
|
13
|
+
normalizeIncludes,
|
|
14
|
+
paneSnapshot,
|
|
15
|
+
type PatchTarget,
|
|
16
|
+
} from "./controller-utils";
|
|
17
|
+
|
|
18
|
+
interface RemoteResourceContext {
|
|
19
|
+
dispatch: Dispatch<AppAction>;
|
|
20
|
+
getState: () => AppState;
|
|
21
|
+
pluginRegistry: PluginRegistry;
|
|
22
|
+
uiRegistry: RemoteUiRegistry | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createRemoteResources({
|
|
26
|
+
dispatch,
|
|
27
|
+
getState,
|
|
28
|
+
pluginRegistry,
|
|
29
|
+
uiRegistry,
|
|
30
|
+
}: RemoteResourceContext) {
|
|
31
|
+
const getResource = (resource: string): unknown => {
|
|
32
|
+
const state = getState();
|
|
33
|
+
const uiNodes = uiRegistry?.snapshot() ?? [];
|
|
34
|
+
if (resource === "app://snapshot") {
|
|
35
|
+
return {
|
|
36
|
+
rev: activeLayoutRev(state),
|
|
37
|
+
app: {
|
|
38
|
+
activePanel: state.activePanel,
|
|
39
|
+
focusedPaneId: state.focusedPaneId,
|
|
40
|
+
previousFocusedPaneId: state.previousFocusedPaneId,
|
|
41
|
+
statusBarVisible: state.statusBarVisible,
|
|
42
|
+
commandBarOpen: state.commandBarOpen,
|
|
43
|
+
commandBarQuery: state.commandBarQuery,
|
|
44
|
+
initialized: state.initialized,
|
|
45
|
+
},
|
|
46
|
+
config: state.config,
|
|
47
|
+
panes: state.config.layout.instances.map((pane) => paneSnapshot(state, pane)),
|
|
48
|
+
commandBar: commandBarSnapshot(state, uiNodes),
|
|
49
|
+
ui: uiNodes,
|
|
50
|
+
schema: remoteControlSchema(),
|
|
51
|
+
help: REMOTE_AGENT_HELP,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (resource === "app://config") return state.config;
|
|
55
|
+
if (resource === "app://layout/current") return state.config.layout;
|
|
56
|
+
if (resource === "app://layouts") return state.config.layouts;
|
|
57
|
+
if (resource === "app://panes") return state.config.layout.instances.map((pane) => paneSnapshot(state, pane));
|
|
58
|
+
if (resource === "app://pane-types") {
|
|
59
|
+
return [...pluginRegistry.panes.values()].map((pane) => ({
|
|
60
|
+
id: pane.id,
|
|
61
|
+
name: pane.name,
|
|
62
|
+
defaultPosition: pane.defaultPosition,
|
|
63
|
+
defaultMode: pane.defaultMode,
|
|
64
|
+
hasSettings: !!pane.settings,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
if (resource === "app://pane-templates") {
|
|
68
|
+
return [...pluginRegistry.paneTemplates.values()].map((template) => ({
|
|
69
|
+
id: template.id,
|
|
70
|
+
paneId: template.paneId,
|
|
71
|
+
label: template.label,
|
|
72
|
+
description: template.description,
|
|
73
|
+
shortcut: template.shortcut,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
if (resource.startsWith("app://pane-state/")) {
|
|
77
|
+
const paneId = decodeURIComponent(resource.slice("app://pane-state/".length));
|
|
78
|
+
return state.paneState[paneId] ?? {};
|
|
79
|
+
}
|
|
80
|
+
if (resource.startsWith("app://pane-settings/")) {
|
|
81
|
+
const paneId = decodeURIComponent(resource.slice("app://pane-settings/".length));
|
|
82
|
+
const descriptor = pluginRegistry.resolvePaneSettings(paneId);
|
|
83
|
+
if (!descriptor) throw new Error(`Pane "${paneId}" does not expose settings.`);
|
|
84
|
+
return {
|
|
85
|
+
paneId: descriptor.paneId,
|
|
86
|
+
settings: descriptor.context.settings,
|
|
87
|
+
fields: descriptor.settingsDef.fields,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (resource === "app://commands") {
|
|
91
|
+
return [...pluginRegistry.commands.values()].map((command) => ({
|
|
92
|
+
id: command.id,
|
|
93
|
+
label: command.label,
|
|
94
|
+
description: command.description,
|
|
95
|
+
shortcut: command.shortcut,
|
|
96
|
+
hasWizard: !!command.wizard?.length,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
if (resource === "app://command-bar") return commandBarSnapshot(state, uiNodes);
|
|
100
|
+
if (resource === "app://command-bar/results") return commandBarResultsFromNodes(uiNodes);
|
|
101
|
+
if (resource === "app://capabilities") return pluginRegistry.capabilities.manifests();
|
|
102
|
+
if (resource === "app://remote/help") return REMOTE_AGENT_HELP;
|
|
103
|
+
if (resource === "ui://tree") return uiNodes;
|
|
104
|
+
throw new Error(`Unknown remote resource "${resource}".`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const buildIncludedState = (include: RemoteStateInclude[] | undefined, defaults: RemoteStateInclude[] = []): RemoteIncludedState | undefined => {
|
|
108
|
+
const included = normalizeIncludes(include, defaults);
|
|
109
|
+
if (included.length === 0) return undefined;
|
|
110
|
+
const state = getState();
|
|
111
|
+
const uiNodes = uiRegistry?.snapshot() ?? [];
|
|
112
|
+
const result: RemoteIncludedState = {
|
|
113
|
+
rev: activeLayoutRev(state),
|
|
114
|
+
included,
|
|
115
|
+
};
|
|
116
|
+
if (included.includes("app")) {
|
|
117
|
+
result.app = {
|
|
118
|
+
activePanel: state.activePanel,
|
|
119
|
+
focusedPaneId: state.focusedPaneId,
|
|
120
|
+
previousFocusedPaneId: state.previousFocusedPaneId,
|
|
121
|
+
statusBarVisible: state.statusBarVisible,
|
|
122
|
+
commandBarOpen: state.commandBarOpen,
|
|
123
|
+
commandBarQuery: state.commandBarQuery,
|
|
124
|
+
initialized: state.initialized,
|
|
125
|
+
activeLayoutIndex: state.config.activeLayoutIndex,
|
|
126
|
+
activeLayoutName: state.config.layouts[state.config.activeLayoutIndex]?.name ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (included.includes("layout")) result.layout = state.config.layout;
|
|
130
|
+
if (included.includes("panes")) {
|
|
131
|
+
result.panes = state.config.layout.instances.map((pane) => paneSnapshot(state, pane));
|
|
132
|
+
}
|
|
133
|
+
if (included.includes("commandBar") || included.includes("commandBar.results")) {
|
|
134
|
+
const commandBar = commandBarSnapshot(state, uiNodes);
|
|
135
|
+
result.commandBar = included.includes("commandBar")
|
|
136
|
+
? commandBar
|
|
137
|
+
: { results: commandBar.results };
|
|
138
|
+
}
|
|
139
|
+
if (included.includes("ui")) result.ui = uiNodes;
|
|
140
|
+
if (included.includes("schema")) result.schema = remoteControlSchema();
|
|
141
|
+
if (included.includes("help")) result.help = REMOTE_AGENT_HELP;
|
|
142
|
+
return result;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const patchTarget = (resource: string): PatchTarget<unknown> => {
|
|
146
|
+
const state = getState();
|
|
147
|
+
if (resource === "app://config") {
|
|
148
|
+
return {
|
|
149
|
+
value: state.config,
|
|
150
|
+
apply: (value) => dispatch({ type: "SET_CONFIG", config: value as AppState["config"] }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (resource === "app://layout/current") {
|
|
154
|
+
return {
|
|
155
|
+
value: state.config.layout,
|
|
156
|
+
apply: (value) => pluginRegistry.updateLayoutFn(value as LayoutConfig),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (resource.startsWith("app://pane-state/")) {
|
|
160
|
+
const paneId = decodeURIComponent(resource.slice("app://pane-state/".length));
|
|
161
|
+
return {
|
|
162
|
+
value: state.paneState[paneId] ?? {},
|
|
163
|
+
apply: (value) => dispatch({ type: "REPLACE_PANE_STATE", paneId, state: value as PaneRuntimeState }),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (resource.startsWith("app://pane-settings/")) {
|
|
167
|
+
const paneId = decodeURIComponent(resource.slice("app://pane-settings/".length));
|
|
168
|
+
const descriptor = pluginRegistry.resolvePaneSettings(paneId);
|
|
169
|
+
if (!descriptor) throw new Error(`Pane "${paneId}" does not expose settings.`);
|
|
170
|
+
return {
|
|
171
|
+
value: descriptor.context.settings,
|
|
172
|
+
apply: (value) => {
|
|
173
|
+
const nextLayout = setPaneSettings(getState().config.layout, descriptor.paneId, value as Record<string, unknown>);
|
|
174
|
+
pluginRegistry.updateLayoutFn(nextLayout);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Remote resource "${resource}" is not patchable.`);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
buildIncludedState,
|
|
183
|
+
getResource,
|
|
184
|
+
patchTarget,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function stableStringify(value: unknown): string {
|
|
2
|
+
if (value == null || typeof value !== "object") return JSON.stringify(value);
|
|
3
|
+
if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
4
|
+
const object = value as Record<string, unknown>;
|
|
5
|
+
return `{${Object.keys(object).sort().map((key) => (
|
|
6
|
+
`${JSON.stringify(key)}:${stableStringify(object[key])}`
|
|
7
|
+
)).join(",")}}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function revisionFor(value: unknown): string {
|
|
11
|
+
const input = stableStringify(value);
|
|
12
|
+
let hash = 0x811c9dc5;
|
|
13
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
14
|
+
hash ^= input.charCodeAt(index);
|
|
15
|
+
hash = Math.imul(hash, 0x01000193);
|
|
16
|
+
}
|
|
17
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
18
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { RemoteControlSchema, RemoteOperationSchema, RemoteResourceSchema } from "./types";
|
|
2
|
+
|
|
3
|
+
export const REMOTE_RESOURCES: RemoteResourceSchema[] = [
|
|
4
|
+
{ uri: "app://snapshot", description: "Current app snapshot including layout, focus, panes, command bar, and plugin/capability catalogs." },
|
|
5
|
+
{ uri: "app://config", description: "Current app config.", patchable: true },
|
|
6
|
+
{ uri: "app://layout/current", description: "Current active layout.", patchable: true },
|
|
7
|
+
{ uri: "app://layouts", description: "Saved layouts." },
|
|
8
|
+
{ uri: "app://panes", description: "Current pane instances with placement and runtime state." },
|
|
9
|
+
{ uri: "app://pane-types", description: "Registered pane types from core and plugins." },
|
|
10
|
+
{ uri: "app://pane-templates", description: "Registered pane templates from core and plugins." },
|
|
11
|
+
{ uri: "app://pane-state/{paneId}", description: "Runtime state for a pane instance.", patchable: true },
|
|
12
|
+
{ uri: "app://pane-settings/{paneId}", description: "Persisted settings for a pane instance.", patchable: true },
|
|
13
|
+
{ uri: "app://commands", description: "Registered command-bar commands." },
|
|
14
|
+
{ uri: "app://command-bar", description: "Current command-bar state and semantic result rows." },
|
|
15
|
+
{ uri: "app://command-bar/results", description: "Current semantic command-bar result rows." },
|
|
16
|
+
{ uri: "app://capabilities", description: "Registered plugin capability manifests." },
|
|
17
|
+
{ uri: "app://remote/help", description: "Agent-oriented remote usage guide with efficient recipes and caveats." },
|
|
18
|
+
{ uri: "ui://tree", description: "Live semantic UI node tree populated by shared controls and interactive primitives." },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const REMOTE_OPERATIONS: RemoteOperationSchema[] = [
|
|
22
|
+
op("app.openCommandBar", "Open the command bar with an optional query and optional mode.", "{ query?: string, mode?: 'command' | 'ticker' }", "local-write"),
|
|
23
|
+
op("app.closeCommandBar", "Close the command bar.", "{}", "local-write"),
|
|
24
|
+
op("app.setCommandBarQuery", "Set the command bar query.", "{ query: string }", "local-write"),
|
|
25
|
+
op("app.search", "Open command-bar search without requiring UI prefix syntax.", "{ mode: 'command' | 'ticker', query?: string }", "local-write"),
|
|
26
|
+
op("app.switchPanel", "Switch the active panel.", "{ panel: 'left' | 'right' }", "local-write"),
|
|
27
|
+
op("app.notify", "Show an in-app notification.", "{ body: string, type?: string }", "local-write"),
|
|
28
|
+
op("commandBar.activateResult", "Activate a visible command-bar result by zero-based index, node id, item id, or label.", "{ index?: number, nodeId?: string, itemId?: string, label?: string }", "local-write"),
|
|
29
|
+
op("pane.show", "Show or focus a pane type.", "{ paneId: string }", "local-write"),
|
|
30
|
+
op("pane.focus", "Focus a pane instance or pane type.", "{ paneId: string }", "local-write"),
|
|
31
|
+
op("pane.close", "Close a pane instance or pane type.", "{ paneId: string }", "local-write"),
|
|
32
|
+
op("pane.createFromTemplate", "Create a pane from a registered template.", "{ templateId: string, options?: object }", "local-write"),
|
|
33
|
+
op("pane.setState", "Patch pane runtime state.", "{ paneId: string, patch: object }", "local-write"),
|
|
34
|
+
op("pane.setSetting", "Set one pane setting using the pane's registered setting field when available.", "{ paneId: string, key: string, value: any }", "local-write"),
|
|
35
|
+
op("ticker.navigate", "Navigate a ticker into the best available ticker research target.", "{ symbol: string, sourcePaneId?: string }", "local-write"),
|
|
36
|
+
op("ticker.pin", "Open or focus a fixed ticker research pane.", "{ symbol: string, floating?: boolean, forceNewPane?: boolean, paneType?: string }", "local-write"),
|
|
37
|
+
op("ticker.select", "Select a ticker in a target pane.", "{ symbol: string, paneId?: string }", "local-write"),
|
|
38
|
+
op("ticker.switchTab", "Switch ticker research tab.", "{ tabId: string, paneId?: string }", "local-write"),
|
|
39
|
+
op("layout.switch", "Switch active layout by index.", "{ index: number }", "local-write"),
|
|
40
|
+
op("layout.new", "Create a new blank layout.", "{ name: string }", "local-write"),
|
|
41
|
+
op("layout.rename", "Rename a layout.", "{ index: number, name: string }", "local-write"),
|
|
42
|
+
op("layout.duplicate", "Duplicate a layout.", "{ index: number }", "local-write"),
|
|
43
|
+
op("layout.delete", "Delete a layout.", "{ index: number }", "local-write"),
|
|
44
|
+
op("layout.undo", "Undo last layout change.", "{}", "local-write"),
|
|
45
|
+
op("layout.redo", "Redo last layout change.", "{}", "local-write"),
|
|
46
|
+
op("layout.gridlock", "Gridlock all visible panes into a dense layout.", "{}", "local-write"),
|
|
47
|
+
op("layout.closeFloating", "Close all floating panes in the active layout.", "{}", "local-write"),
|
|
48
|
+
op("layout.placePane", "Move a pane to a layout region.", "{ paneId: string, region: 'left' | 'right' | 'top' | 'bottom' | 'floating', relativeTo?: string }", "local-write"),
|
|
49
|
+
op("layout.focusRegion", "Focus a pane by visual layout region.", "{ region: 'left' | 'right' | 'top' | 'bottom' | 'center' }", "local-write"),
|
|
50
|
+
op("layout.setGrid", "Dock visible or specified panes into a simple grid.", "{ paneIds?: string[], columns?: number }", "local-write"),
|
|
51
|
+
op("desktop.popOutPane", "Pop a pane into a detached desktop window.", "{ paneId: string }", "local-write"),
|
|
52
|
+
op("desktop.dockPane", "Dock a detached desktop pane.", "{ paneId: string }", "local-write"),
|
|
53
|
+
op("desktop.closeDetachedPane", "Close a detached desktop pane.", "{ paneId: string }", "local-write"),
|
|
54
|
+
op("desktop.focusDetachedPane", "Focus a detached desktop pane.", "{ paneId: string }", "local-write"),
|
|
55
|
+
op("capability.invoke", "Invoke a registered plugin capability operation.", "{ capabilityId: string, operationId: string, payload?: object }", "local-write"),
|
|
56
|
+
op("ui.invoke", "Invoke an action on a live semantic UI node.", "{ nodeId: string, action?: string, input?: any }", "local-write"),
|
|
57
|
+
op("ui.invokeMatching", "Invoke an action on the first semantic UI node matching role, label, index, or metadata.", "{ role?: string, label?: string, contains?: string, index?: number, action?: string, input?: any, metadata?: object }", "local-write"),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export const REMOTE_AGENT_HELP = {
|
|
61
|
+
title: "Gloomberb remote control guide",
|
|
62
|
+
quickStart: [
|
|
63
|
+
"Read app://snapshot once to orient; it includes schema, current layout, panes, command bar state, and semantic UI nodes.",
|
|
64
|
+
"Prefer app-level operations such as app.search, layout.setGrid, layout.closeFloating, and ticker.pin before falling back to ui.invoke.",
|
|
65
|
+
"Use commandBar.activateResult with label, itemId, or index after app.search; avoid raw node ids unless there is no stable semantic selector.",
|
|
66
|
+
"For list-like surfaces, prefer semantic list activation through commandBar.activateResult or ui.invokeMatching.",
|
|
67
|
+
"Use batch for multi-step flows; steps run sequentially and can return a compact final state so a separate read is not required.",
|
|
68
|
+
],
|
|
69
|
+
resources: [
|
|
70
|
+
{ uri: "app://command-bar", use: "Current command-bar query, open state, selected row, and semantic result rows." },
|
|
71
|
+
{ uri: "app://command-bar/results", use: "Just the visible command-bar result/list rows." },
|
|
72
|
+
{ uri: "ui://tree", use: "Low-level live semantic controls; use when no app-level operation exists." },
|
|
73
|
+
{ uri: "app://panes", use: "Pane instances, placement, focus, and runtime state." },
|
|
74
|
+
],
|
|
75
|
+
recipes: [
|
|
76
|
+
{
|
|
77
|
+
goal: "Search for a ticker and open the first result",
|
|
78
|
+
requests: [
|
|
79
|
+
{ type: "call", operation: "app.search", input: { mode: "ticker", query: "google" } },
|
|
80
|
+
{ type: "call", operation: "commandBar.activateResult", input: { index: 0 } },
|
|
81
|
+
],
|
|
82
|
+
batch: {
|
|
83
|
+
type: "batch",
|
|
84
|
+
include: ["commandBar", "panes"],
|
|
85
|
+
requests: [
|
|
86
|
+
{ type: "call", operation: "app.search", input: { mode: "ticker", query: "google" } },
|
|
87
|
+
{ type: "call", operation: "commandBar.activateResult", input: { index: 0 } },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
goal: "Run a command-bar command",
|
|
93
|
+
requests: [
|
|
94
|
+
{ type: "call", operation: "app.search", input: { mode: "command", query: "theme" } },
|
|
95
|
+
{ type: "call", operation: "commandBar.activateResult", input: { label: "Change Theme" } },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
goal: "Activate a visible semantic control without knowing its node id",
|
|
100
|
+
request: { type: "call", operation: "ui.invokeMatching", input: { role: "button", label: "Done", action: "press" } },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
goal: "Arrange current panes",
|
|
104
|
+
request: { type: "call", operation: "layout.setGrid", input: { columns: 2 }, include: ["layout", "panes"] },
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
goal: "Move a chart cursor without mouse/keyboard control",
|
|
108
|
+
request: { type: "call", operation: "ui.invokeMatching", input: { role: "chart", action: "moveCursor", input: { x: 20, y: 4 } } },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
goal: "Pan a chart through its semantic scroll action",
|
|
112
|
+
request: { type: "call", operation: "ui.invokeMatching", input: { role: "chart", action: "scroll", input: { direction: "down", delta: 3 } } },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
batching: {
|
|
116
|
+
requestShape: "{ type: 'batch', requests: RemoteControlRequest[], haltOnError?: boolean, settle?: 'none' | 'afterEach' | 'afterBatch', include?: RemoteStateInclude[] }",
|
|
117
|
+
behavior: "Requests run sequentially. By default the batch stops on the first failed step and returns a compact final state.",
|
|
118
|
+
},
|
|
119
|
+
caveats: [
|
|
120
|
+
"Capability manifests are for plugin services; UI control should rely on app-level operations and shared semantic UI nodes so plugins remain remote-agnostic.",
|
|
121
|
+
"If an operation changes UI, request include: ['commandBar'] or use batch include to avoid a follow-up get.",
|
|
122
|
+
"For charts, use visible semantic chart nodes with actions such as moveCursor, press, drag, release, and scroll.",
|
|
123
|
+
"Use ui.invokeMatching only after checking app-level operations; it is intentionally generic and depends on visible semantic controls.",
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function remoteControlSchema(): RemoteControlSchema {
|
|
128
|
+
return {
|
|
129
|
+
protocolVersion: 1,
|
|
130
|
+
resources: REMOTE_RESOURCES,
|
|
131
|
+
operations: REMOTE_OPERATIONS,
|
|
132
|
+
help: REMOTE_AGENT_HELP,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function op(
|
|
137
|
+
id: string,
|
|
138
|
+
description: string,
|
|
139
|
+
inputShape: string,
|
|
140
|
+
sideEffectLevel: RemoteOperationSchema["sideEffectLevel"],
|
|
141
|
+
): RemoteOperationSchema {
|
|
142
|
+
return { id, description, inputShape, sideEffectLevel, dryRun: sideEffectLevel !== "none" };
|
|
143
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export function remoteEvent(input?: unknown) {
|
|
2
|
+
return {
|
|
3
|
+
input,
|
|
4
|
+
preventDefault() {},
|
|
5
|
+
stopPropagation() {},
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function remoteStringValue(input: unknown): string;
|
|
10
|
+
export function remoteStringValue<T extends string | undefined>(input: unknown, fallback: T): string | T;
|
|
11
|
+
export function remoteStringValue(input: unknown, fallback?: string): string | undefined {
|
|
12
|
+
if (typeof input === "string") return input;
|
|
13
|
+
if (input && typeof input === "object" && typeof (input as { value?: unknown }).value === "string") {
|
|
14
|
+
return (input as { value: string }).value;
|
|
15
|
+
}
|
|
16
|
+
return arguments.length >= 2 ? fallback : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function remoteNumberValue(input: unknown, keys: string[], fallback = 0): number {
|
|
20
|
+
if (typeof input === "number") return input;
|
|
21
|
+
if (input && typeof input === "object") {
|
|
22
|
+
const record = input as Record<string, unknown>;
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
if (typeof record[key] === "number") return record[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function remoteOptionalNumberValue(input: unknown, keys: string[]): number | undefined {
|
|
31
|
+
if (typeof input === "number") return input;
|
|
32
|
+
if (input && typeof input === "object") {
|
|
33
|
+
const record = input as Record<string, unknown>;
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
if (typeof record[key] === "number") return record[key];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function remoteChartEvent(input: unknown, renderable: unknown, width?: number, height?: number) {
|
|
42
|
+
const record = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
|
43
|
+
const resolvedWidth = Math.max(1, width ?? remoteNumberProp(renderable, "width") ?? remoteNumberProp(remoteObjectProp(renderable, "absoluteBounds"), "width") ?? 1);
|
|
44
|
+
const resolvedHeight = Math.max(1, height ?? remoteNumberProp(renderable, "height") ?? remoteNumberProp(remoteObjectProp(renderable, "absoluteBounds"), "height") ?? 1);
|
|
45
|
+
const x = clampRemoteCoordinate(remoteOptionalNumberValue(input, ["x", "cellX", "column"]), resolvedWidth);
|
|
46
|
+
const y = clampRemoteCoordinate(remoteOptionalNumberValue(input, ["y", "cellY", "row"]), resolvedHeight);
|
|
47
|
+
const originX = remoteNumberProp(renderable, "absoluteX")
|
|
48
|
+
?? remoteNumberProp(remoteObjectProp(renderable, "absoluteBounds"), "x")
|
|
49
|
+
?? remoteNumberProp(renderable, "x")
|
|
50
|
+
?? 0;
|
|
51
|
+
const originY = remoteNumberProp(renderable, "absoluteY")
|
|
52
|
+
?? remoteNumberProp(remoteObjectProp(renderable, "absoluteBounds"), "y")
|
|
53
|
+
?? remoteNumberProp(renderable, "y")
|
|
54
|
+
?? 0;
|
|
55
|
+
return {
|
|
56
|
+
input,
|
|
57
|
+
x: originX + x,
|
|
58
|
+
y: originY + y,
|
|
59
|
+
preciseX: originX + x,
|
|
60
|
+
preciseY: originY + y,
|
|
61
|
+
pixelX: remoteOptionalNumberValue(input, ["pixelX"]),
|
|
62
|
+
pixelY: remoteOptionalNumberValue(input, ["pixelY"]),
|
|
63
|
+
button: typeof record.button === "number" ? record.button : 0,
|
|
64
|
+
modifiers: {
|
|
65
|
+
shift: record.shift === true,
|
|
66
|
+
alt: record.alt === true,
|
|
67
|
+
ctrl: record.ctrl === true,
|
|
68
|
+
},
|
|
69
|
+
scroll: remoteChartScroll(input),
|
|
70
|
+
preventDefault() {},
|
|
71
|
+
stopPropagation() {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function remotePropLabel(props: Record<string, unknown>): string | undefined {
|
|
76
|
+
for (const key of ["aria-label", "title", "label", "placeholder"]) {
|
|
77
|
+
const value = props[key];
|
|
78
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function remotePropRole(props: Record<string, unknown>, fallback: string): string {
|
|
84
|
+
const explicit = props["data-gloom-role"];
|
|
85
|
+
return typeof explicit === "string" && explicit.trim() ? explicit.trim() : fallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function remoteMetadataFromProps(props: Record<string, unknown>): Record<string, unknown> {
|
|
89
|
+
const metadata: Record<string, unknown> = {};
|
|
90
|
+
const scope = props["data-gloom-remote-scope"];
|
|
91
|
+
const surface = props["data-gloom-remote-surface"];
|
|
92
|
+
const kind = props["data-gloom-remote-kind"];
|
|
93
|
+
if (typeof scope === "string" && scope.trim()) metadata.scope = scope.trim();
|
|
94
|
+
if (typeof surface === "string" && surface.trim()) metadata.surface = surface.trim();
|
|
95
|
+
if (typeof kind === "string" && kind.trim()) metadata.kind = kind.trim();
|
|
96
|
+
return metadata;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type RemoteItemSelector<T> = (item: T, index: number) => string | undefined;
|
|
100
|
+
|
|
101
|
+
interface RemoteIndexSelectors<T> {
|
|
102
|
+
id?: RemoteItemSelector<T>;
|
|
103
|
+
key?: RemoteItemSelector<T>;
|
|
104
|
+
label?: RemoteItemSelector<T>;
|
|
105
|
+
value?: RemoteItemSelector<T>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveRemoteItemIndex<T>(
|
|
109
|
+
input: unknown,
|
|
110
|
+
items: readonly T[],
|
|
111
|
+
selectors: RemoteIndexSelectors<T>,
|
|
112
|
+
): number {
|
|
113
|
+
if (typeof input === "number") return input;
|
|
114
|
+
if (typeof input === "string") return findRemoteItemIndex(input, items, Object.values(selectors));
|
|
115
|
+
if (input && typeof input === "object") {
|
|
116
|
+
const record = input as Record<string, unknown>;
|
|
117
|
+
if (typeof record.index === "number") return record.index;
|
|
118
|
+
for (const key of ["id", "key", "label", "value"] as const) {
|
|
119
|
+
if (typeof record[key] !== "string") continue;
|
|
120
|
+
const selector = selectors[key];
|
|
121
|
+
if (!selector) continue;
|
|
122
|
+
return findRemoteItemIndex(record[key], items, [selector]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return -1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function remoteChartScroll(input: unknown) {
|
|
129
|
+
if (!input || typeof input !== "object") return undefined;
|
|
130
|
+
const record = input as Record<string, unknown>;
|
|
131
|
+
const direction = record.direction;
|
|
132
|
+
if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") return undefined;
|
|
133
|
+
return {
|
|
134
|
+
direction,
|
|
135
|
+
delta: typeof record.delta === "number" ? record.delta : 1,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function remoteObjectProp(value: unknown, key: string): unknown {
|
|
140
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function remoteNumberProp(value: unknown, key: string): number | undefined {
|
|
144
|
+
const prop = remoteObjectProp(value, key);
|
|
145
|
+
return typeof prop === "number" ? prop : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clampRemoteCoordinate(value: number | undefined, extent: number): number {
|
|
149
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return Math.max(0, (extent - 1) / 2);
|
|
150
|
+
return Math.max(0, Math.min(value, Math.max(extent - 1, 0)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function findRemoteItemIndex<T>(
|
|
154
|
+
value: string,
|
|
155
|
+
items: readonly T[],
|
|
156
|
+
selectors: Array<RemoteItemSelector<T> | undefined>,
|
|
157
|
+
): number {
|
|
158
|
+
const activeSelectors = selectors.filter((selector): selector is RemoteItemSelector<T> => !!selector);
|
|
159
|
+
if (activeSelectors.length === 0) return -1;
|
|
160
|
+
return items.findIndex((item, index) => activeSelectors.some((selector) => selector(item, index) === value));
|
|
161
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { RemoteUiNodeSnapshot } from "./types";
|
|
11
|
+
|
|
12
|
+
type RemoteUiAction = (input?: unknown) => unknown | Promise<unknown>;
|
|
13
|
+
|
|
14
|
+
export interface RemoteUiNodeRegistration {
|
|
15
|
+
role: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
actions?: Record<string, RemoteUiAction | undefined>;
|
|
19
|
+
metadata?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RegisteredRemoteUiNode extends RemoteUiNodeRegistration {
|
|
23
|
+
id: string;
|
|
24
|
+
actions: Record<string, RemoteUiAction>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RemoteUiRegistry {
|
|
28
|
+
register(id: string, registration: RemoteUiNodeRegistration): void;
|
|
29
|
+
unregister(id: string): void;
|
|
30
|
+
snapshot(): RemoteUiNodeSnapshot[];
|
|
31
|
+
invoke(nodeId: string, action: string, input?: unknown): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const RemoteUiRegistryContext = createContext<RemoteUiRegistry | null>(null);
|
|
35
|
+
|
|
36
|
+
function createRemoteUiRegistry(): RemoteUiRegistry {
|
|
37
|
+
const nodes = new Map<string, RegisteredRemoteUiNode>();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
register(id, registration) {
|
|
41
|
+
nodes.set(id, {
|
|
42
|
+
...registration,
|
|
43
|
+
id,
|
|
44
|
+
actions: Object.fromEntries(
|
|
45
|
+
Object.entries(registration.actions ?? {})
|
|
46
|
+
.filter((entry): entry is [string, RemoteUiAction] => typeof entry[1] === "function"),
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
unregister(id) {
|
|
51
|
+
nodes.delete(id);
|
|
52
|
+
},
|
|
53
|
+
snapshot() {
|
|
54
|
+
return [...nodes.values()].map((node) => ({
|
|
55
|
+
id: node.id,
|
|
56
|
+
role: node.role,
|
|
57
|
+
label: node.label,
|
|
58
|
+
disabled: node.disabled,
|
|
59
|
+
actions: Object.keys(node.actions).sort(),
|
|
60
|
+
metadata: node.metadata,
|
|
61
|
+
}));
|
|
62
|
+
},
|
|
63
|
+
async invoke(nodeId, action, input) {
|
|
64
|
+
const node = nodes.get(nodeId);
|
|
65
|
+
if (!node) throw new Error(`Unknown UI node "${nodeId}".`);
|
|
66
|
+
if (node.disabled) throw new Error(`UI node "${nodeId}" is disabled.`);
|
|
67
|
+
const handler = node.actions[action];
|
|
68
|
+
if (!handler) throw new Error(`UI node "${nodeId}" does not expose action "${action}".`);
|
|
69
|
+
return await handler(input);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function RemoteUiRegistryProvider({ children }: { children: ReactNode }) {
|
|
75
|
+
const registryRef = useRef<RemoteUiRegistry | null>(null);
|
|
76
|
+
if (!registryRef.current) {
|
|
77
|
+
registryRef.current = createRemoteUiRegistry();
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<RemoteUiRegistryContext value={registryRef.current}>
|
|
81
|
+
{children}
|
|
82
|
+
</RemoteUiRegistryContext>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useRemoteUiRegistry(): RemoteUiRegistry | null {
|
|
87
|
+
return useContext(RemoteUiRegistryContext);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useRemoteUiNode(registration: RemoteUiNodeRegistration | null | undefined): string | null {
|
|
91
|
+
const registry = useRemoteUiRegistry();
|
|
92
|
+
const generatedId = useId();
|
|
93
|
+
const nodeId = useMemo(() => `ui:${generatedId.replace(/:/g, "")}`, [generatedId]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!registry) return;
|
|
97
|
+
if (!registration) {
|
|
98
|
+
registry.unregister(nodeId);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
registry.register(nodeId, registration);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
return () => registry?.unregister(nodeId);
|
|
106
|
+
}, [
|
|
107
|
+
nodeId,
|
|
108
|
+
registry,
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
return registry && registration ? nodeId : null;
|
|
112
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { readRemoteEndpoint, remoteEndpointPath } from "./endpoint";
|
|
6
|
+
import { sendRemoteControlRequest } from "./client";
|
|
7
|
+
import { startRemoteControlServer, type RemoteControlServer } from "./server";
|
|
8
|
+
|
|
9
|
+
describe("remote control server", () => {
|
|
10
|
+
const tempDirs: string[] = [];
|
|
11
|
+
const servers: RemoteControlServer[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await Promise.all(servers.splice(0).map((server) => server.close().catch(() => {})));
|
|
15
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("writes an endpoint and serves authenticated RPC requests", async () => {
|
|
19
|
+
const dataDir = await mkdtemp(join(tmpdir(), "gloom-remote-"));
|
|
20
|
+
tempDirs.push(dataDir);
|
|
21
|
+
const server = await startRemoteControlServer({
|
|
22
|
+
dataDir,
|
|
23
|
+
appKind: "tui",
|
|
24
|
+
handle: async (request) => ({ ok: true, data: { type: request.type } }),
|
|
25
|
+
});
|
|
26
|
+
servers.push(server);
|
|
27
|
+
|
|
28
|
+
const defaultEndpoint = await readRemoteEndpoint(dataDir);
|
|
29
|
+
const tuiEndpoint = await readRemoteEndpoint(dataDir, "tui");
|
|
30
|
+
expect(defaultEndpoint.port).toBe(server.endpoint.port);
|
|
31
|
+
expect(tuiEndpoint.token).toBe(server.endpoint.token);
|
|
32
|
+
|
|
33
|
+
const response = await sendRemoteControlRequest({ type: "schema" }, { dataDir, appKind: "tui" });
|
|
34
|
+
expect(response).toEqual({ ok: true, data: { type: "schema" } });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("removes endpoint files on close", async () => {
|
|
38
|
+
const dataDir = await mkdtemp(join(tmpdir(), "gloom-remote-"));
|
|
39
|
+
tempDirs.push(dataDir);
|
|
40
|
+
const server = await startRemoteControlServer({
|
|
41
|
+
dataDir,
|
|
42
|
+
appKind: "desktop",
|
|
43
|
+
handle: async () => ({ ok: true, data: null }),
|
|
44
|
+
});
|
|
45
|
+
await server.close();
|
|
46
|
+
|
|
47
|
+
expect(await Bun.file(remoteEndpointPath(dataDir)).exists()).toBe(false);
|
|
48
|
+
expect(await Bun.file(remoteEndpointPath(dataDir, "desktop")).exists()).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|