gloomberb 0.9.8 → 0.9.10

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 (181) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +3 -3
  3. package/src/api-client/index.ts +0 -6
  4. package/src/api-client/request.test.ts +63 -0
  5. package/src/api-client/request.ts +53 -5
  6. package/src/api-client/types.ts +0 -2
  7. package/src/app/pane-runtime/layout-placement.ts +0 -1
  8. package/src/app.tsx +1 -0
  9. package/src/brokers/account-cache.ts +2 -10
  10. package/src/brokers/broker-portfolio-sync.ts +0 -1
  11. package/src/brokers/broker-ticker-sync.ts +20 -16
  12. package/src/brokers/remote-broker-adapter.ts +3 -0
  13. package/src/capabilities/registry.test.ts +25 -0
  14. package/src/capabilities/registry.ts +22 -5
  15. package/src/cli/commands/automation.ts +1 -3
  16. package/src/cli/commands/command-utils.ts +0 -7
  17. package/src/cli/commands/overview.ts +2 -2
  18. package/src/cli/commands/plugins.ts +34 -17
  19. package/src/cli/index.ts +2 -2
  20. package/src/cli.test.ts +0 -157
  21. package/src/components/chart/comparison/renderer.ts +0 -1
  22. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  23. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  24. package/src/components/chart/core/index.test.ts +0 -24
  25. package/src/components/chart/core/renderer.ts +0 -4
  26. package/src/components/chart/core/resolution.test.ts +0 -17
  27. package/src/components/chart/core/resolution.ts +0 -26
  28. package/src/components/chart/core/scroll.test.ts +0 -6
  29. package/src/components/chart/core/scroll.ts +0 -22
  30. package/src/components/chart/core/viewport.test.ts +2 -23
  31. package/src/components/chart/core/viewport.ts +0 -27
  32. package/src/components/chart/market-session.ts +1 -1
  33. package/src/components/chart/native/surface/hooks.ts +10 -3
  34. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  35. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  36. package/src/components/command-bar/helpers.test.ts +1 -80
  37. package/src/components/command-bar/helpers.ts +1 -36
  38. package/src/components/command-bar/pane-settings.ts +1 -1
  39. package/src/components/command-bar/panel/runtime.ts +0 -2
  40. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  41. package/src/components/command-bar/surface/index.test.tsx +0 -16
  42. package/src/components/command-bar/surface/index.tsx +0 -1
  43. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  44. package/src/components/feed-data-table/stack-view.tsx +0 -1
  45. package/src/components/layout/shell/index.test.tsx +2 -113
  46. package/src/components/layout/shell/index.tsx +0 -1
  47. package/src/components/layout/shell/menu.ts +0 -1
  48. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  49. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  50. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  51. package/src/components/price-selector-dialog.tsx +3 -3
  52. package/src/components/ticker/badge/text.tsx +0 -1
  53. package/src/components/ui/page-stack-view.tsx +1 -1
  54. package/src/components/ui/ui.test.tsx +0 -78
  55. package/src/core/state/app/layout-reducer.ts +0 -1
  56. package/src/data/config/layout.ts +3 -3
  57. package/src/data/resource-store.ts +6 -0
  58. package/src/data/sqlite/cache.test.ts +5 -0
  59. package/src/data/ticker-store.ts +0 -7
  60. package/src/market-data/coordinator/auxiliary.ts +1 -44
  61. package/src/market-data/coordinator/entries.ts +0 -1
  62. package/src/market-data/coordinator/index.test.ts +0 -10
  63. package/src/market-data/coordinator/index.ts +1 -18
  64. package/src/market-data/coordinator/quotes.ts +0 -6
  65. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  66. package/src/market-data/market/status.ts +19 -1
  67. package/src/market-data/query-store.ts +0 -4
  68. package/src/market-data/quotes/resolution.ts +2 -2
  69. package/src/market-data/request-types.ts +0 -5
  70. package/src/market-data/result-types.ts +0 -11
  71. package/src/market-data/selectors.ts +1 -5
  72. package/src/news/aggregator.ts +5 -1
  73. package/src/pane-settings.test.ts +0 -18
  74. package/src/plugins/builtin/account-management/form-components.tsx +0 -120
  75. package/src/plugins/builtin/account-management/model.ts +3 -27
  76. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  77. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  78. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  79. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  80. package/src/plugins/builtin/buildout/format.ts +2 -11
  81. package/src/plugins/builtin/chat/chat.test.tsx +2 -1
  82. package/src/plugins/builtin/chat/content/index.tsx +1 -2
  83. package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
  84. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  85. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  86. package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
  87. package/src/plugins/builtin/chat/test-harness.tsx +2 -2
  88. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  89. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  90. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  91. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  92. package/src/plugins/builtin/earnings/index.tsx +0 -9
  93. package/src/plugins/builtin/econ/index.tsx +1 -5
  94. package/src/plugins/builtin/help/index.tsx +2 -2
  95. package/src/plugins/builtin/holders/pane.tsx +0 -5
  96. package/src/plugins/builtin/insider/index.tsx +1 -8
  97. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  98. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  99. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  100. package/src/plugins/builtin/news/index.tsx +0 -1
  101. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  102. package/src/plugins/builtin/options/view.test.tsx +1 -38
  103. package/src/plugins/builtin/plugin-groups.ts +17 -3
  104. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  105. package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
  106. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  107. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  108. package/src/plugins/builtin/sec/index.tsx +0 -2
  109. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  110. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  111. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  112. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
  113. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  114. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  115. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  116. package/src/plugins/builtin/world-indices/model.ts +0 -7
  117. package/src/plugins/ibkr/account-cache.ts +1 -1
  118. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  119. package/src/plugins/ibkr/trade/utils.ts +1 -7
  120. package/src/plugins/loader.ts +2 -1
  121. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  122. package/src/plugins/registry/contributions.ts +22 -11
  123. package/src/plugins/registry/index.test.ts +44 -0
  124. package/src/plugins/registry/index.ts +44 -26
  125. package/src/remote/controller.ts +1 -7
  126. package/src/remote/json-patch.ts +15 -1
  127. package/src/remote/server.ts +16 -6
  128. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  129. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  130. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  131. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  132. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  133. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  134. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  135. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  136. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  137. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  138. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  139. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  140. package/src/renderers/opentui/host.tsx +11 -3
  141. package/src/renderers/opentui/input-host.tsx +5 -1
  142. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  143. package/src/sources/gloomberb-cloud/index.ts +1 -30
  144. package/src/sources/gloomberb-cloud/news.ts +0 -12
  145. package/src/sources/provider-router/financial-routes.ts +25 -3
  146. package/src/sources/provider-router/index.test.ts +167 -0
  147. package/src/sources/provider-router/search.ts +5 -0
  148. package/src/sources/provider-router/supplemental.ts +4 -3
  149. package/src/sources/yahoo-finance/history.ts +5 -1
  150. package/src/sources/yahoo-finance/options.ts +59 -14
  151. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  152. package/src/sources/yahoo-finance.test.ts +40 -21
  153. package/src/sources/yahoo-finance.ts +13 -8
  154. package/src/state/app/context/index.test.tsx +0 -81
  155. package/src/state/app/context/selector.test.tsx +6 -2
  156. package/src/sync/controller.test.ts +151 -0
  157. package/src/sync/controller.ts +92 -57
  158. package/src/sync/core-contributors.ts +45 -21
  159. package/src/sync/react.ts +6 -8
  160. package/src/sync/types.ts +3 -0
  161. package/src/test-support/data-provider.ts +0 -1
  162. package/src/ui/host.tsx +2 -1
  163. package/src/ui/index.tsx +1 -0
  164. package/src/updater.test.ts +0 -21
  165. package/src/updater.ts +20 -16
  166. package/src/utils/async-deadline.ts +26 -0
  167. package/src/utils/selection-clipboard.ts +2 -2
  168. package/src/version.ts +1 -1
  169. package/src/components/layout/pane/header.test.tsx +0 -39
  170. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  171. package/src/plugins/builtin/chat/index.tsx +0 -4
  172. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  173. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  174. package/src/plugins/builtin/help/index.test.tsx +0 -242
  175. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  176. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  177. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  178. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  179. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  180. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  181. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -37,6 +37,11 @@ interface RegistryContributionsOptions {
37
37
  wrapBrokerAdapter?: (broker: BrokerAdapter, pluginId: string) => BrokerAdapter;
38
38
  }
