gloomberb 0.9.5 → 0.9.7
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 +2 -2
- 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/index.ts +2 -0
- package/src/components/chart/core/keyboard.ts +10 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
- 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 +47 -0
- 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/index.ts +7 -0
- package/src/components/layout/pane/footer/index.tsx +23 -0
- package/src/components/layout/shell/index.test.tsx +2 -0
- package/src/components/layout/shell/index.tsx +8 -3
- package/src/components/layout/shell/pane/actions.ts +21 -7
- 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/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/fields.tsx +18 -0
- package/src/components/ui/list-view.tsx +83 -3
- package/src/components/ui/message-composer.tsx +22 -0
- package/src/components/ui/tabs.tsx +45 -0
- 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/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/chat/channels.ts +5 -18
- package/src/plugins/builtin/chat/chat.test.tsx +1 -7
- package/src/plugins/builtin/chat/status-widget.tsx +1 -7
- package/src/plugins/builtin/cloud/plugin.tsx +4 -13
- 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/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
- package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- 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/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/host/box.tsx +5 -0
- 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/main.tsx +5 -1
- package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
- package/src/renderers/opentui/chart-surface.test.tsx +101 -0
- package/src/renderers/opentui/chart-surface.tsx +15 -20
- 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/types/config.ts +1 -0
- 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
|
@@ -5,10 +5,13 @@ import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
|
|
|
5
5
|
import type { TickerFinancials } from "../../../../types/financials";
|
|
6
6
|
import type { TickerRecord } from "../../../../types/ticker";
|
|
7
7
|
import {
|
|
8
|
-
VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS,
|
|
9
8
|
VISIBLE_FINANCIAL_WARMUP_DELAY_MS,
|
|
9
|
+
VISIBLE_QUOTE_REFRESH_COOLDOWN_MS,
|
|
10
|
+
VISIBLE_QUOTE_STREAM_WATCHDOG_MS,
|
|
10
11
|
VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT,
|
|
12
|
+
VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS,
|
|
11
13
|
needsVisibleQuoteWarmup,
|
|
14
|
+
needsVisibleQuoteWatchdogRefresh,
|
|
12
15
|
needsVisibleSnapshotWarmup,
|
|
13
16
|
selectStreamTickers,
|
|
14
17
|
visibleWarmupKey,
|
|
@@ -90,7 +93,7 @@ export function usePortfolioPaneStreaming({
|
|
|
90
93
|
if (
|
|
91
94
|
needsVisibleQuoteWarmup(financials, nowTimestamp)
|
|
92
95
|
&& !warmupInFlightRef.current.has(quoteKey)
|
|
93
|
-
&& nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >=
|
|
96
|
+
&& nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
|
|
94
97
|
) {
|
|
95
98
|
quoteQueue.push(ticker);
|
|
96
99
|
continue;
|
|
@@ -100,7 +103,7 @@ export function usePortfolioPaneStreaming({
|
|
|
100
103
|
if (
|
|
101
104
|
needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
|
|
102
105
|
&& !warmupInFlightRef.current.has(snapshotKey)
|
|
103
|
-
&& nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >=
|
|
106
|
+
&& nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS
|
|
104
107
|
) {
|
|
105
108
|
snapshotQueue.push(ticker);
|
|
106
109
|
}
|
|
@@ -130,7 +133,7 @@ export function usePortfolioPaneStreaming({
|
|
|
130
133
|
try {
|
|
131
134
|
await Promise.allSettled([
|
|
132
135
|
quoteEntries.length > 0
|
|
133
|
-
? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
|
|
136
|
+
? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true })
|
|
134
137
|
: Promise.resolve(),
|
|
135
138
|
snapshotEntries.length > 0
|
|
136
139
|
? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
|
|
@@ -155,5 +158,52 @@ export function usePortfolioPaneStreaming({
|
|
|
155
158
|
};
|
|
156
159
|
}, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
|
|
157
160
|
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!appActive) return;
|
|
163
|
+
if (!focused) return;
|
|
164
|
+
if (!sharedCoordinator) return;
|
|
165
|
+
|
|
166
|
+
let cancelled = false;
|
|
167
|
+
const runWatchdog = async (): Promise<void> => {
|
|
168
|
+
const nowTimestamp = Date.now();
|
|
169
|
+
const quoteEntries = visibleFinancialTickers.flatMap((ticker) => {
|
|
170
|
+
const financials = financialsMap.get(ticker.metadata.ticker);
|
|
171
|
+
const key = visibleWarmupKey("quote", ticker);
|
|
172
|
+
if (
|
|
173
|
+
!needsVisibleQuoteWatchdogRefresh(financials, nowTimestamp)
|
|
174
|
+
|| warmupInFlightRef.current.has(key)
|
|
175
|
+
|| nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
|
|
176
|
+
) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
180
|
+
if (!instrument) return [];
|
|
181
|
+
warmupInFlightRef.current.add(key);
|
|
182
|
+
warmupAttemptRef.current.set(key, nowTimestamp);
|
|
183
|
+
return [{ key, instrument }];
|
|
184
|
+
});
|
|
185
|
+
if (quoteEntries.length === 0) return;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true });
|
|
189
|
+
} catch {
|
|
190
|
+
// Best-effort watchdog for visible rows only.
|
|
191
|
+
} finally {
|
|
192
|
+
for (const entry of quoteEntries) {
|
|
193
|
+
warmupInFlightRef.current.delete(entry.key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const intervalId = setInterval(() => {
|
|
199
|
+
if (!cancelled && mountedRef.current) void runWatchdog();
|
|
200
|
+
}, VISIBLE_QUOTE_STREAM_WATCHDOG_MS);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
cancelled = true;
|
|
204
|
+
clearInterval(intervalId);
|
|
205
|
+
};
|
|
206
|
+
}, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
|
|
207
|
+
|
|
158
208
|
useQuoteStreaming(streamTargets);
|
|
159
209
|
}
|
|
@@ -3,11 +3,13 @@ import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type Col
|
|
|
3
3
|
import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline/view";
|
|
4
4
|
|
|
5
5
|
type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
|
|
6
|
+
export type PortfolioViewMode = "table" | "grid";
|
|
6
7
|
|
|
7
8
|
export interface PortfolioPaneSettings {
|
|
8
9
|
columnIds: string[];
|
|
9
10
|
collectionScope: CollectionScope;
|
|
10
11
|
visibleCollectionIds: string[];
|
|
12
|
+
viewMode: PortfolioViewMode;
|
|
11
13
|
hideHeader: boolean;
|
|
12
14
|
hideCash: boolean;
|
|
13
15
|
}
|
|
@@ -95,11 +97,27 @@ const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
|
|
|
95
97
|
description: "Choose exactly which collections this pane should show.",
|
|
96
98
|
},
|
|
97
99
|
];
|
|
100
|
+
const VIEW_MODE_OPTIONS: PaneSettingOption[] = [
|
|
101
|
+
{
|
|
102
|
+
value: "table",
|
|
103
|
+
label: "Table",
|
|
104
|
+
description: "Show positions in the portfolio table.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
value: "grid",
|
|
108
|
+
label: "Grid",
|
|
109
|
+
description: "Show positions in the portfolio grid.",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
98
112
|
|
|
99
113
|
function isCollectionScope(value: unknown): value is CollectionScope {
|
|
100
114
|
return value === "all" || value === "portfolios" || value === "watchlists" || value === "custom";
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
function isPortfolioViewMode(value: unknown): value is PortfolioViewMode {
|
|
118
|
+
return value === "table" || value === "grid";
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
function filterCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
|
|
104
122
|
switch (settings.collectionScope) {
|
|
105
123
|
case "portfolios":
|
|
@@ -142,6 +160,7 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
|
|
|
142
160
|
columnIds: columnIds.length > 0 ? columnIds : DEFAULT_PORTFOLIO_COLUMN_IDS,
|
|
143
161
|
collectionScope: isCollectionScope(settings?.collectionScope) ? settings.collectionScope : "all",
|
|
144
162
|
visibleCollectionIds,
|
|
163
|
+
viewMode: isPortfolioViewMode(settings?.viewMode) ? settings.viewMode : "table",
|
|
145
164
|
hideHeader: settings?.hideHeader === true,
|
|
146
165
|
hideCash: settings?.hideCash === true,
|
|
147
166
|
};
|
|
@@ -218,9 +237,15 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
|
|
|
218
237
|
.filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
|
|
219
238
|
}
|
|
220
239
|
|
|
221
|
-
export function buildPortfolioPaneSettingsDef(
|
|
240
|
+
export function buildPortfolioPaneSettingsDef(
|
|
241
|
+
config: AppConfig,
|
|
242
|
+
settings: PortfolioPaneSettings,
|
|
243
|
+
activeCollectionId?: string | null,
|
|
244
|
+
): PaneSettingsDef {
|
|
222
245
|
const collectionEntries = getCollectionEntries(config);
|
|
223
246
|
const allCollectionOptions = resolveCollectionOptions(collectionEntries);
|
|
247
|
+
const activeCollectionIsPortfolio = !!activeCollectionId
|
|
248
|
+
&& config.portfolios.some((portfolio) => portfolio.id === activeCollectionId);
|
|
224
249
|
|
|
225
250
|
const fields: PaneSettingsDef["fields"] = [
|
|
226
251
|
{
|
|
@@ -241,6 +266,15 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
241
266
|
},
|
|
242
267
|
];
|
|
243
268
|
|
|
269
|
+
if (activeCollectionIsPortfolio) {
|
|
270
|
+
fields.push({
|
|
271
|
+
key: "viewMode",
|
|
272
|
+
label: "View",
|
|
273
|
+
type: "select",
|
|
274
|
+
options: VIEW_MODE_OPTIONS,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
if (settings.collectionScope === "custom") {
|
|
245
279
|
fields.push({
|
|
246
280
|
key: "visibleCollectionIds",
|
|
@@ -266,6 +300,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
266
300
|
...settings,
|
|
267
301
|
columnIds: [...settings.columnIds],
|
|
268
302
|
visibleCollectionIds: [...settings.visibleCollectionIds],
|
|
303
|
+
viewMode: settings.viewMode,
|
|
269
304
|
},
|
|
270
305
|
fields,
|
|
271
306
|
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
2
5
|
import { BarSizeSetting, ConnectionState, SecType, type ContractDetails } from "@stoqey/ib";
|
|
3
6
|
import { Subject, of } from "rxjs";
|
|
4
7
|
import {
|
|
@@ -246,6 +249,25 @@ test("remote gateway snapshots are side-effect-free", async () => {
|
|
|
246
249
|
}
|
|
247
250
|
});
|
|
248
251
|
|
|
252
|
+
test("native gateway loader bundles without a runtime ./native specifier", async () => {
|
|
253
|
+
const outdir = mkdtempSync(join(tmpdir(), "gloomberb-ibkr-service-"));
|
|
254
|
+
try {
|
|
255
|
+
const result = await Bun.build({
|
|
256
|
+
entrypoints: [join(import.meta.dir, "index.ts")],
|
|
257
|
+
outdir,
|
|
258
|
+
target: "bun",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(true);
|
|
262
|
+
const entry = readFileSync(join(outdir, "index.js"), "utf8");
|
|
263
|
+
expect(entry).toContain("src/plugins/ibkr/gateway/service/native.ts");
|
|
264
|
+
expect(entry).not.toContain('"./native"');
|
|
265
|
+
expect(entry).not.toContain("'./native'");
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(outdir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
249
271
|
describe("tick-by-tick quote updates", () => {
|
|
250
272
|
const contract = {
|
|
251
273
|
symbol: "AAPL",
|
|
@@ -17,18 +17,18 @@ import type {
|
|
|
17
17
|
IbkrSnapshot,
|
|
18
18
|
ResolvedIbkrGatewayConnection,
|
|
19
19
|
} from "../types";
|
|
20
|
+
import {
|
|
21
|
+
loadNativeGatewayModule as loadDefaultNativeGatewayModule,
|
|
22
|
+
type NativeGatewayModule,
|
|
23
|
+
} from "./native-loader";
|
|
20
24
|
|
|
21
25
|
export type {
|
|
22
26
|
IbkrGatewayConfig,
|
|
23
27
|
ResolvedIbkrGatewayConnection,
|
|
24
28
|
} from "../types";
|
|
25
29
|
|
|
26
|
-
type NativeGatewayService = any;
|
|
27
30
|
type NativeGatewayManager = any;
|
|
28
|
-
type
|
|
29
|
-
ibkrGatewayManager: NativeGatewayManager;
|
|
30
|
-
setResolvedIbkrGatewayListener(listener: ResolvedGatewayListener | null): void;
|
|
31
|
-
};
|
|
31
|
+
type NativeGatewayService = any;
|
|
32
32
|
type Listener = () => void;
|
|
33
33
|
type ResolvedGatewayListener = (
|
|
34
34
|
instanceId: string | undefined,
|
|
@@ -47,9 +47,6 @@ let loadedNativeGatewayModule: NativeGatewayModule | null = null;
|
|
|
47
47
|
let nativeGatewayModulePromise: Promise<NativeGatewayModule> | null = null;
|
|
48
48
|
let nativeGatewayManager: NativeGatewayManager | null = null;
|
|
49
49
|
let nativeGatewayModuleLoader: () => Promise<NativeGatewayModule>;
|
|
50
|
-
const importNativeGatewayModule = new Function("specifier", "return import(specifier)") as (
|
|
51
|
-
specifier: string,
|
|
52
|
-
) => Promise<NativeGatewayModule>;
|
|
53
50
|
|
|
54
51
|
const snapshots = new Map<string, IbkrSnapshot>();
|
|
55
52
|
const listeners = new Map<string, Set<Listener>>();
|
|
@@ -60,7 +57,7 @@ function getSnapshotEntry(instanceId?: string): IbkrSnapshot {
|
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
function defaultNativeGatewayModuleLoader(): Promise<NativeGatewayModule> {
|
|
63
|
-
return
|
|
60
|
+
return loadDefaultNativeGatewayModule();
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
nativeGatewayModuleLoader = defaultNativeGatewayModuleLoader;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ResolvedIbkrGatewayConnection } from "../types";
|
|
2
|
+
|
|
3
|
+
export type NativeGatewayModule = {
|
|
4
|
+
ibkrGatewayManager: any;
|
|
5
|
+
setResolvedIbkrGatewayListener(
|
|
6
|
+
listener: ((instanceId: string | undefined, connection: ResolvedIbkrGatewayConnection) => void | Promise<void>) | null,
|
|
7
|
+
): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function loadNativeGatewayModule(): Promise<NativeGatewayModule> {
|
|
11
|
+
return import("./native");
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import type { Dispatch } from "react";
|
|
3
|
+
import type { PluginRegistry } from "../plugins/registry";
|
|
4
|
+
import type { AppAction, AppState } from "../state/app/context";
|
|
5
|
+
import type { DesktopWindowBridge } from "../types/desktop-window";
|
|
6
|
+
import { createAppRemoteController } from "./controller";
|
|
7
|
+
import { useRemoteUiRegistry } from "./semantic-tree";
|
|
8
|
+
import type { RemoteControlRequest, RemoteControlResponse } from "./types";
|
|
9
|
+
|
|
10
|
+
export type RemoteControlHandler = (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
|
|
11
|
+
|
|
12
|
+
export interface RemoteControlAdapter {
|
|
13
|
+
startServer?(options: { dataDir: string; handle: RemoteControlHandler }): void | (() => void | Promise<void>);
|
|
14
|
+
registerHandler?(handler: RemoteControlHandler | null): void | (() => void);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RemoteControlHostProps {
|
|
18
|
+
adapter?: RemoteControlAdapter;
|
|
19
|
+
dispatch: Dispatch<AppAction>;
|
|
20
|
+
getState: () => AppState;
|
|
21
|
+
pluginRegistry: PluginRegistry;
|
|
22
|
+
desktopWindowBridge?: DesktopWindowBridge;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function RemoteControlHost({
|
|
26
|
+
adapter,
|
|
27
|
+
dispatch,
|
|
28
|
+
getState,
|
|
29
|
+
pluginRegistry,
|
|
30
|
+
desktopWindowBridge,
|
|
31
|
+
}: RemoteControlHostProps) {
|
|
32
|
+
const uiRegistry = useRemoteUiRegistry();
|
|
33
|
+
const controller = useMemo(() => createAppRemoteController({
|
|
34
|
+
dispatch,
|
|
35
|
+
getState,
|
|
36
|
+
pluginRegistry,
|
|
37
|
+
uiRegistry,
|
|
38
|
+
desktopWindowBridge,
|
|
39
|
+
afterMutation: async () => {
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
await new Promise((resolve) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const finish = () => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve(undefined);
|
|
47
|
+
};
|
|
48
|
+
if (typeof requestAnimationFrame === "function") {
|
|
49
|
+
requestAnimationFrame(finish);
|
|
50
|
+
}
|
|
51
|
+
setTimeout(finish, 50);
|
|
52
|
+
});
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
54
|
+
},
|
|
55
|
+
}), [desktopWindowBridge, dispatch, getState, pluginRegistry, uiRegistry]);
|
|
56
|
+
const dataDir = getState().config.dataDir;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!adapter?.startServer) return;
|
|
60
|
+
const cleanup = adapter.startServer({ dataDir, handle: controller.handle });
|
|
61
|
+
if (!cleanup) return;
|
|
62
|
+
return () => {
|
|
63
|
+
void cleanup();
|
|
64
|
+
};
|
|
65
|
+
}, [adapter, controller, dataDir]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!adapter?.registerHandler) return;
|
|
69
|
+
const cleanup = adapter.registerHandler(controller.handle);
|
|
70
|
+
if (!cleanup) return;
|
|
71
|
+
return () => {
|
|
72
|
+
cleanup();
|
|
73
|
+
};
|
|
74
|
+
}, [adapter, controller]);
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse } from "./types";
|
|
2
|
+
import { readRemoteEndpoint } from "./endpoint";
|
|
3
|
+
|
|
4
|
+
export interface SendRemoteControlRequestOptions {
|
|
5
|
+
dataDir: string;
|
|
6
|
+
appKind?: RemoteAppKind;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function sendRemoteControlRequest(
|
|
10
|
+
request: RemoteControlRequest,
|
|
11
|
+
options: SendRemoteControlRequestOptions,
|
|
12
|
+
): Promise<RemoteControlResponse> {
|
|
13
|
+
const endpoint = await readRemoteEndpoint(options.dataDir, options.appKind);
|
|
14
|
+
const response = await fetch(`http://127.0.0.1:${endpoint.port}/rpc`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
authorization: `Bearer ${endpoint.token}`,
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
});
|
|
22
|
+
const payload = await response.json() as RemoteControlResponse;
|
|
23
|
+
if (!response.ok && payload.ok !== false) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: {
|
|
27
|
+
code: "remote_http_error",
|
|
28
|
+
message: `Remote control request failed with HTTP ${response.status}.`,
|
|
29
|
+
details: payload,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return payload;
|
|
34
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AppState } from "../state/app/context";
|
|
2
|
+
import type { RemoteUiNodeSnapshot } from "./types";
|
|
3
|
+
|
|
4
|
+
export function commandBarResultsFromNodes(nodes: RemoteUiNodeSnapshot[]) {
|
|
5
|
+
const directResults = nodes
|
|
6
|
+
.filter((node) => node.role === "command-bar-result")
|
|
7
|
+
.map((node) => {
|
|
8
|
+
const metadata = node.metadata ?? {};
|
|
9
|
+
const item = metadata.item && typeof metadata.item === "object"
|
|
10
|
+
? metadata.item as Record<string, unknown>
|
|
11
|
+
: {};
|
|
12
|
+
return {
|
|
13
|
+
nodeId: node.id,
|
|
14
|
+
index: metadata.index,
|
|
15
|
+
label: node.label ?? item.label,
|
|
16
|
+
detail: item.detail,
|
|
17
|
+
category: item.category,
|
|
18
|
+
kind: item.kind,
|
|
19
|
+
right: item.right,
|
|
20
|
+
selected: metadata.selected === true,
|
|
21
|
+
disabled: node.disabled === true,
|
|
22
|
+
actions: node.actions,
|
|
23
|
+
itemId: item.id,
|
|
24
|
+
actionInput: undefined,
|
|
25
|
+
metadata,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const listResults = nodes.flatMap((node) => {
|
|
30
|
+
const metadata = node.metadata ?? {};
|
|
31
|
+
if (node.role !== "list" && node.role !== "command-bar-list") return [];
|
|
32
|
+
if (metadata.scope !== "command-bar") return [];
|
|
33
|
+
const rawItems = Array.isArray(metadata.items) ? metadata.items : [];
|
|
34
|
+
const selectedIndex = typeof metadata.selectedIndex === "number" ? metadata.selectedIndex : -1;
|
|
35
|
+
return rawItems
|
|
36
|
+
.map((entry, fallbackIndex) => (
|
|
37
|
+
entry && typeof entry === "object"
|
|
38
|
+
? entry as Record<string, unknown>
|
|
39
|
+
: { label: String(entry), index: fallbackIndex }
|
|
40
|
+
))
|
|
41
|
+
.map((item, fallbackIndex) => {
|
|
42
|
+
const index = typeof item.index === "number" ? item.index : fallbackIndex;
|
|
43
|
+
return {
|
|
44
|
+
nodeId: node.id,
|
|
45
|
+
index,
|
|
46
|
+
label: item.label,
|
|
47
|
+
detail: item.detail,
|
|
48
|
+
category: item.category ?? metadata.category,
|
|
49
|
+
kind: item.kind ?? metadata.itemKind,
|
|
50
|
+
right: item.right,
|
|
51
|
+
selected: selectedIndex === index,
|
|
52
|
+
disabled: item.disabled === true || node.disabled === true,
|
|
53
|
+
actions: node.actions,
|
|
54
|
+
itemId: item.id,
|
|
55
|
+
actionInput: {
|
|
56
|
+
index,
|
|
57
|
+
id: item.id,
|
|
58
|
+
label: item.label,
|
|
59
|
+
},
|
|
60
|
+
metadata: {
|
|
61
|
+
...metadata,
|
|
62
|
+
item,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return [...directResults, ...listResults].sort((left, right) => {
|
|
69
|
+
const leftIndex = typeof left.index === "number" ? left.index : Number.MAX_SAFE_INTEGER;
|
|
70
|
+
const rightIndex = typeof right.index === "number" ? right.index : Number.MAX_SAFE_INTEGER;
|
|
71
|
+
return leftIndex - rightIndex;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isCommandBarInputNode(node: RemoteUiNodeSnapshot): boolean {
|
|
76
|
+
return (
|
|
77
|
+
(node.role === "input" || node.role === "command-bar-input")
|
|
78
|
+
&& node.metadata?.scope === "command-bar"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function commandBarInputValue(nodes: RemoteUiNodeSnapshot[], fallback: string): string {
|
|
83
|
+
const focusedInput = nodes.find((node) => isCommandBarInputNode(node) && node.metadata?.focused === true);
|
|
84
|
+
const value = focusedInput?.metadata?.value;
|
|
85
|
+
return typeof value === "string" ? value : fallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function commandBarSnapshot(state: AppState, nodes: RemoteUiNodeSnapshot[]) {
|
|
89
|
+
const results = commandBarResultsFromNodes(nodes);
|
|
90
|
+
return {
|
|
91
|
+
open: state.commandBarOpen,
|
|
92
|
+
query: commandBarInputValue(nodes, state.commandBarQuery),
|
|
93
|
+
stateQuery: state.commandBarQuery,
|
|
94
|
+
launchRequest: state.commandBarLaunchRequest,
|
|
95
|
+
selectedIndex: results.find((result) => result.selected)?.index ?? null,
|
|
96
|
+
results,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getDockedPaneIds } from "../plugins/pane-manager";
|
|
2
|
+
import type { AppState } from "../state/app/context";
|
|
3
|
+
import type { LayoutConfig, PaneInstanceConfig } from "../types/config";
|
|
4
|
+
import { revisionFor } from "./revision";
|
|
5
|
+
import type { RemoteControlResponse, RemoteIncludedState, RemoteStateInclude } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface PatchTarget<T> {
|
|
8
|
+
value: T;
|
|
9
|
+
apply(value: T): Promise<void> | void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ok<T>(data: T, rev?: string, state?: RemoteIncludedState): RemoteControlResponse<T> {
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
data,
|
|
16
|
+
...(rev ? { rev } : {}),
|
|
17
|
+
...(state ? { state } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fail(code: string, error: unknown): RemoteControlResponse {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: {
|
|
25
|
+
code,
|
|
26
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function asRecord(input: unknown): Record<string, unknown> {
|
|
32
|
+
return input && typeof input === "object" && !Array.isArray(input) ? input as Record<string, unknown> : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stringInput(input: Record<string, unknown>, key: string): string {
|
|
36
|
+
const value = input[key];
|
|
37
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
38
|
+
throw new Error(`${key} is required.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function optionalString(input: Record<string, unknown>, key: string): string | undefined {
|
|
44
|
+
const value = input[key];
|
|
45
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function numberInput(input: Record<string, unknown>, key: string): number {
|
|
49
|
+
const value = input[key];
|
|
50
|
+
if (!Number.isInteger(value)) {
|
|
51
|
+
throw new Error(`${key} must be an integer.`);
|
|
52
|
+
}
|
|
53
|
+
return value as number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function optionalNumber(input: Record<string, unknown>, key: string): number | undefined {
|
|
57
|
+
const value = input[key];
|
|
58
|
+
return Number.isInteger(value) ? value as number : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function activeLayoutRev(state: AppState): string {
|
|
62
|
+
return revisionFor({
|
|
63
|
+
layout: state.config.layout,
|
|
64
|
+
paneState: state.paneState,
|
|
65
|
+
focusedPaneId: state.focusedPaneId,
|
|
66
|
+
activePanel: state.activePanel,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function panePlacement(layout: LayoutConfig, instanceId: string): "docked" | "floating" | "detached" | "hidden" {
|
|
71
|
+
if (getDockedPaneIds(layout).includes(instanceId)) return "docked";
|
|
72
|
+
if (layout.floating.some((entry) => entry.instanceId === instanceId)) return "floating";
|
|
73
|
+
if ((layout.detached ?? []).some((entry) => entry.instanceId === instanceId)) return "detached";
|
|
74
|
+
return "hidden";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function paneSnapshot(state: AppState, pane: PaneInstanceConfig) {
|
|
78
|
+
return {
|
|
79
|
+
...pane,
|
|
80
|
+
placement: panePlacement(state.config.layout, pane.instanceId),
|
|
81
|
+
focused: state.focusedPaneId === pane.instanceId,
|
|
82
|
+
runtimeState: state.paneState[pane.instanceId] ?? {},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function mutationSummary(state: AppState, extra: Record<string, unknown> = {}) {
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
rev: activeLayoutRev(state),
|
|
90
|
+
activeLayoutIndex: state.config.activeLayoutIndex,
|
|
91
|
+
activeLayoutName: state.config.layouts[state.config.activeLayoutIndex]?.name ?? null,
|
|
92
|
+
focusedPaneId: state.focusedPaneId,
|
|
93
|
+
commandBarOpen: state.commandBarOpen,
|
|
94
|
+
commandBarQuery: state.commandBarQuery,
|
|
95
|
+
dockedPaneIds: getDockedPaneIds(state.config.layout),
|
|
96
|
+
floatingPaneIds: state.config.layout.floating.map((entry) => entry.instanceId),
|
|
97
|
+
detachedPaneIds: (state.config.layout.detached ?? []).map((entry) => entry.instanceId),
|
|
98
|
+
...extra,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeIncludes(
|
|
103
|
+
include: RemoteStateInclude[] | undefined,
|
|
104
|
+
defaults: RemoteStateInclude[] = [],
|
|
105
|
+
): RemoteStateInclude[] {
|
|
106
|
+
const raw = include ?? defaults;
|
|
107
|
+
if (raw.includes("all")) {
|
|
108
|
+
return ["app", "layout", "panes", "commandBar", "ui", "schema", "help"];
|
|
109
|
+
}
|
|
110
|
+
return [...new Set(raw)];
|
|
111
|
+
}
|