gloomberb 0.7.2 → 0.7.4

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.
Files changed (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -183,31 +183,70 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
183
183
  fetchGenRef.current += 1;
184
184
  const gen = fetchGenRef.current;
185
185
 
186
- for (const entry of WORLD_INDICES) {
187
- setQuotes((prev) => {
188
- const next = new Map(prev);
186
+ setQuotes((prev) => {
187
+ const next = new Map(prev);
188
+ for (const entry of WORLD_INDICES) {
189
189
  const existing = next.get(entry.symbol);
190
190
  next.set(entry.symbol, { quote: existing?.quote ?? null, loading: true, error: null });
191
+ }
192
+ return next;
193
+ });
194
+
195
+ const loadQuotes = async (): Promise<QuoteMap> => {
196
+ const next = new Map<string, IndexQuoteState>();
197
+ if (dataProvider.getQuotesBatch) {
198
+ const results = await dataProvider.getQuotesBatch(
199
+ WORLD_INDICES.map((entry) => ({ symbol: entry.symbol, exchange: "" })),
200
+ );
201
+ const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
202
+ for (const entry of WORLD_INDICES) {
203
+ const result = bySymbol.get(entry.symbol);
204
+ next.set(entry.symbol, {
205
+ quote: result?.quote ?? null,
206
+ loading: false,
207
+ error: result?.error
208
+ ? result.error instanceof Error ? result.error.message : String(result.error)
209
+ : null,
210
+ });
211
+ }
191
212
  return next;
192
- });
213
+ }
193
214
 
194
- dataProvider.getQuote(entry.symbol, "").then((quote) => {
195
- if (fetchGenRef.current !== gen) return;
196
- setQuotes((prev) => {
197
- const next = new Map(prev);
215
+ await Promise.all(WORLD_INDICES.map(async (entry) => {
216
+ try {
217
+ const quote = await dataProvider.getQuote(entry.symbol, "");
198
218
  next.set(entry.symbol, { quote, loading: false, error: null });
199
- return next;
200
- });
201
- }).catch((err: unknown) => {
202
- if (fetchGenRef.current !== gen) return;
203
- const msg = err instanceof Error ? err.message : String(err);
204
- setQuotes((prev) => {
205
- const next = new Map(prev);
206
- next.set(entry.symbol, { quote: null, loading: false, error: msg });
207
- return next;
208
- });
219
+ } catch (err: unknown) {
220
+ next.set(entry.symbol, {
221
+ quote: null,
222
+ loading: false,
223
+ error: err instanceof Error ? err.message : String(err),
224
+ });
225
+ }
226
+ }));
227
+ return next;
228
+ };
229
+
230
+ loadQuotes().then((nextQuotes) => {
231
+ if (fetchGenRef.current !== gen) return;
232
+ setQuotes((prev) => {
233
+ const next = new Map(prev);
234
+ for (const [symbol, state] of nextQuotes) {
235
+ next.set(symbol, state);
236
+ }
237
+ return next;
209
238
  });
210
- }
239
+ }).catch((err: unknown) => {
240
+ if (fetchGenRef.current !== gen) return;
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ setQuotes((prev) => {
243
+ const next = new Map(prev);
244
+ for (const entry of WORLD_INDICES) {
245
+ next.set(entry.symbol, { quote: null, loading: false, error: message });
246
+ }
247
+ return next;
248
+ });
249
+ });
211
250
  }, [dataProvider]);
212
251
 
213
252
  useEffect(() => {
@@ -16,6 +16,7 @@ import { colors, priceColor } from "../../theme/colors";
16
16
  import type { PaneProps } from "../../types/plugin";
17
17
  import type { Quote } from "../../types/financials";
18
18
  import { formatCurrency, padTo } from "../../utils/format";
19
+ import { isPlainKey } from "../../utils/keyboard";
19
20
  import { formatMarketPrice, formatMarketQuantity } from "../../utils/market-format";
20
21
  import { getBrokerInstance } from "../../utils/broker-instances";
21
22
  import { usePluginPaneActions } from "../plugin-runtime";
@@ -295,13 +296,13 @@ export function TradingPane({ focused, width, height }: PaneProps) {
295
296
  if (!focused) return;
296
297
  event.stopPropagation?.();
297
298
 
298
- if (event.name === "j" || event.name === "down") {
299
+ if (isPlainKey(event, "j", "down")) {
299
300
  const nextIndex = Math.min(tradeState.selectedOpenOrderIndex + 1, Math.max(0, gatewaySnapshot.openOrders.length - 1));
300
301
  updateTradingPaneState({ selectedOpenOrderIndex: nextIndex });
301
302
  return;
302
303
  }
303
304
 
304
- if (event.name === "k" || event.name === "up") {
305
+ if (isPlainKey(event, "k", "up")) {
305
306
  const nextIndex = Math.max(0, tradeState.selectedOpenOrderIndex - 1);
306
307
  updateTradingPaneState({ selectedOpenOrderIndex: nextIndex });
307
308
  return;
@@ -20,7 +20,7 @@ import {
20
20
  import type { BrokerAdapter } from "../types/broker";
21
21
  import type { PluginCapability } from "../capabilities";
22
22
  import type { DataProvider } from "../types/data-provider";
23
- import type { AppNotificationRequest, BrokerInstanceUpdateOptions } from "../types/plugin";
23
+ import type { AppNotificationRequest, BrokerInstanceUpdateOptions, PinTickerOptions } from "../types/plugin";
24
24
 
25
25
  export interface PluginRuntimeAccess {
26
26
  getMarketData(): DataProvider | null;
@@ -30,7 +30,7 @@ export interface PluginRuntimeAccess {
30
30
  updateBrokerInstance(instanceId: string, values: Record<string, unknown>, options?: BrokerInstanceUpdateOptions): Promise<void>;
31
31
  syncBrokerInstance(instanceId: string): Promise<void>;
32
32
  removeBrokerInstance(instanceId: string): Promise<void>;
33
- pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
33
+ pinTicker(symbol: string, options?: PinTickerOptions): void;
34
34
  navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
35
35
  selectTicker(symbol: string, paneId?: string): void;
36
36
  switchTab(tabId: string, paneId?: string): void;
@@ -154,6 +154,10 @@ export function buildPredictionMarketsPaneSettingsDef(
154
154
 
155
155
  return {
156
156
  title: "Prediction Markets Settings",
157
+ values: {
158
+ ...settings,
159
+ columnIds: [...settings.columnIds],
160
+ },
157
161
  fields,
158
162
  };
159
163
  }
@@ -222,6 +222,61 @@ describe("PluginRegistry detail tabs", () => {
222
222
  });
223
223
 
224
224
  describe("PluginRegistry pane settings", () => {
225
+ test("resolves effective pane setting values from settings definitions", async () => {
226
+ const registry = createRegistry();
227
+ const config = createDefaultConfig("/tmp/gloomberb-pane-settings-test");
228
+ config.layout = {
229
+ dockRoot: { kind: "pane", instanceId: "test-pane:main" },
230
+ instances: [{
231
+ instanceId: "test-pane:main",
232
+ paneId: "test-pane",
233
+ binding: { kind: "none" },
234
+ settings: {},
235
+ }],
236
+ floating: [],
237
+ };
238
+ registry.getConfigFn = () => config;
239
+ registry.getLayoutFn = () => config.layout;
240
+
241
+ await registry.register(plugin("tables", (ctx) => {
242
+ ctx.registerPane({
243
+ id: "test-pane",
244
+ name: "Test Pane",
245
+ defaultPosition: "right",
246
+ component: () => null,
247
+ settings: {
248
+ values: {
249
+ columnIds: ["ticker", "price"],
250
+ collectionScope: "all",
251
+ },
252
+ fields: [
253
+ {
254
+ key: "columnIds",
255
+ label: "Columns",
256
+ type: "ordered-multi-select",
257
+ options: [
258
+ { value: "ticker", label: "Ticker" },
259
+ { value: "price", label: "Price" },
260
+ ],
261
+ },
262
+ {
263
+ key: "collectionScope",
264
+ label: "Collections",
265
+ type: "select",
266
+ options: [{ value: "all", label: "All" }],
267
+ },
268
+ ],
269
+ },
270
+ });
271
+ }));
272
+
273
+ const descriptor = registry.resolvePaneSettings("test-pane:main");
274
+
275
+ expect(descriptor?.context.settings.columnIds).toEqual(["ticker", "price"]);
276
+ expect(descriptor?.context.settings.collectionScope).toBe("all");
277
+ expect(descriptor?.rawSettings.columnIds).toBeUndefined();
278
+ });
279
+
225
280
  test("resolves plugin-scoped pane setting values from plugin config", async () => {
226
281
  const registry = createRegistry();
227
282
  const config = createDefaultConfig("/tmp/gloomberb-pane-settings-test");
@@ -26,6 +26,7 @@ import type {
26
26
  PaneSettingsDef,
27
27
  PaneTemplateCreateOptions,
28
28
  PaneTemplateDef,
29
+ PinTickerOptions,
29
30
  PluginPaneSettingsState,
30
31
  PluginResumeState,
31
32
  TickerAction,
@@ -140,7 +141,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
140
141
  createPaneFromTemplateAsyncFn: ((templateId: string, options?: PaneTemplateCreateOptions) => Promise<void>) = async () => {};
141
142
  hidePaneFn: ((paneId: string) => void) = () => {};
142
143
  focusPaneFn: ((paneId: string) => void) = () => {};
143
- pinTickerFn: ((symbol: string, options?: { floating?: boolean; paneType?: string }) => void) = () => {};
144
+ pinTickerFn: ((symbol: string, options?: PinTickerOptions) => void) = () => {};
144
145
  navigateTickerFn: ((symbol: string, options?: { sourcePaneId?: string | null }) => void) = () => {};
145
146
  getMarketData = () => this.marketData;
146
147
  getCapability = (capabilityId: string) => this.capabilities.get(capabilityId)?.capability ?? null;
@@ -151,7 +152,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
151
152
  );
152
153
  syncBrokerInstance = (instanceId: string) => this.syncBrokerInstanceFn(instanceId);
153
154
  removeBrokerInstance = (instanceId: string) => this.removeBrokerInstanceFn(instanceId);
154
- pinTicker = (symbol: string, options?: { floating?: boolean; paneType?: string }) => {
155
+ pinTicker = (symbol: string, options?: PinTickerOptions) => {
155
156
  this.pinTickerFn(symbol, options);
156
157
  };
157
158
  navigateTicker = (symbol: string, options?: { sourcePaneId?: string | null }) => {
@@ -444,6 +445,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
444
445
  pane: PaneInstanceConfig;
445
446
  paneDef: PaneDef;
446
447
  settingsDef: PaneSettingsDef;
448
+ rawSettings: Record<string, unknown>;
447
449
  context: PaneSettingsContext;
448
450
  } | null {
449
451
  const targetPaneId = this.resolvePaneTarget(paneId);
@@ -483,8 +485,12 @@ export class PluginRegistry implements PluginRuntimeAccess {
483
485
  : paneDef.settings;
484
486
  if (!settingsDef) return null;
485
487
 
488
+ const rawSettings = { ...paneSettings };
489
+ const resolvedSettings = {
490
+ ...paneSettings,
491
+ ...(settingsDef.values ?? {}),
492
+ };
486
493
  if (pluginId) {
487
- const resolvedSettings = { ...paneSettings };
488
494
  for (const field of settingsDef.fields) {
489
495
  if (field.storage !== "plugin") continue;
490
496
  const configValue = this.getConfigState(pluginId, field.key);
@@ -494,8 +500,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
494
500
  resolvedSettings[field.key] = configValue;
495
501
  }
496
502
  }
497
- context.settings = resolvedSettings;
498
503
  }
504
+ context.settings = resolvedSettings;
499
505
 
500
506
  return {
501
507
  paneId: targetPaneId,
@@ -503,6 +509,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
503
509
  pane,
504
510
  paneDef,
505
511
  settingsDef,
512
+ rawSettings,
506
513
  context,
507
514
  };
508
515
  }
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
3
3
  import {
4
- resolveTickerNavigationDetailPane,
4
+ findFixedTickerPaneForSymbol,
5
+ resolveTickerNavigationReplacementPane,
5
6
  shouldFocusTickerNavigationTarget,
6
7
  } from "./ticker-navigation";
7
8
 
@@ -20,8 +21,8 @@ function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
20
21
  };
21
22
  }
22
23
 
23
- describe("resolveTickerNavigationDetailPane", () => {
24
- test("prefers the detail pane following the pane that opened ticker navigation", () => {
24
+ describe("resolveTickerNavigationReplacementPane", () => {
25
+ test("only replaces the ticker detail pane that opened ticker navigation", () => {
25
26
  const layout = createLayout([
26
27
  createPaneInstance("portfolio-list", {
27
28
  instanceId: "portfolio-list:main",
@@ -42,8 +43,37 @@ describe("resolveTickerNavigationDetailPane", () => {
42
43
  ]);
43
44
 
44
45
  expect(
45
- resolveTickerNavigationDetailPane(layout, "comparison-chart:main")?.instanceId,
46
- ).toBe("ticker-detail:compare");
46
+ resolveTickerNavigationReplacementPane(layout, "comparison-chart:main")?.instanceId,
47
+ ).toBeUndefined();
48
+ expect(
49
+ resolveTickerNavigationReplacementPane(layout, "ticker-detail:portfolio")?.instanceId,
50
+ ).toBe("ticker-detail:portfolio");
51
+ });
52
+ });
53
+
54
+ describe("findFixedTickerPaneForSymbol", () => {
55
+ test("finds only a visible fixed pane for the requested symbol", () => {
56
+ const layout = createLayout([
57
+ createPaneInstance("portfolio-list", {
58
+ instanceId: "portfolio-list:main",
59
+ binding: { kind: "none" },
60
+ }),
61
+ createPaneInstance("ticker-detail", {
62
+ instanceId: "ticker-detail:follow",
63
+ binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
64
+ }),
65
+ createPaneInstance("ticker-detail", {
66
+ instanceId: "ticker-detail:aapl",
67
+ binding: { kind: "fixed", symbol: "AAPL" },
68
+ }),
69
+ createPaneInstance("ticker-detail", {
70
+ instanceId: "ticker-detail:msft",
71
+ binding: { kind: "fixed", symbol: "MSFT" },
72
+ }),
73
+ ]);
74
+
75
+ expect(findFixedTickerPaneForSymbol(layout, "ticker-detail", "AAPL")?.instanceId).toBe("ticker-detail:aapl");
76
+ expect(findFixedTickerPaneForSymbol(layout, "ticker-detail", "NVDA")).toBeNull();
47
77
  });
48
78
  });
49
79
 
@@ -1,33 +1,27 @@
1
1
  import { findPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
2
2
  import { isPaneInLayout } from "./pane-manager";
3
3
 
4
- export function resolveTickerNavigationDetailPane(
4
+ export function resolveTickerNavigationReplacementPane(
5
5
  layout: LayoutConfig,
6
6
  sourcePaneId: string | null,
7
7
  ): PaneInstanceConfig | null {
8
8
  const sourceInstance = sourcePaneId ? findPaneInstance(layout, sourcePaneId) : null;
9
+ return sourceInstance?.paneId === "ticker-detail" && isPaneInLayout(layout, sourceInstance.instanceId)
10
+ ? sourceInstance
11
+ : null;
12
+ }
9
13
 
10
- return (
11
- sourceInstance?.paneId === "ticker-detail" && isPaneInLayout(layout, sourceInstance.instanceId)
12
- ? sourceInstance
13
- : null
14
- )
15
- ?? layout.instances.find((instance) =>
16
- instance.paneId === "ticker-detail"
17
- && instance.binding?.kind === "follow"
18
- && instance.binding.sourceInstanceId === sourcePaneId
19
- && isPaneInLayout(layout, instance.instanceId)
20
- )
21
- ?? layout.instances.find((instance) =>
22
- instance.paneId === "ticker-detail"
23
- && instance.binding?.kind === "follow"
24
- && isPaneInLayout(layout, instance.instanceId)
25
- )
26
- ?? layout.instances.find((instance) =>
27
- instance.paneId === "ticker-detail"
28
- && isPaneInLayout(layout, instance.instanceId)
29
- )
30
- ?? null;
14
+ export function findFixedTickerPaneForSymbol(
15
+ layout: LayoutConfig,
16
+ paneId: string,
17
+ symbol: string,
18
+ ): PaneInstanceConfig | null {
19
+ return layout.instances.find((instance) =>
20
+ instance.paneId === paneId
21
+ && instance.binding?.kind === "fixed"
22
+ && instance.binding.symbol === symbol
23
+ && isPaneInLayout(layout, instance.instanceId)
24
+ ) ?? null;
31
25
  }
32
26
 
33
27
  export function shouldFocusTickerNavigationTarget({
@@ -21,6 +21,15 @@ describe("applicationMenuCommand", () => {
21
21
  })).toBeNull();
22
22
  });
23
23
 
24
+ test("parses the native devtools command", () => {
25
+ expect(applicationMenuCommand({
26
+ data: {
27
+ action: ELECTROBUN_APPLICATION_MENU_ACTION,
28
+ data: { type: "open-devtools" },
29
+ },
30
+ })).toEqual({ type: "open-devtools" });
31
+ });
32
+
24
33
  test("ignores unknown command types", () => {
25
34
  expect(applicationMenuCommand({
26
35
  data: {
@@ -1,5 +1,7 @@
1
- import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu";
2
- import { ELECTROBUN_APPLICATION_MENU_ACTION } from "./application-menu";
1
+ import {
2
+ ELECTROBUN_APPLICATION_MENU_ACTION,
3
+ type ElectrobunApplicationMenuCommand,
4
+ } from "./application-menu";
3
5
 
4
6
  function record(value: unknown): Record<string, unknown> | null {
5
7
  return value && typeof value === "object" && !Array.isArray(value)
@@ -19,7 +21,7 @@ function applicationMenuClickPayload(event: unknown): Record<string, unknown> |
19
21
  return typeof eventRecord.action === "string" ? eventRecord : null;
20
22
  }
21
23
 
22
- function normalizeApplicationMenuCommand(value: unknown): DesktopApplicationMenuCommand | null {
24
+ function normalizeApplicationMenuCommand(value: unknown): ElectrobunApplicationMenuCommand | null {
23
25
  const command = record(value);
24
26
  if (!command || typeof command.type !== "string") return null;
25
27
 
@@ -42,13 +44,14 @@ function normalizeApplicationMenuCommand(value: unknown): DesktopApplicationMenu
42
44
  case "layout-undo":
43
45
  case "layout-redo":
44
46
  case "layout-gridlock":
47
+ case "open-devtools":
45
48
  return { type: command.type };
46
49
  default:
47
50
  return null;
48
51
  }
49
52
  }
50
53
 
51
- export function applicationMenuCommand(event: unknown): DesktopApplicationMenuCommand | null {
54
+ export function applicationMenuCommand(event: unknown): ElectrobunApplicationMenuCommand | null {
52
55
  const payload = applicationMenuClickPayload(event);
53
56
  if (payload?.action !== ELECTROBUN_APPLICATION_MENU_ACTION) return null;
54
57
  return normalizeApplicationMenuCommand(payload.data);
@@ -4,9 +4,13 @@ import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu"
4
4
  export const ELECTROBUN_APPLICATION_MENU_ACTION = "gloom.application-menu.select";
5
5
  export const GITHUB_ISSUE_URL = "https://github.com/vincelwt/gloomberb/issues/new/choose";
6
6
 
7
+ export type ElectrobunApplicationMenuCommand =
8
+ | DesktopApplicationMenuCommand
9
+ | { type: "open-devtools" };
10
+
7
11
  function commandItem(
8
12
  label: string,
9
- command: DesktopApplicationMenuCommand,
13
+ command: ElectrobunApplicationMenuCommand,
10
14
  options: { accelerator?: string } = {},
11
15
  ): ApplicationMenuItemConfig {
12
16
  return {
@@ -72,6 +76,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
72
76
  label: "View",
73
77
  submenu: [
74
78
  openCommandBar("Open Command Bar", "", { accelerator: "CmdOrCtrl+K" }),
79
+ commandItem("Open Developer Tools", { type: "open-devtools" }),
75
80
  { type: "divider" },
76
81
  commandItem("Toggle Status Bar", { type: "toggle-status-bar" }),
77
82
  { type: "divider" },
@@ -1,13 +1,15 @@
1
1
  import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
2
- import type { PaneRuntimeState } from "../../../core/state/app-state";
2
+ import { clonePaneStateMap, syncConfigActiveLayoutState, type PaneRuntimeState } from "../../../core/state/app-state";
3
3
  import { cloneLayout, type AppConfig } from "../../../types/config";
4
4
  import type { DesktopSharedStateSnapshot } from "../../../types/desktop-window";
5
5
  import { detachPaneToFrame, dockPane, insertAtRootEdge, removePane } from "../../../plugins/pane-manager";
6
6
 
7
- function clonePaneStateMap(state: Record<string, PaneRuntimeState>): Record<string, PaneRuntimeState> {
8
- return Object.fromEntries(
9
- Object.entries(state).map(([paneId, paneState]) => [paneId, { ...paneState }]),
10
- );
7
+ function cloneSavedLayouts(config: AppConfig): AppConfig["layouts"] {
8
+ return config.layouts.map((entry) => ({
9
+ ...entry,
10
+ layout: cloneLayout(entry.layout),
11
+ paneState: entry.paneState ? clonePaneStateMap(entry.paneState) : entry.paneState,
12
+ }));
11
13
  }
12
14
 
13
15
  function filterPaneState(
@@ -20,17 +22,6 @@ function filterPaneState(
20
22
  );
21
23
  }
22
24
 
23
- function syncActiveLayout(config: AppConfig): AppConfig {
24
- return {
25
- ...config,
26
- layouts: config.layouts.map((entry, index) => (
27
- index === config.activeLayoutIndex
28
- ? { ...entry, layout: cloneLayout(config.layout) }
29
- : entry
30
- )),
31
- };
32
- }
33
-
34
25
  export interface DesktopWorkspace {
35
26
  getSnapshot(): DesktopSharedStateSnapshot;
36
27
  syncMainState(snapshot: DesktopSharedStateSnapshot): DesktopSharedStateSnapshot;
@@ -55,22 +46,37 @@ export function createDesktopWorkspace(
55
46
  config: AppConfig,
56
47
  sessionSnapshot: AppSessionSnapshot | null,
57
48
  ): DesktopWorkspace {
49
+ const savedPaneState = config.layouts[config.activeLayoutIndex]?.paneState ?? {};
50
+ const initialPaneState = filterPaneState(config.layout, clonePaneStateMap({
51
+ ...(sessionSnapshot?.paneState ?? {}),
52
+ ...savedPaneState,
53
+ }));
54
+ const initialFocusedPaneId = config.layouts[config.activeLayoutIndex]?.focusedPaneId
55
+ ?? sessionSnapshot?.focusedPaneId
56
+ ?? null;
57
+ const initialActivePanel = config.layouts[config.activeLayoutIndex]?.activePanel
58
+ ?? (sessionSnapshot?.activePanel === "right" ? "right" : "left");
58
59
  let sharedState: DesktopSharedStateSnapshot = {
59
- config: syncActiveLayout(config),
60
- paneState: filterPaneState(config.layout, clonePaneStateMap(sessionSnapshot?.paneState ?? {})),
61
- focusedPaneId: sessionSnapshot?.focusedPaneId ?? null,
62
- activePanel: sessionSnapshot?.activePanel === "right" ? "right" : "left",
60
+ config: syncConfigActiveLayoutState(config, initialPaneState, initialFocusedPaneId, initialActivePanel),
61
+ paneState: initialPaneState,
62
+ focusedPaneId: initialFocusedPaneId,
63
+ activePanel: initialActivePanel,
63
64
  statusBarVisible: sessionSnapshot?.statusBarVisible !== false,
64
65
  };
65
66
 
66
67
  const updateConfig = (nextConfig: AppConfig, options?: { layoutChanged?: boolean }) => {
67
- const syncedConfig = syncActiveLayout(nextConfig);
68
+ const syncedConfig = syncConfigActiveLayoutState(
69
+ nextConfig,
70
+ sharedState.paneState,
71
+ sharedState.focusedPaneId,
72
+ sharedState.activePanel,
73
+ );
68
74
  sharedState = {
69
75
  ...sharedState,
70
76
  config: {
71
77
  ...syncedConfig,
72
78
  layout: cloneLayout(syncedConfig.layout),
73
- layouts: syncedConfig.layouts.map((entry) => ({ ...entry, layout: cloneLayout(entry.layout) })),
79
+ layouts: cloneSavedLayouts(syncedConfig),
74
80
  },
75
81
  paneState: filterPaneState(syncedConfig.layout, sharedState.paneState),
76
82
  layoutChanged: options?.layoutChanged,
@@ -82,7 +88,7 @@ export function createDesktopWorkspace(
82
88
  config: {
83
89
  ...sharedState.config,
84
90
  layout: cloneLayout(sharedState.config.layout),
85
- layouts: sharedState.config.layouts.map((entry) => ({ ...entry, layout: cloneLayout(entry.layout) })),
91
+ layouts: cloneSavedLayouts(sharedState.config),
86
92
  },
87
93
  paneState: clonePaneStateMap(sharedState.paneState),
88
94
  focusedPaneId: sharedState.focusedPaneId,
@@ -94,12 +100,17 @@ export function createDesktopWorkspace(
94
100
  return {
95
101
  getSnapshot,
96
102
  syncMainState(snapshot) {
97
- const syncedConfig = syncActiveLayout(snapshot.config);
103
+ const syncedConfig = syncConfigActiveLayoutState(
104
+ snapshot.config,
105
+ snapshot.paneState,
106
+ snapshot.focusedPaneId,
107
+ snapshot.activePanel,
108
+ );
98
109
  sharedState = {
99
110
  config: {
100
111
  ...syncedConfig,
101
112
  layout: cloneLayout(syncedConfig.layout),
102
- layouts: syncedConfig.layouts.map((entry) => ({ ...entry, layout: cloneLayout(entry.layout) })),
113
+ layouts: cloneSavedLayouts(syncedConfig),
103
114
  },
104
115
  paneState: filterPaneState(syncedConfig.layout, clonePaneStateMap(snapshot.paneState)),
105
116
  focusedPaneId: snapshot.focusedPaneId,
@@ -113,12 +124,19 @@ export function createDesktopWorkspace(
113
124
  return updateConfig(config, options);
114
125
  },
115
126
  replaceDetachedPaneState(paneId, paneState) {
127
+ const nextPaneState = filterPaneState(sharedState.config.layout, {
128
+ ...sharedState.paneState,
129
+ [paneId]: { ...paneState },
130
+ });
116
131
  sharedState = {
117
132
  ...sharedState,
118
- paneState: filterPaneState(sharedState.config.layout, {
119
- ...sharedState.paneState,
120
- [paneId]: { ...paneState },
121
- }),
133
+ config: syncConfigActiveLayoutState(
134
+ sharedState.config,
135
+ nextPaneState,
136
+ sharedState.focusedPaneId,
137
+ sharedState.activePanel,
138
+ ),
139
+ paneState: nextPaneState,
122
140
  };
123
141
  return getSnapshot();
124
142
  },