39
39
 
40
+ function setUnique<T>(map: Map<string, T>, id: string, value: T): void {
41
+ if (map.has(id)) throw new Error(`Duplicate plugin contribution id: ${id}`);
42
+ map.set(id, value);
43
+ }
44
+
40
45
  export class RegistryContributions {
41
46
  readonly pluginItems = new Map<string, PluginItems>();
42
47
  readonly commandOwners = new Map<string, string>();
@@ -82,30 +87,30 @@ export class RegistryContributions {
82
87
  }
83
88
 
84
89
  registerPane(pluginId: string, pane: PaneDef, items = this.getOrCreatePluginItems(pluginId)): void {
85
- this.panesMap.set(pane.id, this.options.wrapPaneDef(pluginId, pane));
90
+ setUnique(this.panesMap, pane.id, this.options.wrapPaneDef(pluginId, pane));
86
91
  this.paneOwners.set(pane.id, pluginId);
87
92
  items.panes.push(pane.id);
88
93
  }
89
94
 
90
95
  registerPaneTemplate(pluginId: string, template: PaneTemplateDef, items = this.getOrCreatePluginItems(pluginId)): void {
91
- this.paneTemplatesMap.set(template.id, template);
96
+ setUnique(this.paneTemplatesMap, template.id, template);
92
97
  this.paneTemplateOwners.set(template.id, pluginId);
93
98
  items.paneTemplates.push(template.id);
94
99
  }
95
100
 
96
101
  registerCommand(pluginId: string, command: CommandDef, items = this.getOrCreatePluginItems(pluginId)): void {
97
- this.commandsMap.set(command.id, command);
102
+ setUnique(this.commandsMap, command.id, command);
98
103
  this.commandOwners.set(command.id, pluginId);
99
104
  items.commands.push(command.id);
100
105
  }
101
106
 
102
107
  registerColumn(_pluginId: string, column: CustomColumnDef, items: PluginItems): void {
103
- this.columnsMap.set(column.id, column);
108
+ setUnique(this.columnsMap, column.id, column);
104
109
  items.columns.push(column.id);
105
110
  }
106
111
 
107
112
  registerBroker(pluginId: string, broker: BrokerAdapter, items = this.getOrCreatePluginItems(pluginId)): void {
108
- this.brokersMap.set(broker.id, this.options.wrapBrokerAdapter?.(broker, pluginId) ?? broker);
113
+ setUnique(this.brokersMap, broker.id, this.options.wrapBrokerAdapter?.(broker, pluginId) ?? broker);
109
114
  items.brokers.push(broker.id);
110
115
  }
111
116
 
@@ -115,19 +120,19 @@ export class RegistryContributions {
115
120
  }
116
121
 
117
122
  registerTickerResearchTab(pluginId: string, tab: TickerResearchTabDef, items = this.getOrCreatePluginItems(pluginId)): void {
118
- this.tickerResearchTabsMap.set(tab.id, this.options.wrapTickerResearchTabDef(pluginId, tab));
123
+ setUnique(this.tickerResearchTabsMap, tab.id, this.options.wrapTickerResearchTabDef(pluginId, tab));
119
124
  this.tickerResearchTabOwners.set(tab.id, pluginId);
120
125
  items.tickerResearchTabs.push(tab.id);
121
126
  }
122
127
 
123
128
  registerShortcut(pluginId: string, shortcut: KeyboardShortcut, items = this.getOrCreatePluginItems(pluginId)): void {
124
- this.shortcutsMap.set(shortcut.id, shortcut);
129
+ setUnique(this.shortcutsMap, shortcut.id, shortcut);
125
130
  this.shortcutOwners.set(shortcut.id, pluginId);
126
131
  items.shortcuts.push(shortcut.id);
127
132
  }
128
133
 
129
134
  registerTickerAction(_pluginId: string, action: TickerAction, items: PluginItems): void {
130
- this.tickerActionsMap.set(action.id, action);
135
+ setUnique(this.tickerActionsMap, action.id, action);
131
136
  items.tickerActions.push(action.id);
132
137
  }
133
138
 
@@ -166,9 +171,15 @@ export class RegistryContributions {
166
171
  }
167
172
  for (const actionId of items.tickerActions) this.tickerActionsMap.delete(actionId);
168
173
  for (const providerKey of items.contextMenuProviders) this.contextMenuProvidersMap.delete(providerKey);
169
- for (const dispose of items.eventDisposers) dispose();
170
- for (const dispose of items.capabilityDisposers) dispose();
171
- for (const dispose of items.newsQueryWatchDisposers) dispose();
174
+ let disposeError: unknown;
175
+ for (const dispose of [...items.eventDisposers, ...items.capabilityDisposers, ...items.newsQueryWatchDisposers]) {
176
+ try {
177
+ dispose();
178
+ } catch (error) {
179
+ disposeError ??= error;
180
+ }
181
+ }
172
182
  this.pluginItems.delete(pluginId);
183
+ if (disposeError) throw disposeError;
173
184
  }
