gloomberb 0.7.1 → 0.7.3

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 (131) 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 +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +47 -7
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +67 -11
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. package/src/version.ts +1 -1
@@ -8,7 +8,9 @@ import {
8
8
  DEFAULT_TICKER_CHART_RESOLUTION,
9
9
  normalizeChartResolution,
10
10
  } from "../../../components/chart/chart-resolution";
11
+ import type { PriceSparklinePeriod } from "../../../components/price-sparkline-view";
11
12
  import { getSharedRegistry } from "../../registry";
13
+ import { formatTickerListInput, MAX_TICKER_LIST_SIZE, parseTickerListInput } from "../../../utils/ticker-list";
12
14
 
13
15
  type DetailTabSummary = { id: string; name: string; order: number };
14
16
 
@@ -24,6 +26,14 @@ export interface TickerDetailPaneSettings {
24
26
  chartResolution: ReturnType<typeof normalizeChartResolution>;
25
27
  }
26
28
 
29
+ export interface QuoteMonitorPaneSettings {
30
+ symbols: string[];
31
+ symbolsText: string;
32
+ chartPeriod: PriceSparklinePeriod;
33
+ }
34
+
35
+ const DEFAULT_QUOTE_MONITOR_CHART_PERIOD: PriceSparklinePeriod = "1M";
36
+
27
37
  export function getTickerDetailPaneSettings(
28
38
  settings: Record<string, unknown> | undefined,
29
39
  ): TickerDetailPaneSettings {
@@ -100,15 +110,86 @@ export function buildTickerDetailSettingsDef(settings: TickerDetailPaneSettings)
100
110
  };
101
111
  }
102
112
 
113
+ function coerceQuoteMonitorSymbols(value: unknown): string[] {
114
+ if (!Array.isArray(value)) return [];
115
+ const parsed = value.filter((entry): entry is string => typeof entry === "string");
116
+ try {
117
+ return parseTickerListInput(parsed.join(", "), MAX_TICKER_LIST_SIZE);
118
+ } catch {
119
+ return parsed.slice(0, MAX_TICKER_LIST_SIZE);
120
+ }
121
+ }
122
+
123
+ export function buildQuoteMonitorPaneTitle(symbols: string[]): string {
124
+ if (symbols.length === 0) return "Quote Monitor";
125
+ if (symbols.length <= 3) return symbols.join(" · ");
126
+ return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2}`;
127
+ }
128
+
129
+ export function getQuoteMonitorPaneSettings(
130
+ settings: Record<string, unknown> | undefined,
131
+ fallbackSymbol?: string | null,
132
+ ): QuoteMonitorPaneSettings {
133
+ const storedSymbols = coerceQuoteMonitorSymbols(settings?.symbols);
134
+ const legacySymbol = typeof settings?.symbol === "string" ? settings.symbol.trim() : "";
135
+ const storedText = typeof settings?.symbolsText === "string" ? settings.symbolsText : "";
136
+ let symbols = storedSymbols;
137
+
138
+ if (symbols.length === 0 && storedText.trim().length > 0) {
139
+ try {
140
+ symbols = parseTickerListInput(storedText, MAX_TICKER_LIST_SIZE);
141
+ } catch {
142
+ symbols = [];
143
+ }
144
+ }
145
+ if (symbols.length === 0 && legacySymbol) {
146
+ try {
147
+ symbols = parseTickerListInput(legacySymbol, MAX_TICKER_LIST_SIZE);
148
+ } catch {
149
+ symbols = [legacySymbol.toUpperCase()];
150
+ }
151
+ }
152
+ if (symbols.length === 0 && fallbackSymbol) {
153
+ symbols = [fallbackSymbol.toUpperCase()];
154
+ }
155
+
156
+ return {
157
+ symbols,
158
+ symbolsText: storedText.trim().length > 0 ? storedText : formatTickerListInput(symbols),
159
+ chartPeriod: settings?.chartPeriod === "1D"
160
+ || settings?.chartPeriod === "1W"
161
+ || settings?.chartPeriod === "1M"
162
+ || settings?.chartPeriod === "1Y"
163
+ ? settings.chartPeriod
164
+ : DEFAULT_QUOTE_MONITOR_CHART_PERIOD,
165
+ };
166
+ }
167
+
103
168
  export function buildQuoteMonitorSettingsDef(): PaneSettingsDef {
104
169
  return {
105
170
  title: "Quote Monitor Settings",
171
+ values: {
172
+ chartPeriod: DEFAULT_QUOTE_MONITOR_CHART_PERIOD,
173
+ },
106
174
  fields: [
107
175
  {
108
- key: "symbol",
109
- label: "Ticker",
176
+ key: "symbolsText",
177
+ label: "Tickers",
178
+ description: `Enter up to ${MAX_TICKER_LIST_SIZE} tickers separated by commas.`,
110
179
  type: "text",
111
- placeholder: "AAPL",
180
+ placeholder: "AAPL, MSFT, NVDA",
181
+ },
182
+ {
183
+ key: "chartPeriod",
184
+ label: "Chart Period",
185
+ description: "Set the period used for quote sparklines and range labels.",
186
+ type: "select",
187
+ options: [
188
+ { value: "1D", label: "1D" },
189
+ { value: "1W", label: "1W" },
190
+ { value: "1M", label: "1M" },
191
+ { value: "1Y", label: "1Y" },
192
+ ],
112
193
  },
113
194
  ],
114
195
  };
@@ -820,7 +820,7 @@ describe("TickerDetailPane", () => {
820
820
  expect(frame).toContain("+25.00%");
821
821
  });
822
822
 
823
- test("falls back to Overview when a hidden active tab becomes unavailable", async () => {
823
+ test("renders an Overview fallback without overwriting a temporarily unavailable active tab", async () => {
824
824
  const gatewayConfig = createDetailConfig("AAPL", [createGatewayInstance()]);
825
825
  const noGatewayConfig = createDetailConfig("AAPL");
826
826
  setSharedRegistryForTests(makeRegistry());
@@ -844,7 +844,7 @@ describe("TickerDetailPane", () => {
844
844
  await flushFrame();
845
845
 
846
846
  const frame = testSetup.captureCharFrame();
847
- expect(frame).toContain("active:overview");
847
+ expect(frame).toContain("active:ibkr-trade");
848
848
  expect(frame).not.toContain("Trade");
849
849
  });
850
850
 
@@ -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
  },