gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -13,7 +13,7 @@ import * as nodeConfigStoreHost from "../../../data/config-store-node";
13
13
  import { cloneLayout, findPaneInstance, type AppConfig } from "../../../types/config";
14
14
  import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
15
15
  import type { PaneRuntimeState } from "../../../core/state/app-state";
16
- import type { DesktopDockPreviewState, DesktopSharedStateSnapshot } from "../../../types/desktop-window";
16
+ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState } from "../../../types/desktop-window";
17
17
  import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
18
18
  import { buildSoundCommand } from "../../../notifications/app-notifier";
19
19
  import { isPaneDetached } from "../../../plugins/pane-manager";
@@ -37,8 +37,12 @@ import {
37
37
  type WindowFrame,
38
38
  type WindowMinimumSize,
39
39
  } from "./window-frame";
40
-
41
- const MAIN_WINDOW_RPC_KEY = "main";
40
+ import {
41
+ detachedRpcKey,
42
+ focusWindowForRpcKey,
43
+ MAIN_WINDOW_RPC_KEY,
44
+ paneIdFromDetachedRpcKey,
45
+ } from "./window-focus";
42
46
 
43
47
  type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
44
48
  type WindowMoveEvent = { data?: { x?: number; y?: number } };
@@ -61,6 +65,7 @@ let services: AppServices | null = null;
61
65
  let mainWindow: BrowserWindow | null = null;
62
66
  let desktopWorkspace: DesktopWorkspace | null = null;
63
67
  let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
68
+ let currentThemePreview: DesktopThemePreviewState = { theme: null };
64
69
  let desktopUpdateInProgress = false;
65
70
 
66
71
  const detachedWindows = new Map<string, BrowserWindow>();
@@ -101,10 +106,6 @@ function requireDesktopWorkspace(): DesktopWorkspace {
101
106
  return desktopWorkspace;
102
107
  }
103
108
 