174
185
  }
@@ -70,6 +70,49 @@ afterEach(() => {
70
70
  currentPersistence = null;
71
71
  });
72
72
 
73
+ describe("PluginRegistry lifecycle", () => {
74
+ test("rolls back rejected registrations and always removes owned contributions", async () => {
75
+ const registry = createRegistry();
76
+ const pane = {
77
+ id: "shared-pane",
78
+ name: "Shared",
79
+ component: () => null,
80
+ defaultPosition: "right" as const,
81
+ };
82
+
83
+ await expect(registry.register({
84
+ id: "setup-failure",
85
+ name: "Setup failure",
86
+ version: "1.0.0",
87
+ panes: [pane],
88
+ setup: () => { throw new Error("setup failed"); },
89
+ })).rejects.toThrow("setup failed");
90
+ expect(registry.allPlugins.has("setup-failure")).toBe(false);
91
+ expect(registry.panes.has("shared-pane")).toBe(false);
92
+
93
+ await registry.register({ id: "owner", name: "Owner", version: "1.0.0", panes: [pane] });
94
+ await expect(registry.register({ id: "collision", name: "Collision", version: "1.0.0", panes: [pane] }))
95
+ .rejects.toThrow("Duplicate plugin contribution id");
96
+ expect(registry.panes.get("shared-pane")?.name).toBe("Shared");
97
+
98
+ await registry.register({
99
+ id: "throwing-dispose",
100
+ name: "Throwing dispose",
101
+ version: "1.0.0",
102
+ panes: [{
103
+ id: "disposable-pane",
104
+ name: "Disposable",
105
+ component: () => null,
106
+ defaultPosition: "right",
107
+ }],
108
+ dispose: () => { throw new Error("dispose failed"); },
109
+ });
110
+ expect(() => registry.unregister("throwing-dispose")).toThrow("dispose failed");
111
+ expect(registry.allPlugins.has("throwing-dispose")).toBe(false);
112
+ expect(registry.panes.has("disposable-pane")).toBe(false);
113
+ });
114
+ });
115
+
73
116
  describe("PluginRegistry context menu providers", () => {
74
117
  test("registers and unregisters context menu providers", async () => {
75
118
  const registry = createRegistry();
@@ -234,6 +277,7 @@ describe("PluginRegistry pane settings", () => {
234
277
  settings: {},
235
278
  }],
236
279
  floating: [],
280
+ detached: [],
237
281
  };
238
282
  registry.getConfigFn = () => config;
239
283
  registry.getLayoutFn = () => config.layout;
@@ -441,35 +441,47 @@ export class PluginRegistry implements PluginRuntimeAccess {
441
441
 
442
442
  async register(plugin: GloomPlugin): Promise<void> {
443
443
  this.registryLog.info(`Registering plugin: ${plugin.id} v${plugin.version ?? "?"}`);
444
- const items = this.contributions.getOrCreatePluginItems(plugin.id);
445
- if (plugin.panes) {
446
- for (const pane of plugin.panes) {
447
- this.contributions.registerPane(plugin.id, pane, items);
444
+ if (this.plugins.has(plugin.id)) throw new Error(`Plugin already registered: ${plugin.id}`);
445
+ this.plugins.set(plugin.id, plugin);
446
+ try {
447
+ const items = this.contributions.getOrCreatePluginItems(plugin.id);
448
+ if (plugin.panes) {
449
+ for (const pane of plugin.panes) {
450
+ this.contributions.registerPane(plugin.id, pane, items);
451
+ }
448
452
  }
449
- }
450
453
 
451
- if (plugin.paneTemplates) {
452
- for (const template of plugin.paneTemplates) {
453
- this.contributions.registerPaneTemplate(plugin.id, template, items);
454
+ if (plugin.paneTemplates) {
455
+ for (const template of plugin.paneTemplates) {
456
+ this.contributions.registerPaneTemplate(plugin.id, template, items);
457
+ }
454
458
  }
455
- }
456
-
457
- if (plugin.broker) {
458
- this.contributions.registerBroker(plugin.id, plugin.broker, items);
459
- }
460
459
 
461
- if (this.enableCapabilityHandlers && plugin.capabilities) {
462
- for (const capability of plugin.capabilities) {
463
- this.registerCapabilityForPlugin(plugin.id, capability, items);
460
+ if (plugin.broker) {
461
+ this.contributions.registerBroker(plugin.id, plugin.broker, items);
464
462
  }
465
- }
466
463
 
467
- this.slots.register(plugin, this);
464
+ if (this.enableCapabilityHandlers && plugin.capabilities) {
465
+ for (const capability of plugin.capabilities) {
466
+ this.registerCapabilityForPlugin(plugin.id, capability, items);
467
+ }
468
+ }
468
469
 
469
- this.plugins.set(plugin.id, plugin);
470
+ this.slots.register(plugin, this);
470
471
 
471
- if (plugin.setup) {
472
- await plugin.setup(this.createContext(plugin.id));
472
+ if (plugin.setup) {
473
+ await plugin.setup(this.createContext(plugin.id));
474
+ }
475
+ } catch (error) {
476
+ try {
477
+ this.removePlugin(plugin.id);
478
+ } catch (cleanupError) {
479
+ this.registryLog.error("Failed to clean up rejected plugin registration", {
480
+ pluginId: plugin.id,
481
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
482
+ });
483
+ }
484
+ throw error;
473
485
  }
474
486
 
475
487
  this.events.emit("plugin:registered", { pluginId: plugin.id });
@@ -480,13 +492,19 @@ export class PluginRegistry implements PluginRuntimeAccess {
480
492
  if (!plugin) return;
481
493
  this.registryLog.info(`Unregistering plugin: ${pluginId}`);
482
494
 
483
- plugin.dispose?.();
484
- this.slots.unregister(pluginId);
485
-
486
- this.contributions.unregister(pluginId);
495
+ this.removePlugin(pluginId);
496
+ this.events.emit("plugin:unregistered", { pluginId });
497
+ }
487
498
 
499
+ private removePlugin(pluginId: string): void {
500
+ const plugin = this.plugins.get(pluginId);
501
+ if (!plugin) return;
502
+ let cleanupError: unknown;
503
+ try { plugin.dispose?.(); } catch (error) { cleanupError = error; }
504
+ try { this.slots.unregister(pluginId); } catch (error) { cleanupError ??= error; }
505
+ try { this.contributions.unregister(pluginId); } catch (error) { cleanupError ??= error; }
488
506
  this.plugins.delete(pluginId);
489
- this.events.emit("plugin:unregistered", { pluginId });
507
+ if (cleanupError) throw cleanupError;
490
508
  }
491
509
 
492
510
  destroy(): void {
@@ -51,7 +51,6 @@ interface AppRemoteControllerOptions {
51
51
  }
52
52
 
53
53
  const DEFAULT_MUTATION_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
54
- const DEFAULT_BATCH_INCLUDE: RemoteStateInclude[] = ["app", "layout", "panes", "commandBar"];
55
54
 
56
55
  export function createAppRemoteController({
57
56
  dispatch,
@@ -68,11 +67,6 @@ export function createAppRemoteController({
68
67
  uiRegistry,
69
68
  });
70
69
 
71
- const getAfterMutation = async (resource: string): Promise<unknown> => {
72
- await afterMutation();
73
- return getResource(resource);
74
- };
75
-
76
70
  const getAfterMutationSummary = async (extra?: Record<string, unknown>): Promise<unknown> => {
77
71
  await afterMutation();
78
72
  return mutationSummary(getState(), extra);
@@ -420,7 +414,7 @@ export function createAppRemoteController({
420
414
  ok: responses.every((response) => response.ok),
421
415
  haltedAt,
422
416
  responses,
423
- }, undefined, buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_BATCH_INCLUDE));
417
+ }, undefined, buildIncludedState(request.include, request.dryRun ? [] : DEFAULT_MUTATION_INCLUDE));
424
418
  }
425
419
  default:
426
420
  throw new Error(`Unknown remote request type: ${(request as { type?: unknown }).type}`);
@@ -12,6 +12,14 @@ function pointerSegments(path: string): string[] {
12
12
  return path.slice(1).split("/").map(decodePointerSegment);
13
13
  }
14
14
 
15
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
16
+
17
+ function assertSafeObjectKey(key: string): void {
18
+ if (UNSAFE_OBJECT_KEYS.has(key)) {
19
+ throw new Error(`Unsafe object key in JSON patch path: ${key}`);
20
+ }
21
+ }
22
+
15
23
  function cloneJson<T>(value: T): T {
16
24
  return value == null ? value : JSON.parse(JSON.stringify(value)) as T;
17
25
  }
@@ -30,12 +38,18 @@ function containerFor(root: unknown, path: string): { parent: unknown; key: stri
30
38
  }
31
39
  parent = parent[index];
32
40
  } else if (parent && typeof parent === "object") {
41
+ assertSafeObjectKey(segment);
42
+ if (!Object.prototype.hasOwnProperty.call(parent, segment)) {
43
+ throw new Error(`Cannot descend into missing object key: ${segment}`);
44
+ }
33
45
  parent = (parent as Record<string, unknown>)[segment];
34
46
  } else {
35
47
  throw new Error(`Cannot descend into non-object patch path: ${path}`);
36
48
  }
37
49
  }
38
- return { parent, key: segments[segments.length - 1]! };
50
+ const key = segments[segments.length - 1]!;
51
+ if (!Array.isArray(parent)) assertSafeObjectKey(key);
52
+ return { parent, key };
39
53
  }
40
54
 
41
55
  function applyOperation(root: unknown, operation: RemoteJsonPatchOperation): void {
@@ -45,21 +45,31 @@ export async function startRemoteControlServer(options: RemoteControlServerOptio
45
45
  token,
46
46
  startedAt: new Date().toISOString(),
47
47
  };
48
- await writeRemoteEndpointFiles(options.dataDir, endpoint);
48
+ try {
49
+ await writeRemoteEndpointFiles(options.dataDir, endpoint);
50
+ } catch (error) {
51
+ await closeHttpServer(httpServer);
52
+ await removeRemoteEndpointFiles(options.dataDir, endpoint).catch(() => {});
53
+ throw error;
54
+ }
49
55
 
50
56
  return {
51
57
  endpoint,
52
58
  close: async () => {
53
- await new Promise<void>((resolve, reject) => {
54
- httpServer.close((error) => (error ? reject(error) : resolve()));
55
- }).catch((error) => {
56
- if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
57
- });
59
+ await closeHttpServer(httpServer);
58
60
  await removeRemoteEndpointFiles(options.dataDir, endpoint);
59
61
  },
60
62
  };
61
63
  }
62
64
 
65
+ async function closeHttpServer(httpServer: ReturnType<typeof createServer>): Promise<void> {
66
+ await new Promise<void>((resolve, reject) => {
67
+ httpServer.close((error) => (error ? reject(error) : resolve()));
68
+ }).catch((error) => {
69
+ if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
70
+ });
71
+ }
72
+
63
73
  async function handleHttpRequest(
64
74
  handle: RemoteControlServerOptions["handle"],
65
75
  token: string,
@@ -26,7 +26,7 @@ function openCommandBar(label: string, query: string, options?: { accelerator?:
26
26
  return commandItem(label, { type: "open-command-bar", query }, options);
27
27
  }
28
28
 
29
- export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
29
+ function buildApplicationMenu(): ApplicationMenuItemConfig[] {
30
30
  return [
31
31
  {
32
32
  label: "Gloomberb",
@@ -19,6 +19,7 @@ const NOTES_INDEX_FILE = "__quick-notes-index__.json";
19
19
  const BROKER_INVOKE_OPERATIONS = new Set([
20
20
  "validate",
21
21
  "importPositions",
22
+ "importPortfolioSnapshot",
22
23
  "connect",
23
24
  "disconnect",
24
25
  "getPersistedConfigUpdate",
@@ -96,6 +97,7 @@ function invokeBrokerOperation(
96
97
  switch (operation) {
97
98
  case "validate":
98
99
  case "importPositions":
100
+ case "importPortfolioSnapshot":
99
101
  case "connect":
100
102
  case "disconnect":
101
103
  case "getPersistedConfigUpdate":
@@ -39,4 +39,18 @@ describe("handleHttpFetch", () => {
39
39
  expect(response.headers.location).toBe("/reader");
40
40
  expect(response.setCookie).toEqual(["substack.sid=sid123; Path=/; HttpOnly"]);
41
41
  });
42
+
43
+ test("aborts the backend fetch at the requested deadline", async () => {
44
+ server = Bun.serve({
45
+ port: 0,
46
+ fetch() {
47
+ return new Promise<Response>(() => {});
48
+ },
49
+ });
50
+
51
+ await expect(handleHttpFetch({
52
+ url: server.url.toString(),
53
+ init: { timeoutMs: 10 },
54
+ })).rejects.toThrow();
55
+ });
42
56
  });
@@ -34,12 +34,20 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
34
34
  typeof init.body === "string" && method !== "GET" && method !== "HEAD"
35
35
  ? init.body
36
36
  : undefined;
37
+ const timeoutMs =
38
+ typeof init.timeoutMs === "number"
39
+ && Number.isFinite(init.timeoutMs)
40
+ && init.timeoutMs > 0
41
+ && init.timeoutMs <= 120_000
42
+ ? init.timeoutMs
43
+ : undefined;
37
44
 
38
45
  const response = await fetch(url, {
39
46
  method,
40
47
  headers: normalizeHttpFetchHeaders(init.headers),
41
48
  body,
42
49
  redirect,
50
+ signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined,
43
51
  });
44
52
  const responseHeaders: Record<string, string> = {};
45
53
  response.headers.forEach((value, key) => {
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync } from "fs";
2
+ import { homedir } from "os";
2
3
  import { join } from "path";
3
4
  import { createAppServices, type AppServices } from "../../../../core/app-services";
4
5
  import type { AppSessionSnapshot } from "../../../../core/state/session-persistence";
@@ -98,7 +99,7 @@ function buildInitializationPayload(
98
99
  }
99
100
 
100
101
  async function resolveDesktopDataDir(): Promise<string> {
101
- const dataDir = await getDataDir() ?? join(process.env.HOME || "~", ".gloomberb");
102
+ const dataDir = await getDataDir() ?? join(process.env.HOME || homedir(), ".gloomberb");
102
103
  if (!existsSync(dataDir)) {
103
104
  mkdirSync(dataDir, { recursive: true });
104
105
  }
@@ -261,6 +261,7 @@ function createRuntime(payload: CliPaneShotPayload): PluginRuntimeAccess {
261
261
  switchPanel: () => {},
262
262
  openCommandBar: () => {},
263
263
  showPane: () => {},
264
+ createPaneFromTemplate: () => {},
264
265
  hidePane: () => {},
265
266
  focusPane: () => {},
266
267
  openPaneSettings: () => {},
@@ -19,7 +19,6 @@ export const CSS_TEXT_DIM = "var(--gloom-text-dim)";
19
19
  export const CSS_TEXT_BRIGHT = "var(--gloom-text-bright)";
20
20
  export const CSS_SELECTED = "var(--gloom-selected)";
21
21
  export const CSS_SELECTED_TEXT = "var(--gloom-selected-text)";
22
- export const CSS_HOVER_BG = "var(--gloom-hover-bg)";
23
22
 
24
23
  function hasAttribute(attributes: unknown, flag: number): boolean {
25
24
  return typeof attributes === "number" && (attributes & flag) !== 0;
@@ -90,17 +90,6 @@ export function callCellMouseHandler(handler: unknown, event: CellMouseEvent): v
90
90
  (handler as (event: unknown) => void)(event);
91
91
  }
92
92
 
93
- export function hasDirectMouseHandler(props: Record<string, unknown>): boolean {
94
- return typeof props.onMouseDown === "function"
95
- || typeof props.onMouseOver === "function"
96
- || typeof props.onMouseUp === "function"
97
- || typeof props.onMouseMove === "function"
98
- || typeof props.onMouseOut === "function"
99
- || typeof props.onMouseDrag === "function"
100
- || typeof props.onMouseDragEnd === "function"
101
- || typeof props.onMouseScroll === "function";
102
- }
103
-
104
93
  export function cellBoundsForElement(
105
94
  getElement: () => HTMLElement | null,
106
95
  getProps: () => Record<string, unknown>,
@@ -11,6 +11,8 @@ interface ElectrobunHttpFetchResponse {
11
11
  body: string;
12
12
  }
13
13
 
14
+ const CLOUD_MARKET_HTTP_TIMEOUT_MS = 10_000;
15
+
14
16
  function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
15
17
  const normalized: Record<string, string> = {};
16
18
  if (!headers) return normalized;
@@ -75,7 +77,11 @@ async function electrobunHttpFetch(url: string, init?: RequestInit): Promise<Res
75
77
  return fetchResponse;
76
78
  }
77
79
 
78
- async function requestBackendHttpFetch(url: string, init?: RequestInit): Promise<ElectrobunHttpFetchResponse> {
80
+ async function requestBackendHttpFetch(
81
+ url: string,
82
+ init?: RequestInit,
83
+ timeoutMs?: number,
84
+ ): Promise<ElectrobunHttpFetchResponse> {
79
85
  return backendRequest<ElectrobunHttpFetchResponse>("http.fetch", {
80
86
  url,
81
87
  init: {
@@ -83,6 +89,7 @@ async function requestBackendHttpFetch(url: string, init?: RequestInit): Promise
83
89
  headers: normalizeHeaders(init?.headers),
84
90
  body: await serializeBody(init?.body),
85
91
  redirect: init?.redirect,
92
+ timeoutMs,
86
93
  },
87
94
  });
88
95
  }
@@ -100,7 +107,13 @@ function createResponseHeaders(headers: Record<string, string>, setCookie: strin
100
107
  }
101
108
 
102
109
  async function electrobunCloudApiFetch(url: string, init?: RequestInit): Promise<Response> {
103
- const response = await requestBackendHttpFetch(url, init);
110
+ if (init?.signal?.aborted) {
111
+ throw createAbortError();
112
+ }
113
+ const timeoutMs = new URL(url).pathname.startsWith("/market/")
114
+ ? CLOUD_MARKET_HTTP_TIMEOUT_MS
115
+ : undefined;
116
+ const response = await withAbort(requestBackendHttpFetch(url, init, timeoutMs), init?.signal);
104
117
 
105
118
  return {
106
119
  ok: response.status >= 200 && response.status < 300,
@@ -3,9 +3,11 @@ import type { NewsArticle, NewsQuery } from "../../../../news/types";
3
3
  import type { DataProvider, QuoteSubscriptionTarget } from "../../../../types/data-provider";
4
4
  import type { Quote, TickerFinancials } from "../../../../types/financials";
5
5
  import { backendRequest, getElectrobunBackendInitSnapshot, onCapabilityEvent } from "../backend-rpc";
6
+ import { createCapabilityInvoker } from "./capability-invoker";
6
7
 
7
8
  const ASSET_DATA_CAPABILITY_ID = "asset-data.asset-data-router";
8
9
  const NEWS_CAPABILITY_ID = "news.core";
10
+ const ASSET_DATA_REQUEST_TIMEOUT_MS = 115_000;
9
11
 
10
12
  export type RemoteAssetDataClient = DataProvider & {
11
13
  getNews(query: NewsQuery): Promise<NewsArticle[]>;
@@ -64,23 +66,12 @@ function hasRendererOperation(operations: Set<string> | null, operationId: strin
64
66
  return operations === null || operations.has(operationId);
65
67
  }
66
68
 
67
- function createCapabilityInvoker() {
68
- const inFlightRequests = new Map<string, Promise<unknown>>();
69
- return function invoke<T>(capabilityId: string, operationId: string, payload: unknown): Promise<T> {
70
- const requestPayload = { capabilityId, operationId, payload };
71
- const key = JSON.stringify(requestPayload);
72
- const inFlight = inFlightRequests.get(key);
73
- if (inFlight) return inFlight as Promise<T>;
74
- const promise = backendRequest<T>("capability.invoke", requestPayload).finally(() => {
75
- inFlightRequests.delete(key);
76
- });
77
- inFlightRequests.set(key, promise);
78
- return promise;
79
- };
80
- }
81
-
82
69
  export function createRemoteAssetDataClient(): RemoteAssetDataClient {
83
- const invoke = createCapabilityInvoker();
70
+ const invoke = createCapabilityInvoker({
71
+ request: backendRequest,
72
+ shouldApplyDeadline: (capabilityId) => capabilityId === ASSET_DATA_CAPABILITY_ID,
73
+ timeoutMs: ASSET_DATA_REQUEST_TIMEOUT_MS,
74
+ });
84
75
  const assetDataOperations = getRendererOperationIds(ASSET_DATA_CAPABILITY_ID);
85
76
  const newsOperations = getRendererOperationIds(NEWS_CAPABILITY_ID);
86
77
  let nextSubscriptionId = 1;
@@ -93,7 +84,7 @@ export function createRemoteAssetDataClient(): RemoteAssetDataClient {
93
84
  let quoteBackendFlushTimer: ReturnType<typeof setTimeout> | null = null;
94
85
 
95
86
  const quoteTargetKey = (target: QuoteSubscriptionTarget) =>
96
- `${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}:${target.context?.instrument?.conId ?? target.context?.instrument?.localSymbol ?? ""}`;
87
+ `${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}:${target.context?.instrument?.conId ?? target.context?.instrument?.localSymbol ?? target.context?.instrument?.symbol ?? ""}`;
97
88
 
98
89
  const scheduleQuoteBackendSubscriptionFlush = () => {
99
90
  if (quoteBackendFlushTimer) return;
@@ -156,7 +147,7 @@ export function createRemoteAssetDataClient(): RemoteAssetDataClient {
156
147
  if (!hasRendererOperation(assetDataOperations, "subscribeQuotes")) return () => {};
157
148
  const uniqueTargets = [...new Map(
158
149
  targets.map((target) => [
159
- `${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}`,
150
+ quoteTargetKey(target),
160
151
  target,
161
152
  ] as const),
162
153
  ).values()];
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createCapabilityInvoker } from "./capability-invoker";
3
+
4
+ describe("createCapabilityInvoker", () => {
5
+ test("keeps timed-out calls deduped until the raw backend request settles", async () => {
6
+ let requestCount = 0;
7
+ const rawResolvers: Array<(value: unknown) => void> = [];
8
+ const invoke = createCapabilityInvoker({
9
+ request: <T>() => {
10
+ requestCount += 1;
11
+ return new Promise<T>((resolve) => {
12
+ rawResolvers.push((value) => resolve(value as T));
13
+ });
14
+ },
15
+ shouldApplyDeadline: (capabilityId) => capabilityId.startsWith("asset-data."),
16
+ timeoutMs: 10,
17
+ });
18
+ const payload = { ticker: "AAPL", exchange: "NASDAQ" };
19
+
20
+ const first = invoke("asset-data.asset-data-router", "getQuote", payload);
21
+ const duplicate = invoke("asset-data.asset-data-router", "getQuote", payload);
22
+ expect(duplicate).toBe(first);
23
+ expect(requestCount).toBe(1);
24
+ await expect(first).rejects.toThrow("Asset data request timed out after 10ms");
25
+
26
+ const retryWhileRawPending = invoke(
27
+ "asset-data.asset-data-router",
28
+ "getQuote",
29
+ payload,
30
+ );
31
+ expect(retryWhileRawPending).toBe(first);
32
+ await expect(retryWhileRawPending).rejects.toThrow(
33
+ "Asset data request timed out after 10ms",
34
+ );
35
+ expect(requestCount).toBe(1);
36
+
37
+ rawResolvers[0]?.({ price: 100 });
38
+ await Promise.resolve();
39
+ await Promise.resolve();
40
+
41
+ const retryAfterRawSettles = invoke(
42
+ "asset-data.asset-data-router",
43
+ "getQuote",
44
+ payload,
45
+ );
46
+ expect(requestCount).toBe(2);
47
+ rawResolvers[1]?.({ price: 101 });
48
+ await expect(retryAfterRawSettles).resolves.toEqual({ price: 101 });
49
+ });
50
+ });
@@ -0,0 +1,36 @@
1
+ import { withDeadline } from "../../../../utils/async-deadline";
2
+
3
+ type CapabilityRequest = <T>(method: string, payload: unknown) => Promise<T>;
4
+
5
+ export function createCapabilityInvoker(options: {
6
+ request: CapabilityRequest;
7
+ shouldApplyDeadline(capabilityId: string): boolean;
8
+ timeoutMs: number;
9
+ }) {
10
+ const inFlightRequests = new Map<string, Promise<unknown>>();
11
+
12
+ return function invoke<T>(capabilityId: string, operationId: string, payload: unknown): Promise<T> {
13
+ const requestPayload = { capabilityId, operationId, payload };
14
+ const key = JSON.stringify(requestPayload);
15
+ const inFlight = inFlightRequests.get(key);
16
+ if (inFlight) return inFlight as Promise<T>;
17
+
18
+ const request = options.request<T>("capability.invoke", requestPayload);
19
+ const boundedRequest = options.shouldApplyDeadline(capabilityId)
20
+ ? withDeadline(
21
+ request,
22
+ options.timeoutMs,
23
+ `Asset data request timed out after ${options.timeoutMs}ms: ${operationId}`,
24
+ )
25
+ : request;
26
+ inFlightRequests.set(key, boundedRequest);
27
+ void request.finally(() => {
28
+ if (inFlightRequests.get(key) === boundedRequest) {
29
+ inFlightRequests.delete(key);
30
+ }
31
+ }).catch(() => {
32
+ // The returned bounded request owns error delivery to the caller.
33
+ });
34
+ return boundedRequest;
35
+ };
36
+ }