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,434 @@
|
|
|
1
|
+
import type { Dispatch } from "react";
|
|
2
|
+
import {
|
|
3
|
+
dockPane,
|
|
4
|
+
floatPane,
|
|
5
|
+
getDockLeafLayouts,
|
|
6
|
+
gridlockAllPanes,
|
|
7
|
+
insertAtRootEdge,
|
|
8
|
+
removeFloatingPanes,
|
|
9
|
+
} from "../plugins/pane-manager";
|
|
10
|
+
import type { PluginRegistry } from "../plugins/registry";
|
|
11
|
+
import type { AppAction, AppState } from "../state/app/context";
|
|
12
|
+
import { setPaneSettings } from "../pane-settings";
|
|
13
|
+
import type { DesktopWindowBridge } from "../types/desktop-window";
|
|
14
|
+
import { applyJsonPatch } from "./json-patch";
|
|
15
|
+
import { revisionFor } from "./revision";
|
|
16
|
+
import type {
|
|
17
|
+
RemoteControlRequest,
|
|
18
|
+
RemoteControlResponse,
|
|
19
|
+
RemoteJsonPatchOperation,
|
|
20
|
+
RemoteStateInclude,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import type { RemoteUiRegistry } from "./semantic-tree";
|
|
23
|
+
import { commandBarResultsFromNodes, isCommandBarInputNode } from "./command-bar";
|
|
24
|
+
import { REMOTE_AGENT_HELP, remoteControlSchema } from "./schema";
|
|
25
|
+
import {
|
|
26
|
+
asRecord,
|
|
27
|
+
fail,
|
|
28
|
+
mutationSummary,
|
|
29
|
+
numberInput,
|
|
30
|
+
ok,
|
|
31
|
+
optionalNumber,
|
|
32
|
+
optionalString,
|
|
33
|
+
stringInput,
|
|
34
|
+
} from "./controller-utils";
|
|
35
|
+
import {
|
|
36
|
+
buildGridDockRoot,
|
|
37
|
+
regionToDockPosition,
|
|
38
|
+
regionToRootEdge,
|
|
39
|
+
requirePaneInstance,
|
|
40
|
+
visiblePaneIds,
|
|
41
|
+
} from "./layout-helpers";
|
|
42
|
+
import { createRemoteResources } from "./resources";
|
|
43
|
+
|
|
44
|
+
interface AppRemoteControllerOptions {
|
|
45
|
+
dispatch: Dispatch<AppAction>;
|
|
46
|
+
getState: () => AppState;
|
|
47
|
+
pluginRegistry: PluginRegistry;
|
|
48
|
+
uiRegistry: RemoteUiRegistry | null;
|
|
49
|
+
desktopWindowBridge?: DesktopWindowBridge;
|
|
50
|
+
afterMutation?: () => Promise<void> | void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MUTATION_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
|
|
54
|
+
const DEFAULT_BATCH_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
|
|
55
|
+
|
|
56
|
+
export function createAppRemoteController({
|
|
57
|
+
dispatch,
|
|
58
|
+
getState,
|
|
59
|
+
pluginRegistry,
|
|
60
|
+
uiRegistry,
|
|
61
|
+
desktopWindowBridge,
|
|
62
|
+
afterMutation = () => {},
|
|
63
|
+
}: AppRemoteControllerOptions) {
|
|
64
|
+
const { buildIncludedState, getResource, patchTarget } = createRemoteResources({
|
|
65
|
+
dispatch,
|
|
66
|
+
getState,
|
|
67
|
+
pluginRegistry,
|
|
68
|
+
uiRegistry,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const getAfterMutation = async (resource: string): Promise<unknown> => {
|
|
72
|
+
await afterMutation();
|
|
73
|
+
return getResource(resource);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const getAfterMutationSummary = async (extra?: Record<string, unknown>): Promise<unknown> => {
|
|
77
|
+
await afterMutation();
|
|
78
|
+
return mutationSummary(getState(), extra);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const openCommandBar = async (input: Record<string, unknown>): Promise<unknown> => {
|
|
82
|
+
const mode = optionalString(input, "mode") ?? "command";
|
|
83
|
+
const query = optionalString(input, "query") ?? "";
|
|
84
|
+
if (getState().commandBarOpen) {
|
|
85
|
+
dispatch({ type: "SET_COMMAND_BAR", open: false });
|
|
86
|
+
await afterMutation();
|
|
87
|
+
}
|
|
88
|
+
if (mode === "ticker") {
|
|
89
|
+
dispatch({ type: "SET_COMMAND_BAR", open: true, launch: { kind: "ticker-search", query } });
|
|
90
|
+
await afterMutation();
|
|
91
|
+
return mutationSummary(getState(), { commandBar: getResource("app://command-bar") });
|
|
92
|
+
}
|
|
93
|
+
if (mode !== "command" && mode !== "default") {
|
|
94
|
+
throw new Error(`Unsupported command-bar mode "${mode}".`);
|
|
95
|
+
}
|
|
96
|
+
dispatch({ type: "SET_COMMAND_BAR", open: true, query });
|
|
97
|
+
await afterMutation();
|
|
98
|
+
return mutationSummary(getState(), { commandBar: getResource("app://command-bar") });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const setVisibleCommandBarQuery = async (query: string): Promise<void> => {
|
|
102
|
+
dispatch({ type: "SET_COMMAND_BAR_QUERY", query });
|
|
103
|
+
await afterMutation();
|
|
104
|
+
const inputNode = (uiRegistry?.snapshot() ?? [])
|
|
105
|
+
.find((node) => isCommandBarInputNode(node) && node.metadata?.focused === true && node.actions.includes("setValue"));
|
|
106
|
+
if (!inputNode) return;
|
|
107
|
+
if (inputNode.metadata?.value === query) return;
|
|
108
|
+
await uiRegistry?.invoke(inputNode.id, "setValue", { value: query });
|
|
109
|
+
await afterMutation();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const activateCommandBarResult = async (input: Record<string, unknown>): Promise<unknown> => {
|
|
113
|
+
const nodeId = optionalString(input, "nodeId");
|
|
114
|
+
const index = optionalNumber(input, "index");
|
|
115
|
+
const itemId = optionalString(input, "itemId");
|
|
116
|
+
const label = optionalString(input, "label");
|
|
117
|
+
const results = commandBarResultsFromNodes(uiRegistry?.snapshot() ?? []);
|
|
118
|
+
const result = nodeId
|
|
119
|
+
? results.find((entry) => entry.nodeId === nodeId)
|
|
120
|
+
: itemId
|
|
121
|
+
? results.find((entry) => entry.itemId === itemId)
|
|
122
|
+
: label
|
|
123
|
+
? results.find((entry) => entry.label === label)
|
|
124
|
+
: typeof index === "number"
|
|
125
|
+
? results.find((entry) => entry.index === index)
|
|
126
|
+
: results.find((entry) => entry.selected) ?? results[0];
|
|
127
|
+
if (!result) throw new Error("No matching command-bar result is visible.");
|
|
128
|
+
if (!result.actions.includes("activate")) {
|
|
129
|
+
throw new Error(`Command-bar result "${result.nodeId}" does not expose activate.`);
|
|
130
|
+
}
|
|
131
|
+
await uiRegistry?.invoke(result.nodeId, "activate", result.actionInput);
|
|
132
|
+
return getAfterMutationSummary({ activatedResult: result });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const invokeMatchingUiNode = async (input: Record<string, unknown>): Promise<unknown> => {
|
|
136
|
+
const role = optionalString(input, "role");
|
|
137
|
+
const label = optionalString(input, "label");
|
|
138
|
+
const contains = optionalString(input, "contains");
|
|
139
|
+
const index = optionalNumber(input, "index");
|
|
140
|
+
const action = optionalString(input, "action") ?? "press";
|
|
141
|
+
const metadataFilter = asRecord(input.metadata);
|
|
142
|
+
const candidates = (uiRegistry?.snapshot() ?? []).filter((node) => {
|
|
143
|
+
if (role && node.role !== role) return false;
|
|
144
|
+
if (label && node.label !== label && node.metadata?.item && typeof node.metadata.item === "object") {
|
|
145
|
+
const item = node.metadata.item as Record<string, unknown>;
|
|
146
|
+
if (item.label !== label && item.id !== label) return false;
|
|
147
|
+
} else if (label && node.label !== label) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (contains) {
|
|
151
|
+
const haystack = [
|
|
152
|
+
node.label,
|
|
153
|
+
node.role,
|
|
154
|
+
JSON.stringify(node.metadata ?? {}),
|
|
155
|
+
].filter((entry): entry is string => typeof entry === "string").join(" ").toLowerCase();
|
|
156
|
+
if (!haystack.includes(contains.toLowerCase())) return false;
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of Object.entries(metadataFilter)) {
|
|
159
|
+
if (node.metadata?.[key] !== value) return false;
|
|
160
|
+
}
|
|
161
|
+
if (!node.actions.includes(action)) return false;
|
|
162
|
+
if (node.disabled) return false;
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
const node = typeof index === "number" ? candidates[index] : candidates[0];
|
|
166
|
+
if (!node) throw new Error("No matching semantic UI node is visible.");
|
|
167
|
+
const result = await uiRegistry?.invoke(node.id, action, input.input);
|
|
168
|
+
return getAfterMutationSummary({ invokedNode: node, result });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const call = async (operation: string, rawInput: unknown, dryRun?: boolean): Promise<unknown> => {
|
|
172
|
+
const input = asRecord(rawInput);
|
|
173
|
+
const dryRunResult = () => ({ operation, input, dryRun: true });
|
|
174
|
+
if (dryRun) return dryRunResult();
|
|
175
|
+
|
|
176
|
+
switch (operation) {
|
|
177
|
+
case "app.openCommandBar":
|
|
178
|
+
return openCommandBar(input);
|
|
179
|
+
case "app.closeCommandBar":
|
|
180
|
+
dispatch({ type: "SET_COMMAND_BAR", open: false });
|
|
181
|
+
return getAfterMutationSummary();
|
|
182
|
+
case "app.setCommandBarQuery":
|
|
183
|
+
await setVisibleCommandBarQuery(stringInput(input, "query"));
|
|
184
|
+
return mutationSummary(getState(), { commandBar: getResource("app://command-bar") });
|
|
185
|
+
case "app.search":
|
|
186
|
+
return openCommandBar(input);
|
|
187
|
+
case "app.switchPanel":
|
|
188
|
+
dispatch({ type: "SET_ACTIVE_PANEL", panel: input.panel === "right" ? "right" : "left" });
|
|
189
|
+
return getAfterMutationSummary();
|
|
190
|
+
case "app.notify":
|
|
191
|
+
pluginRegistry.notify({ body: stringInput(input, "body"), type: input.type as never });
|
|
192
|
+
return null;
|
|
193
|
+
case "commandBar.activateResult":
|
|
194
|
+
return activateCommandBarResult(input);
|
|
195
|
+
case "pane.show":
|
|
196
|
+
pluginRegistry.showPane(stringInput(input, "paneId"));
|
|
197
|
+
return getAfterMutationSummary();
|
|
198
|
+
case "pane.focus":
|
|
199
|
+
pluginRegistry.focusPane(stringInput(input, "paneId"));
|
|
200
|
+
return getAfterMutationSummary();
|
|
201
|
+
case "pane.close": {
|
|
202
|
+
const paneId = stringInput(input, "paneId");
|
|
203
|
+
pluginRegistry.hidePane(paneId);
|
|
204
|
+
return getAfterMutationSummary({ affectedPaneIds: [paneId] });
|
|
205
|
+
}
|
|
206
|
+
case "pane.createFromTemplate":
|
|
207
|
+
await pluginRegistry.createPaneFromTemplateAsyncFn(
|
|
208
|
+
stringInput(input, "templateId"),
|
|
209
|
+
asRecord(input.options),
|
|
210
|
+
);
|
|
211
|
+
return getAfterMutationSummary();
|
|
212
|
+
case "pane.setState":
|
|
213
|
+
dispatch({
|
|
214
|
+
type: "UPDATE_PANE_STATE",
|
|
215
|
+
paneId: stringInput(input, "paneId"),
|
|
216
|
+
patch: asRecord(input.patch),
|
|
217
|
+
});
|
|
218
|
+
return getAfterMutationSummary({ affectedPaneIds: [stringInput(input, "paneId")] });
|
|
219
|
+
case "pane.setSetting": {
|
|
220
|
+
const paneId = stringInput(input, "paneId");
|
|
221
|
+
const key = stringInput(input, "key");
|
|
222
|
+
const descriptor = pluginRegistry.resolvePaneSettings(paneId);
|
|
223
|
+
const field = descriptor?.settingsDef.fields.find((entry) => entry.key === key);
|
|
224
|
+
if (field) {
|
|
225
|
+
await pluginRegistry.applyPaneSettingValueFn(descriptor!.paneId, field, input.value);
|
|
226
|
+
} else {
|
|
227
|
+
const instanceId = descriptor?.paneId ?? paneId;
|
|
228
|
+
const current = pluginRegistry.resolvePaneSettings(instanceId)?.context.settings ?? {};
|
|
229
|
+
pluginRegistry.updateLayoutFn(setPaneSettings(getState().config.layout, instanceId, {
|
|
230
|
+
...current,
|
|
231
|
+
[key]: input.value,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
return getAfterMutationSummary({ affectedPaneIds: [paneId] });
|
|
235
|
+
}
|
|
236
|
+
case "ticker.navigate":
|
|
237
|
+
pluginRegistry.navigateTicker(stringInput(input, "symbol"), { sourcePaneId: optionalString(input, "sourcePaneId") });
|
|
238
|
+
return getAfterMutationSummary({ symbol: stringInput(input, "symbol") });
|
|
239
|
+
case "ticker.pin":
|
|
240
|
+
pluginRegistry.pinTicker(stringInput(input, "symbol"), {
|
|
241
|
+
floating: input.floating === true,
|
|
242
|
+
forceNewPane: input.forceNewPane === true,
|
|
243
|
+
paneType: optionalString(input, "paneType"),
|
|
244
|
+
});
|
|
245
|
+
return getAfterMutationSummary({ symbol: stringInput(input, "symbol") });
|
|
246
|
+
case "ticker.select":
|
|
247
|
+
pluginRegistry.selectTicker(stringInput(input, "symbol"), optionalString(input, "paneId"));
|
|
248
|
+
return getAfterMutationSummary({ symbol: stringInput(input, "symbol") });
|
|
249
|
+
case "ticker.switchTab":
|
|
250
|
+
pluginRegistry.switchTab(stringInput(input, "tabId"), optionalString(input, "paneId"));
|
|
251
|
+
return getAfterMutationSummary({ tabId: stringInput(input, "tabId") });
|
|
252
|
+
case "layout.switch":
|
|
253
|
+
dispatch({ type: "SWITCH_LAYOUT", index: numberInput(input, "index") });
|
|
254
|
+
return getAfterMutationSummary();
|
|
255
|
+
case "layout.new":
|
|
256
|
+
dispatch({ type: "NEW_LAYOUT", name: stringInput(input, "name") });
|
|
257
|
+
return getAfterMutationSummary();
|
|
258
|
+
case "layout.rename":
|
|
259
|
+
dispatch({ type: "RENAME_LAYOUT", index: numberInput(input, "index"), name: stringInput(input, "name") });
|
|
260
|
+
return getAfterMutationSummary();
|
|
261
|
+
case "layout.duplicate":
|
|
262
|
+
dispatch({ type: "DUPLICATE_LAYOUT", index: numberInput(input, "index") });
|
|
263
|
+
return getAfterMutationSummary();
|
|
264
|
+
case "layout.delete":
|
|
265
|
+
dispatch({ type: "DELETE_LAYOUT", index: numberInput(input, "index") });
|
|
266
|
+
return getAfterMutationSummary();
|
|
267
|
+
case "layout.undo":
|
|
268
|
+
dispatch({ type: "UNDO_LAYOUT" });
|
|
269
|
+
return getAfterMutationSummary();
|
|
270
|
+
case "layout.redo":
|
|
271
|
+
dispatch({ type: "REDO_LAYOUT" });
|
|
272
|
+
return getAfterMutationSummary();
|
|
273
|
+
case "layout.gridlock": {
|
|
274
|
+
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
275
|
+
pluginRegistry.updateLayoutFn(gridlockAllPanes(
|
|
276
|
+
getState().config.layout,
|
|
277
|
+
{ x: 0, y: 0, width, height },
|
|
278
|
+
pluginRegistry.panes,
|
|
279
|
+
));
|
|
280
|
+
return getAfterMutationSummary();
|
|
281
|
+
}
|
|
282
|
+
case "layout.closeFloating": {
|
|
283
|
+
const floatingPaneIds = getState().config.layout.floating.map((entry) => entry.instanceId);
|
|
284
|
+
pluginRegistry.updateLayoutFn(removeFloatingPanes(getState().config.layout));
|
|
285
|
+
return getAfterMutationSummary({ affectedPaneIds: floatingPaneIds });
|
|
286
|
+
}
|
|
287
|
+
case "layout.placePane": {
|
|
288
|
+
const pane = requirePaneInstance(getState().config.layout, stringInput(input, "paneId"));
|
|
289
|
+
const region = stringInput(input, "region");
|
|
290
|
+
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
291
|
+
const def = pluginRegistry.panes.get(pane.paneId);
|
|
292
|
+
const nextLayout = region === "floating"
|
|
293
|
+
? floatPane(getState().config.layout, pane.instanceId, width, height, def)
|
|
294
|
+
: optionalString(input, "relativeTo")
|
|
295
|
+
? dockPane(getState().config.layout, pane.instanceId, {
|
|
296
|
+
relativeTo: requirePaneInstance(getState().config.layout, optionalString(input, "relativeTo")!).instanceId,
|
|
297
|
+
position: regionToDockPosition(region),
|
|
298
|
+
})
|
|
299
|
+
: insertAtRootEdge(getState().config.layout, pane.instanceId, regionToRootEdge(region));
|
|
300
|
+
pluginRegistry.updateLayoutFn(nextLayout);
|
|
301
|
+
return getAfterMutationSummary({ affectedPaneIds: [pane.instanceId] });
|
|
302
|
+
}
|
|
303
|
+
case "layout.focusRegion": {
|
|
304
|
+
const region = stringInput(input, "region");
|
|
305
|
+
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
306
|
+
const leaves = getDockLeafLayouts(getState().config.layout, { x: 0, y: 0, width, height });
|
|
307
|
+
if (leaves.length === 0) throw new Error("No docked panes are visible.");
|
|
308
|
+
const target = leaves
|
|
309
|
+
.map((leaf) => {
|
|
310
|
+
const centerX = leaf.rect.x + leaf.rect.width / 2;
|
|
311
|
+
const centerY = leaf.rect.y + leaf.rect.height / 2;
|
|
312
|
+
const score = region === "left" ? centerX
|
|
313
|
+
: region === "right" ? -centerX
|
|
314
|
+
: region === "top" ? centerY
|
|
315
|
+
: region === "bottom" ? -centerY
|
|
316
|
+
: Math.abs(centerX - width / 2) + Math.abs(centerY - height / 2);
|
|
317
|
+
return { leaf, score };
|
|
318
|
+
})
|
|
319
|
+
.sort((a, b) => a.score - b.score)[0]!.leaf;
|
|
320
|
+
dispatch({ type: "FOCUS_PANE", paneId: target.instanceId });
|
|
321
|
+
return getAfterMutationSummary({ affectedPaneIds: [target.instanceId] });
|
|
322
|
+
}
|
|
323
|
+
case "layout.setGrid": {
|
|
324
|
+
const rawPaneIds = Array.isArray(input.paneIds) ? input.paneIds : visiblePaneIds(getState().config.layout);
|
|
325
|
+
const paneIds = rawPaneIds.map((id) => requirePaneInstance(getState().config.layout, String(id)).instanceId);
|
|
326
|
+
const nextLayout = {
|
|
327
|
+
...getState().config.layout,
|
|
328
|
+
dockRoot: buildGridDockRoot(paneIds, optionalNumber(input, "columns")),
|
|
329
|
+
floating: getState().config.layout.floating.filter((entry) => !paneIds.includes(entry.instanceId)),
|
|
330
|
+
detached: (getState().config.layout.detached ?? []).filter((entry) => !paneIds.includes(entry.instanceId)),
|
|
331
|
+
};
|
|
332
|
+
pluginRegistry.updateLayoutFn(nextLayout);
|
|
333
|
+
return getAfterMutationSummary({ affectedPaneIds: paneIds });
|
|
334
|
+
}
|
|
335
|
+
case "desktop.popOutPane":
|
|
336
|
+
await desktopWindowBridge?.popOutPane?.(stringInput(input, "paneId"));
|
|
337
|
+
return getAfterMutationSummary({ affectedPaneIds: [stringInput(input, "paneId")] });
|
|
338
|
+
case "desktop.dockPane":
|
|
339
|
+
await desktopWindowBridge?.dockDetachedPane?.(stringInput(input, "paneId"));
|
|
340
|
+
return getAfterMutationSummary({ affectedPaneIds: [stringInput(input, "paneId")] });
|
|
341
|
+
case "desktop.closeDetachedPane":
|
|
342
|
+
await desktopWindowBridge?.closeDetachedPane?.(stringInput(input, "paneId"));
|
|
343
|
+
return getAfterMutationSummary({ affectedPaneIds: [stringInput(input, "paneId")] });
|
|
344
|
+
case "desktop.focusDetachedPane":
|
|
345
|
+
await desktopWindowBridge?.focusDetachedPane?.(stringInput(input, "paneId"));
|
|
346
|
+
return getAfterMutationSummary({ affectedPaneIds: [stringInput(input, "paneId")] });
|
|
347
|
+
case "capability.invoke":
|
|
348
|
+
return await pluginRegistry.capabilities.invoke(
|
|
349
|
+
stringInput(input, "capabilityId"),
|
|
350
|
+
stringInput(input, "operationId"),
|
|
351
|
+
input.payload ?? {},
|
|
352
|
+
);
|
|
353
|
+
case "ui.invoke": {
|
|
354
|
+
const result = await uiRegistry?.invoke(
|
|
355
|
+
stringInput(input, "nodeId"),
|
|
356
|
+
optionalString(input, "action") ?? "press",
|
|
357
|
+
input.input,
|
|
358
|
+
);
|
|
359
|
+
return getAfterMutationSummary({ result });
|
|
360
|
+
}
|
|
361
|
+
case "ui.invokeMatching":
|
|
362
|
+
return invokeMatchingUiNode(input);
|
|
363
|
+
default:
|
|
364
|
+
throw new Error(`Unknown remote operation "${operation}".`);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const handle = async (request: RemoteControlRequest): Promise<RemoteControlResponse> => {
|
|
369
|
+
try {
|
|
370
|
+
switch (request.type) {
|
|
371
|
+
case "help":
|
|
372
|
+
return ok(REMOTE_AGENT_HELP, revisionFor(REMOTE_AGENT_HELP));
|
|
373
|
+
case "schema":
|
|
374
|
+
return ok(remoteControlSchema());
|
|
375
|
+
case "get": {
|
|
376
|
+
const data = getResource(request.resource);
|
|
377
|
+
return ok(data, revisionFor(data), buildIncludedState(request.include));
|
|
378
|
+
}
|
|
379
|
+
case "call": {
|
|
380
|
+
const data = await call(request.operation, request.input, request.dryRun);
|
|
381
|
+
return ok(data, undefined, buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_MUTATION_INCLUDE));
|
|
382
|
+
}
|
|
383
|
+
case "patch": {
|
|
384
|
+
const target = patchTarget(request.resource);
|
|
385
|
+
const currentRev = revisionFor(target.value);
|
|
386
|
+
if (request.expectRev && request.expectRev !== currentRev) {
|
|
387
|
+
throw new Error(`Revision mismatch for ${request.resource}: expected ${request.expectRev}, got ${currentRev}.`);
|
|
388
|
+
}
|
|
389
|
+
const nextValue = applyJsonPatch(target.value, request.patch as RemoteJsonPatchOperation[]);
|
|
390
|
+
if (!request.dryRun) {
|
|
391
|
+
await target.apply(nextValue);
|
|
392
|
+
await afterMutation();
|
|
393
|
+
}
|
|
394
|
+
return ok(
|
|
395
|
+
nextValue,
|
|
396
|
+
revisionFor(nextValue),
|
|
397
|
+
buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_MUTATION_INCLUDE),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
case "batch": {
|
|
401
|
+
const responses = [];
|
|
402
|
+
const haltOnError = request.haltOnError !== false;
|
|
403
|
+
let haltedAt: number | null = null;
|
|
404
|
+
for (const entry of request.requests) {
|
|
405
|
+
const entryRequest = request.dryRun && (entry.type === "call" || entry.type === "patch")
|
|
406
|
+
? { ...entry, dryRun: true, include: entry.include ?? [] } as RemoteControlRequest
|
|
407
|
+
: (entry.type === "call" || entry.type === "patch")
|
|
408
|
+
? { ...entry, include: entry.include ?? [] } as RemoteControlRequest
|
|
409
|
+
: entry;
|
|
410
|
+
const response = await handle(entryRequest);
|
|
411
|
+
responses.push(response);
|
|
412
|
+
if (request.settle === "afterEach") await afterMutation();
|
|
413
|
+
if (!response.ok && haltOnError) {
|
|
414
|
+
haltedAt = responses.length - 1;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (request.settle === "afterBatch") await afterMutation();
|
|
419
|
+
return ok({
|
|
420
|
+
ok: responses.every((response) => response.ok),
|
|
421
|
+
haltedAt,
|
|
422
|
+
responses,
|
|
423
|
+
}, undefined, buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_BATCH_INCLUDE));
|
|
424
|
+
}
|
|
425
|
+
default:
|
|
426
|
+
throw new Error(`Unknown remote request type: ${(request as { type?: unknown }).type}`);
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return fail("remote_error", error);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return { handle };
|
|
434
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile, rm, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import type { RemoteAppKind, RemoteEndpoint } from "./types";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_ENDPOINT_FILE = "remote-control.json";
|
|
6
|
+
|
|
7
|
+
export function remoteEndpointPath(dataDir: string, appKind?: RemoteAppKind): string {
|
|
8
|
+
return join(dataDir, appKind ? `remote-control.${appKind}.json` : DEFAULT_ENDPOINT_FILE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readRemoteEndpoint(dataDir: string, appKind?: RemoteAppKind): Promise<RemoteEndpoint> {
|
|
12
|
+
const raw = await readFile(remoteEndpointPath(dataDir, appKind), "utf-8");
|
|
13
|
+
return JSON.parse(raw) as RemoteEndpoint;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeRemoteEndpointFiles(dataDir: string, endpoint: RemoteEndpoint): Promise<void> {
|
|
17
|
+
await writeRemoteEndpoint(remoteEndpointPath(dataDir, endpoint.appKind), endpoint);
|
|
18
|
+
await writeRemoteEndpoint(remoteEndpointPath(dataDir), endpoint);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function removeRemoteEndpointFiles(dataDir: string, endpoint: RemoteEndpoint): Promise<void> {
|
|
22
|
+
await Promise.all([
|
|
23
|
+
removeRemoteEndpointIfOwned(remoteEndpointPath(dataDir, endpoint.appKind), endpoint),
|
|
24
|
+
removeRemoteEndpointIfOwned(remoteEndpointPath(dataDir), endpoint),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeRemoteEndpoint(filePath: string, endpoint: RemoteEndpoint): Promise<void> {
|
|
29
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
30
|
+
await writeFile(filePath, JSON.stringify(endpoint, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function removeRemoteEndpointIfOwned(filePath: string, endpoint: RemoteEndpoint): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const existing = JSON.parse(await readFile(filePath, "utf-8")) as RemoteEndpoint;
|
|
36
|
+
if (existing.pid !== endpoint.pid || existing.token !== endpoint.token || existing.port !== endpoint.port) return;
|
|
37
|
+
await rm(filePath, { force: true });
|
|
38
|
+
} catch {
|
|
39
|
+
await rm(filePath, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyJsonPatch } from "./json-patch";
|
|
3
|
+
|
|
4
|
+
describe("applyJsonPatch", () => {
|
|
5
|
+
test("adds, replaces, and removes nested values without mutating the source", () => {
|
|
6
|
+
const source: { panes: Array<{ id: string; state: Record<string, unknown> }> } = {
|
|
7
|
+
panes: [
|
|
8
|
+
{ id: "one", state: { symbol: "NVDA", stale: true } },
|
|
9
|
+
],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const patched = applyJsonPatch(source, [
|
|
13
|
+
{ op: "replace", path: "/panes/0/state/symbol", value: "AAPL" },
|
|
14
|
+
{ op: "remove", path: "/panes/0/state/stale" },
|
|
15
|
+
{ op: "add", path: "/panes/0/state/tab", value: "chart" },
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
expect(patched).toEqual({
|
|
19
|
+
panes: [
|
|
20
|
+
{ id: "one", state: { symbol: "AAPL", tab: "chart" } },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
expect(source.panes[0]!.state).toEqual({ symbol: "NVDA", stale: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("appends to arrays with dash pointer", () => {
|
|
27
|
+
expect(applyJsonPatch({ values: ["a"] }, [
|
|
28
|
+
{ op: "add", path: "/values/-", value: "b" },
|
|
29
|
+
])).toEqual({ values: ["a", "b"] });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RemoteJsonPatchOperation } from "./types";
|
|
2
|
+
|
|
3
|
+
function decodePointerSegment(segment: string): string {
|
|
4
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function pointerSegments(path: string): string[] {
|
|
8
|
+
if (path === "") return [];
|
|
9
|
+
if (!path.startsWith("/")) {
|
|
10
|
+
throw new Error(`JSON patch path must start with "/": ${path}`);
|
|
11
|
+
}
|
|
12
|
+
return path.slice(1).split("/").map(decodePointerSegment);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cloneJson<T>(value: T): T {
|
|
16
|
+
return value == null ? value : JSON.parse(JSON.stringify(value)) as T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function containerFor(root: unknown, path: string): { parent: unknown; key: string } {
|
|
20
|
+
const segments = pointerSegments(path);
|
|
21
|
+
if (segments.length === 0) {
|
|
22
|
+
throw new Error("Patch path cannot target the document root.");
|
|
23
|
+
}
|
|
24
|
+
let parent = root;
|
|
25
|
+
for (const segment of segments.slice(0, -1)) {
|
|
26
|
+
if (Array.isArray(parent)) {
|
|
27
|
+
const index = Number(segment);
|
|
28
|
+
if (!Number.isInteger(index) || index < 0 || index >= parent.length) {
|
|
29
|
+
throw new Error(`Invalid array index in JSON patch path: ${segment}`);
|
|
30
|
+
}
|
|
31
|
+
parent = parent[index];
|
|
32
|
+
} else if (parent && typeof parent === "object") {
|
|
33
|
+
parent = (parent as Record<string, unknown>)[segment];
|
|
34
|
+
} else {
|
|
35
|
+
throw new Error(`Cannot descend into non-object patch path: ${path}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { parent, key: segments[segments.length - 1]! };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function applyOperation(root: unknown, operation: RemoteJsonPatchOperation): void {
|
|
42
|
+
const { parent, key } = containerFor(root, operation.path);
|
|
43
|
+
if (Array.isArray(parent)) {
|
|
44
|
+
const index = key === "-" ? parent.length : Number(key);
|
|
45
|
+
if (!Number.isInteger(index) || index < 0 || index > parent.length) {
|
|
46
|
+
throw new Error(`Invalid array index in JSON patch path: ${key}`);
|
|
47
|
+
}
|
|
48
|
+
if (operation.op === "remove") {
|
|
49
|
+
if (index >= parent.length) throw new Error(`Cannot remove missing array index: ${key}`);
|
|
50
|
+
parent.splice(index, 1);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (operation.op === "add") {
|
|
54
|
+
parent.splice(index, 0, operation.value);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (index >= parent.length) throw new Error(`Cannot replace missing array index: ${key}`);
|
|
58
|
+
parent[index] = operation.value;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!parent || typeof parent !== "object") {
|
|
63
|
+
throw new Error(`Cannot patch non-object at path: ${operation.path}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const target = parent as Record<string, unknown>;
|
|
67
|
+
if (operation.op === "remove") {
|
|
68
|
+
if (!Object.prototype.hasOwnProperty.call(target, key)) {
|
|
69
|
+
throw new Error(`Cannot remove missing key: ${key}`);
|
|
70
|
+
}
|
|
71
|
+
delete target[key];
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (operation.op === "replace" && !Object.prototype.hasOwnProperty.call(target, key)) {
|
|
75
|
+
throw new Error(`Cannot replace missing key: ${key}`);
|
|
76
|
+
}
|
|
77
|
+
target[key] = operation.value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyJsonPatch<T>(value: T, patch: RemoteJsonPatchOperation[]): T {
|
|
81
|
+
const next = cloneJson(value);
|
|
82
|
+
for (const operation of patch) {
|
|
83
|
+
if (operation.op !== "add" && operation.op !== "replace" && operation.op !== "remove") {
|
|
84
|
+
throw new Error(`Unsupported JSON patch operation: ${(operation as { op?: unknown }).op}`);
|
|
85
|
+
}
|
|
86
|
+
applyOperation(next, operation);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDockedPaneIds,
|
|
3
|
+
} from "../plugins/pane-manager";
|
|
4
|
+
import type { DockLayoutNode, LayoutConfig, PaneInstanceConfig } from "../types/config";
|
|
5
|
+
|
|
6
|
+
export function requirePaneInstance(layout: LayoutConfig, paneId: string): PaneInstanceConfig {
|
|
7
|
+
const instance = layout.instances.find((entry) => entry.instanceId === paneId)
|
|
8
|
+
?? layout.instances.find((entry) => entry.paneId === paneId);
|
|
9
|
+
if (!instance) throw new Error(`Unknown pane "${paneId}".`);
|
|
10
|
+
return instance;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildGridDockRoot(paneIds: string[], columns?: number): DockLayoutNode | null {
|
|
14
|
+
if (paneIds.length === 0) return null;
|
|
15
|
+
const columnCount = Math.max(1, Math.min(
|
|
16
|
+
paneIds.length,
|
|
17
|
+
Number.isInteger(columns) && columns! > 0 ? columns! : Math.ceil(Math.sqrt(paneIds.length)),
|
|
18
|
+
));
|
|
19
|
+
const rows: DockLayoutNode[] = [];
|
|
20
|
+
for (let index = 0; index < paneIds.length; index += columnCount) {
|
|
21
|
+
rows.push(buildSplit(
|
|
22
|
+
paneIds.slice(index, index + columnCount).map((instanceId) => ({ kind: "pane", instanceId })),
|
|
23
|
+
"horizontal",
|
|
24
|
+
));
|
|
25
|
+
}
|
|
26
|
+
return buildSplit(rows, "vertical");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function visiblePaneIds(layout: LayoutConfig): string[] {
|
|
30
|
+
const ids = new Set<string>();
|
|
31
|
+
getDockedPaneIds(layout).forEach((id) => ids.add(id));
|
|
32
|
+
layout.floating.forEach((entry) => ids.add(entry.instanceId));
|
|
33
|
+
(layout.detached ?? []).forEach((entry) => ids.add(entry.instanceId));
|
|
34
|
+
return [...ids];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function regionToDockPosition(region: string): "left" | "right" | "above" | "below" {
|
|
38
|
+
if (region === "left" || region === "right") return region;
|
|
39
|
+
if (region === "top") return "above";
|
|
40
|
+
if (region === "bottom") return "below";
|
|
41
|
+
throw new Error(`Unsupported dock region "${region}".`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function regionToRootEdge(region: string): "left" | "right" | "top" | "bottom" {
|
|
45
|
+
if (region === "left" || region === "right" || region === "top" || region === "bottom") return region;
|
|
46
|
+
throw new Error(`Unsupported root-edge region "${region}".`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSplit(nodes: DockLayoutNode[], axis: "horizontal" | "vertical"): DockLayoutNode {
|
|
50
|
+
if (nodes.length === 0) throw new Error("Cannot build an empty dock split.");
|
|
51
|
+
if (nodes.length === 1) return nodes[0]!;
|
|
52
|
+
const splitIndex = Math.ceil(nodes.length / 2);
|
|
53
|
+
const firstNodes = nodes.slice(0, splitIndex);
|
|
54
|
+
const secondNodes = nodes.slice(splitIndex);
|
|
55
|
+
return {
|
|
56
|
+
kind: "split",
|
|
57
|
+
axis,
|
|
58
|
+
ratio: firstNodes.length / nodes.length,
|
|
59
|
+
first: buildSplit(firstNodes, axis),
|
|
60
|
+
second: buildSplit(secondNodes, axis),
|
|
61
|
+
};
|
|
62
|
+
}
|