104
- function detachedRpcKey(instanceId: string): string {
105
- return `detached:${instanceId}`;
106
- }
107
-
108
109
  function registerWindowRpc(key: string, rpc: DesktopRpc): void {
109
110
  windowRpcs.set(key, rpc);
110
111
  rpcWindowKeys.set(rpc, key);
@@ -156,10 +157,11 @@ function normalizeInitWindowTarget(
156
157
  if (rpcKey === MAIN_WINDOW_RPC_KEY) {
157
158
  return { kind: "main" };
158
159
  }
159
- if (rpcKey?.startsWith("detached:")) {
160
+ const detachedPaneId = paneIdFromDetachedRpcKey(rpcKey);
161
+ if (detachedPaneId) {
160
162
  return {
161
163
  kind: "detached",
162
- paneId: rpcKey.slice("detached:".length) || undefined,
164
+ paneId: detachedPaneId,
163
165
  };
164
166
  }
165
167
  const kind = payload.kind === "detached" ? "detached" : "main";
@@ -560,6 +562,17 @@ function sendDockPreview(preview: DesktopDockPreviewState): void {
560
562
  });
561
563
  }
562
564
 
565
+ function sendThemePreview(preview: DesktopThemePreviewState): void {
566
+ if (currentThemePreview.theme === preview.theme) return;
567
+ currentThemePreview = preview;
568
+ const encodedPreview = encodeRpcValue(preview) as DesktopThemePreviewState;
569
+ forEachReadyWindowRpc((rpc) => {
570
+ rpc.send["desktop.themePreview"]({
571
+ preview: encodedPreview,
572
+ });
573
+ });
574
+ }
575
+
563
576
  function clearDockPreview(paneId?: string): void {
564
577
  if (paneId && currentDockPreview.paneId && currentDockPreview.paneId !== paneId) return;
565
578
  sendDockPreview({ paneId: null, edge: null });
@@ -848,6 +861,7 @@ async function initialize(
848
861
  config: requireConfig(),
849
862
  sessionSnapshot: getSessionSnapshot(),
850
863
  desktopSnapshot: getDesktopSnapshot(),
864
+ desktopThemePreview: currentThemePreview,
851
865
  pluginState: loadPluginState(),
852
866
  capabilityManifests: requireServices().pluginRegistry.capabilities.manifests({ rendererOnly: true }),
853
867
  windowKind: windowTarget.kind,
@@ -869,6 +883,9 @@ async function handleDesktop(
869
883
  sendDesktopState(snapshot);
870
884
  return null;
871
885
  }
886
+ case "desktop.setThemePreview":
887
+ sendThemePreview((payload.preview ?? { theme: null }) as DesktopThemePreviewState);
888
+ return null;
872
889
  case "desktop.replaceDetachedPaneState":
873
890
  if (typeof payload.paneId !== "string") {
874
891
  throw new Error("desktop.replaceDetachedPaneState requires paneId.");
@@ -881,7 +898,7 @@ async function handleDesktop(
881
898
  }
882
899
  const snapshot = workspace.popOutPane(payload.paneId, resolveDetachedWindowFrame(payload.paneId));
883
900
  await commitDesktopSnapshot(snapshot);
884
- (detachedWindows.get(payload.paneId) as any)?.focus?.();
901
+ focusWindowForRpcKey(detachedRpcKey(payload.paneId), mainWindow, detachedWindows);
885
902
  return null;
886
903
  }
887
904
  case "desktop.dockDetachedPane": {
@@ -911,7 +928,7 @@ async function handleDesktop(
911
928
  if (typeof payload.paneId !== "string") {
912
929
  throw new Error("desktop.focusDetachedPane requires paneId.");
913
930
  }
914
- (detachedWindows.get(payload.paneId) as any)?.focus?.();
931
+ focusWindowForRpcKey(detachedRpcKey(payload.paneId), mainWindow, detachedWindows);
915
932
  return null;
916
933
  default:
917
934
  throw new Error(`Unknown desktop method: ${method}`);
@@ -1055,6 +1072,9 @@ async function handleBackendRequest(
1055
1072
  case "host.copyText":
1056
1073
  Utils.clipboardWriteText(normalizeText(payload.text) ?? "");
1057
1074
  return null;
1075
+ case "host.focusWindow":
1076
+ focusWindowForRpcKey(getRpcWindowKey(rpc), mainWindow, detachedWindows);
1077
+ return null;
1058
1078
  case "host.copyPngImage": {
1059
1079
  const pngBase64 = normalizeText(payload.pngBase64);
1060
1080
  if (!pngBase64) throw new Error("host.copyPngImage requires PNG data.");
@@ -1141,6 +1161,7 @@ mainWindow = new BrowserWindow({
1141
1161
  sandbox: false,
1142
1162
  });
1143
1163
  updateWindowFrameCache(mainWindow, DEFAULT_WINDOW_FRAME, MAIN_WINDOW_MIN_SIZE);
1164
+ focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY, mainWindow, detachedWindows);
1144
1165
  (mainWindow as any).on?.("move", (event: WindowMoveEvent) => {
1145
1166
  applyWindowMoveEvent(mainWindow, event);
1146
1167
  });
@@ -0,0 +1,37 @@
1
+ export const MAIN_WINDOW_RPC_KEY = "main";
2
+
3
+ const DETACHED_WINDOW_RPC_PREFIX = "detached:";
4
+
5
+ export interface FocusableElectrobunWindow {
6
+ focus?: () => void;
7
+ }
8
+
9
+ export function detachedRpcKey(instanceId: string): string {
10
+ return `${DETACHED_WINDOW_RPC_PREFIX}${instanceId}`;
11
+ }
12
+
13
+ export function paneIdFromDetachedRpcKey(rpcKey: string | undefined): string | null {
14
+ if (!rpcKey?.startsWith(DETACHED_WINDOW_RPC_PREFIX)) return null;
15
+ return rpcKey.slice(DETACHED_WINDOW_RPC_PREFIX.length) || null;
16
+ }
17
+
18
+ export function resolveWindowForRpcKey<T>(
19
+ rpcKey: string | undefined,
20
+ mainWindow: T | null,
21
+ detachedWindows: ReadonlyMap<string, T>,
22
+ ): T | null {
23
+ if (rpcKey === MAIN_WINDOW_RPC_KEY) return mainWindow;
24
+ const paneId = paneIdFromDetachedRpcKey(rpcKey);
25
+ return paneId ? detachedWindows.get(paneId) ?? null : null;
26
+ }
27
+
28
+ export function focusWindowForRpcKey(
29
+ rpcKey: string | undefined,
30
+ mainWindow: FocusableElectrobunWindow | null,
31
+ detachedWindows: ReadonlyMap<string, FocusableElectrobunWindow>,
32
+ ): boolean {
33
+ const window = resolveWindowForRpcKey(rpcKey, mainWindow, detachedWindows);
34
+ if (!window?.focus) return false;
35
+ window.focus();
36
+ return true;
37
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
2
- import type { DesktopDockPreviewState, DesktopSharedStateSnapshot } from "../../../types/desktop-window";
2
+ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState } from "../../../types/desktop-window";
3
3
  import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu";
4
4
  import type { AppConfig } from "../../../types/config";
5
5
  import type { UpdateProgress } from "../../../updater";
@@ -11,6 +11,7 @@ export interface ElectrobunBackendInit {
11
11
  config: AppConfig;
12
12
  sessionSnapshot: AppSessionSnapshot | null;
13
13
  desktopSnapshot: DesktopSharedStateSnapshot | null;
14
+ desktopThemePreview: DesktopThemePreviewState;
14
15
  pluginState: Record<string, Record<string, unknown>>;
15
16
  capabilityManifests: CapabilityManifest[];
16
17
  windowKind: "main" | "detached";
@@ -39,6 +40,10 @@ export interface DesktopDockPreviewMessage {
39
40
  preview: DesktopDockPreviewState;
40
41
  }
41
42
 
43
+ export interface DesktopThemePreviewMessage {
44
+ preview: DesktopThemePreviewState;
45
+ }
46
+
42
47
  export interface UpdateProgressMessage {
43
48
  progress: UpdateProgress;
44
49
  }
@@ -65,6 +70,7 @@ export interface ElectrobunDesktopRpcSchema {
65
70
  "application-menu.select": ApplicationMenuSelectMessage;
66
71
  "desktop.state": DesktopStateMessage;
67
72
  "desktop.dockPreview": DesktopDockPreviewMessage;
73
+ "desktop.themePreview": DesktopThemePreviewMessage;
68
74
  "update.progress": UpdateProgressMessage;
69
75
  "capability.event": CapabilityEventMessage;
70
76
  };
@@ -6,7 +6,7 @@ import { PluginRegistry } from "../../../plugins/registry";
6
6
  import type { AppServices } from "../../../core/app-services";
7
7
  import type { AppConfig } from "../../../types/config";
8
8
  import type { DataProvider, QuoteSubscriptionTarget } from "../../../types/data-provider";
9
- import type { Quote } from "../../../types/financials";
9
+ import type { Quote, TickerFinancials } from "../../../types/financials";
10
10
  import type { NewsArticle, NewsQuery } from "../../../news/types";
11
11
  import type { TickerRecord, TickerMetadata } from "../../../types/ticker";
12
12
  import { newsProvider, type CapabilityManifest } from "../../../capabilities";
@@ -21,7 +21,6 @@ import { debugLog } from "../../../utils/debug-log";
21
21
  import { measurePerf } from "../../../utils/perf-marks";
22
22
  import { getRendererBuiltinPlugins } from "../../../plugins/catalog-ui";
23
23
 
24
- const REMOTE_DATA_REQUEST_CACHE_LIMIT = 150;
25
24
  const servicesLog = debugLog.createLogger("services");
26
25
  const ASSET_DATA_CAPABILITY_ID = "asset-data.asset-data-router";
27
26
  const NEWS_CAPABILITY_ID = "news.core";
@@ -58,6 +57,9 @@ type PayloadBuilder = (...args: any[]) => Record<string, unknown>;
58
57
 
59
58
  const assetDataPayloads: Record<string, PayloadBuilder> = {
60
59
  canProvide: (ticker, exchange, context) => ({ ticker, exchange, context }),
60
+ getCachedFinancialsForTargets: (targets, options) => ({ targets, options }),
61
+ getQuotesBatch: (targets, options) => ({ targets, options }),
62
+ getTickerFinancialsBatch: (targets, options) => ({ targets, options }),
61
63
  getTickerFinancials: (ticker, exchange, context) => ({ ticker, exchange, context }),
62
64
  getQuote: (ticker, exchange, context) => ({ ticker, exchange, context }),
63
65
  getExchangeRate: (fromCurrency) => ({ fromCurrency }),
@@ -96,21 +98,16 @@ function hasRendererOperation(operations: Set<string> | null, operationId: strin
96
98
  }
97
99
 
98
100
  function createCapabilityInvoker() {
99
- const requestCache = new Map<string, Promise<unknown>>();
101
+ const inFlightRequests = new Map<string, Promise<unknown>>();
100
102
  return function invoke<T>(capabilityId: string, operationId: string, payload: unknown): Promise<T> {
101
103
  const requestPayload = { capabilityId, operationId, payload };
102
104
  const key = JSON.stringify(requestPayload);
103
- const cached = requestCache.get(key);
104
- if (cached) return cached as Promise<T>;
105
- const promise = backendRequest<T>("capability.invoke", requestPayload).catch((error) => {
106
- requestCache.delete(key);
107
- throw error;
105
+ const inFlight = inFlightRequests.get(key);
106
+ if (inFlight) return inFlight as Promise<T>;
107
+ const promise = backendRequest<T>("capability.invoke", requestPayload).finally(() => {
108
+ inFlightRequests.delete(key);
108
109
  });
109
- requestCache.set(key, promise);
110
- if (requestCache.size > REMOTE_DATA_REQUEST_CACHE_LIMIT) {
111
- const oldestKey = requestCache.keys().next().value;
112
- if (oldestKey) requestCache.delete(oldestKey);
113
- }
110
+ inFlightRequests.set(key, promise);
114
111
  return promise;
115
112
  };
116
113
  }
@@ -120,10 +117,69 @@ function createRemoteAssetDataClient(): RemoteAssetDataClient {
120
117
  const assetDataOperations = getRendererOperationIds(ASSET_DATA_CAPABILITY_ID);
121
118
  const newsOperations = getRendererOperationIds(NEWS_CAPABILITY_ID);
122
119
  let nextSubscriptionId = 1;
120
+ const quoteListeners = new Map<string, {
121
+ target: QuoteSubscriptionTarget;
122
+ listeners: Set<(target: QuoteSubscriptionTarget, quote: Quote) => void>;
123
+ }>();
124
+ let quoteBackendSubscriptionId: string | null = null;
125
+ let disposeQuoteBackendMessages: (() => void) | null = null;
126
+ let quoteBackendFlushTimer: ReturnType<typeof setTimeout> | null = null;
127
+
128
+ const quoteTargetKey = (target: QuoteSubscriptionTarget) =>
129
+ `${target.symbol}:${target.exchange ?? ""}:${target.context?.brokerId ?? ""}:${target.context?.brokerInstanceId ?? ""}:${target.context?.instrument?.conId ?? target.context?.instrument?.localSymbol ?? ""}`;
130
+
131
+ const scheduleQuoteBackendSubscriptionFlush = () => {
132
+ if (quoteBackendFlushTimer) return;
133
+ quoteBackendFlushTimer = setTimeout(() => {
134
+ quoteBackendFlushTimer = null;
135
+ flushQuoteBackendSubscription();
136
+ }, 25);
137
+ };
138
+
139
+ const flushQuoteBackendSubscription = () => {
140
+ if (!hasRendererOperation(assetDataOperations, "subscribeQuotes")) return;
141
+ const targets = [...quoteListeners.values()].map((entry) => entry.target);
142
+ const previousSubscriptionId = quoteBackendSubscriptionId;
143
+ if (previousSubscriptionId) {
144
+ quoteBackendSubscriptionId = null;
145
+ disposeQuoteBackendMessages?.();
146
+ disposeQuoteBackendMessages = null;
147
+ void backendRequest("capability.unsubscribe", { subscriptionId: previousSubscriptionId }).catch(() => {});
148
+ }
149
+ if (targets.length === 0) return;
150
+
151
+ const subscriptionId = `quote:${nextSubscriptionId++}`;
152
+ quoteBackendSubscriptionId = subscriptionId;
153
+ disposeQuoteBackendMessages = onCapabilityEvent(subscriptionId, (message) => {
154
+ const event = message.event as { target: QuoteSubscriptionTarget; quote: Quote };
155
+ const key = quoteTargetKey(event.target);
156
+ for (const listener of quoteListeners.get(key)?.listeners ?? []) {
157
+ listener(event.target, event.quote);
158
+ }
159
+ });
160
+ void backendRequest("capability.subscribe", {
161
+ subscriptionId,
162
+ capabilityId: ASSET_DATA_CAPABILITY_ID,
163
+ operationId: "subscribeQuotes",
164
+ payload: { targets },
165
+ }).catch((error) => {
166
+ if (quoteBackendSubscriptionId === subscriptionId) {
167
+ quoteBackendSubscriptionId = null;
168
+ disposeQuoteBackendMessages?.();
169
+ disposeQuoteBackendMessages = null;
170
+ }
171
+ console.error("Failed to subscribe to backend quotes", error);
172
+ });
173
+ };
174
+
123
175
  const base = {
124
176
  id: "desktop-backend",
125
177
  name: "Gloomberb Backend",
126
- getCachedFinancialsForTargets: () => new Map(),
178
+ getCachedFinancialsForTargets: (targets, options) => (
179
+ hasRendererOperation(assetDataOperations, "getCachedFinancialsForTargets")
180
+ ? invoke<Map<string, TickerFinancials>>(ASSET_DATA_CAPABILITY_ID, "getCachedFinancialsForTargets", { targets, options })
181
+ : new Map()
182
+ ),
127
183
  getNews: (query: NewsQuery) => (
128
184
  hasRendererOperation(newsOperations, "fetchNews")
129
185
  ? invoke<NewsArticle[]>(NEWS_CAPABILITY_ID, "fetchNews", { query })
@@ -142,28 +198,29 @@ function createRemoteAssetDataClient(): RemoteAssetDataClient {
142
198
  ).values()];
143
199
  if (uniqueTargets.length === 0) return () => {};
144
200
 
145
- const subscriptionId = `quote:${nextSubscriptionId++}`;
146
- const disposeMessages = onCapabilityEvent(subscriptionId, (message) => {
147
- const event = message.event as { target: QuoteSubscriptionTarget; quote: Quote };
148
- onQuote(event.target, event.quote);
149
- });
150
201
  let disposed = false;
151
-
152
- void backendRequest("capability.subscribe", {
153
- subscriptionId,
154
- capabilityId: ASSET_DATA_CAPABILITY_ID,
155
- operationId: "subscribeQuotes",
156
- payload: { targets: uniqueTargets },
157
- }).catch((error) => {
158
- disposeMessages();
159
- console.error("Failed to subscribe to backend quotes", error);
160
- });
202
+ for (const target of uniqueTargets) {
203
+ const key = quoteTargetKey(target);
204
+ const entry = quoteListeners.get(key) ?? { target, listeners: new Set() };
205
+ entry.target = target;
206
+ entry.listeners.add(onQuote);
207
+ quoteListeners.set(key, entry);
208
+ }
209
+ scheduleQuoteBackendSubscriptionFlush();
161
210
 
162
211
  return () => {
163
212
  if (disposed) return;
164
213
  disposed = true;
165
- disposeMessages();
166
- void backendRequest("capability.unsubscribe", { subscriptionId }).catch(() => {});
214
+ for (const target of uniqueTargets) {
215
+ const key = quoteTargetKey(target);
216
+ const entry = quoteListeners.get(key);
217
+ if (!entry) continue;
218
+ entry.listeners.delete(onQuote);
219
+ if (entry.listeners.size === 0) {
220
+ quoteListeners.delete(key);
221
+ }
222
+ }
223
+ scheduleQuoteBackendSubscriptionFlush();
167
224
  };
168
225
  },
169
226
  };
@@ -7,6 +7,7 @@ import {
7
7
  type ContextMenuSelectMessage,
8
8
  type DesktopDockPreviewMessage,
9
9
  type DesktopStateMessage,
10
+ type DesktopThemePreviewMessage,
10
11
  type ElectrobunBackendInit,
11
12
  type ElectrobunDesktopRpcSchema,
12
13
  type UpdateProgressMessage,
@@ -17,6 +18,7 @@ type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void;
17
18
  type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void;
18
19
  type DesktopStateListener = (message: DesktopStateMessage) => void;
19
20
  type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void;
21
+ type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void;
20
22
  type UpdateProgressListener = (message: UpdateProgressMessage) => void;
21
23
  type CapabilityEventListener = (message: CapabilityEventMessage) => void;
22
24
 
@@ -25,6 +27,7 @@ const contextMenuSelectListeners = new Map<string, Set<ContextMenuSelectListener
25
27
  const applicationMenuSelectListeners = new Set<ApplicationMenuSelectListener>();
26
28
  const desktopStateListeners = new Set<DesktopStateListener>();
27
29
  const desktopDockPreviewListeners = new Set<DesktopDockPreviewListener>();
30
+ const desktopThemePreviewListeners = new Set<DesktopThemePreviewListener>();
28
31
  const updateProgressListeners = new Set<UpdateProgressListener>();
29
32
  const capabilityEventListeners = new Map<string, Set<CapabilityEventListener>>();
30
33
 
@@ -81,6 +84,11 @@ const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
81
84
  listener({ preview: decodeRpcValue(message.preview) });
82
85
  }
83
86
  },
87
+ "desktop.themePreview": (message) => {
88
+ for (const listener of desktopThemePreviewListeners) {
89
+ listener({ preview: decodeRpcValue(message.preview) });
90
+ }
91
+ },
84
92
  "update.progress": (message) => {
85
93
  const decoded = { progress: decodeRpcValue<UpdateProgressMessage["progress"]>(message.progress) };
86
94
  for (const listener of updateProgressListeners) {
@@ -165,6 +173,13 @@ export function onDesktopDockPreview(listener: DesktopDockPreviewListener): () =
165
173
  };
166
174
  }
167
175
 
176
+ export function onDesktopThemePreview(listener: DesktopThemePreviewListener): () => void {
177
+ desktopThemePreviewListeners.add(listener);
178
+ return () => {
179
+ desktopThemePreviewListeners.delete(listener);
180
+ };
181
+ }
182
+
168
183
  export function onUpdateProgress(listener: UpdateProgressListener): () => void {
169
184
  updateProgressListeners.add(listener);
170
185
  return () => {