gloomberb 0.9.6 → 0.9.8

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 (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { buildRelationshipAnalysis, createRelationshipPaneTemplate } from "../correlation/relationship/pane";
3
3
  import { buildFundamentalGraphRows, buildValuationGraphRows } from "./data-panes/fundamental-graph";
4
+ import { allMetricDefs, buildGraphBarSeries, graphRowsForFinancials } from "./data-panes/fundamental-graph/model";
4
5
  import { buildHistoricalPriceRows } from "./data-panes/historical-prices";
5
6
  import { createDefaultConfig } from "../../../types/config";
6
- import type { FinancialStatement, PricePoint } from "../../../types/financials";
7
+ import type { FinancialStatement, PricePoint, TickerFinancials } from "../../../types/financials";
7
8
 
8
9
  describe("ticker data panes", () => {
9
10
  test("builds historical price rows with close-to-close changes", () => {
@@ -65,6 +66,55 @@ describe("ticker data panes", () => {
65
66
  ]);
66
67
  });
67
68
 
69
+ test("orders multi-symbol chart categories by statement date", () => {
70
+ const series = buildGraphBarSeries([
71
+ { key: "NVDA:2025-01-31", symbol: "NVDA", date: "2025-01-31", category: "2025 Q1", value: 1, growth: null, barWidth: 1 },
72
+ { key: "NVDA:2026-04-30", symbol: "NVDA", date: "2026-04-30", category: "2026 Q2", value: 2, growth: null, barWidth: 1 },
73
+ { key: "AMD:2024-12-31", symbol: "AMD", date: "2024-12-31", category: "2024 Q4", value: 3, growth: null, barWidth: 1 },
74
+ ]);
75
+
76
+ expect(series[0]?.points.map((point) => point.category)).toEqual(["2024 Q4", "2025 Q1", "2026 Q2"]);
77
+ expect(series[1]?.points.map((point) => point.category)).toEqual(["2024 Q4", "2025 Q1", "2026 Q2"]);
78
+ });
79
+
80
+ test("derives free cash flow and margins for graph metrics", () => {
81
+ const fcfRows = buildFundamentalGraphRows([
82
+ { date: "2025-03-31", operatingCashFlow: 100, capitalExpenditure: -25 },
83
+ { date: "2025-06-30", freeCashFlow: 90, operatingCashFlow: 120, capitalExpenditure: -30 },
84
+ ] satisfies FinancialStatement[], "freeCashFlow", "NVDA", "quarterly");
85
+ const marginRows = buildFundamentalGraphRows([
86
+ { date: "2025-03-31", totalRevenue: 200, grossProfit: 100 },
87
+ ] satisfies FinancialStatement[], "grossMargin", "AMD", "quarterly");
88
+
89
+ expect(fcfRows.map((row) => row.value)).toEqual([75, 90]);
90
+ expect(marginRows[0]?.value).toBe(0.5);
91
+ expect(allMetricDefs().map(({ definition }) => definition.key)).toContain("freeCashFlowMargin");
92
+ });
93
+
94
+ test("fills missing Q4 margin rows from annual totals and preceding quarters", () => {
95
+ const financials = {
96
+ annualStatements: [
97
+ { date: "2025-12-31", totalRevenue: 1_000, grossProfit: 500 },
98
+ ],
99
+ quarterlyStatements: [
100
+ { date: "2025-03-31", totalRevenue: 100, grossProfit: 40 },
101
+ { date: "2025-06-30", totalRevenue: 200, grossProfit: 90 },
102
+ { date: "2025-09-30", totalRevenue: 300, grossProfit: 150 },
103
+ { date: "2025-12-31" },
104
+ ],
105
+ priceHistory: [],
106
+ } satisfies TickerFinancials;
107
+
108
+ const rows = graphRowsForFinancials(financials, "fundamental", "grossMargin", "quarterly", "AMD");
109
+
110
+ expect(rows.map((row) => [row.category, row.value])).toEqual([
111
+ ["2025 Q1", 0.4],
112
+ ["2025 Q2", 0.45],
113
+ ["2025 Q3", 0.5],
114
+ ["2025 Q4", 220 / 400],
115
+ ]);
116
+ });
117
+
68
118
  test("builds valuation graph rows from available multiples", () => {
69
119
  const rows = buildValuationGraphRows({
70
120
  quote: { symbol: "AMD", price: 100, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1, marketCap: 1_000 },
@@ -83,6 +133,50 @@ describe("ticker data panes", () => {
83
133
  ]);
84
134
  });
85
135
 
136
+ test("builds historical trailing and forward P/E rows from prices and EPS", () => {
137
+ const financials = {
138
+ quote: { symbol: "META", price: 320, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1 },
139
+ fundamentals: { forwardPE: 16 },
140
+ annualStatements: [
141
+ { date: "2024-12-31", eps: 5 },
142
+ { date: "2025-12-31", eps: 10 },
143
+ { date: "2026-12-31", eps: 20 },
144
+ ],
145
+ quarterlyStatements: [
146
+ { date: "2025-03-31", eps: 1 },
147
+ { date: "2025-06-30", eps: 2 },
148
+ { date: "2025-09-30", eps: 3 },
149
+ { date: "2025-12-31", eps: 4 },
150
+ { date: "2026-03-31", eps: 5 },
151
+ ],
152
+ priceHistory: [
153
+ { date: new Date("2024-12-31T00:00:00Z"), close: 100 },
154
+ { date: new Date("2025-12-31T00:00:00Z"), close: 150 },
155
+ { date: new Date("2026-03-31T00:00:00Z"), close: 180 },
156
+ { date: new Date("2026-12-31T00:00:00Z"), close: 300 },
157
+ ],
158
+ } satisfies TickerFinancials;
159
+
160
+ const trailingAnnual = buildValuationGraphRows(financials, "trailingPE", "META", "annual");
161
+ const forwardAnnual = buildValuationGraphRows(financials, "forwardPE", "META", "annual");
162
+ const trailingQuarterly = buildValuationGraphRows(financials, "trailingPE", "META", "quarterly");
163
+
164
+ expect(trailingAnnual.map((row) => [row.category, row.value])).toEqual([
165
+ ["FY2024", 20],
166
+ ["FY2025", 15],
167
+ ["FY2026", 15],
168
+ ]);
169
+ expect(forwardAnnual.map((row) => [row.category, row.value])).toEqual([
170
+ ["FY2024", 10],
171
+ ["FY2025", 7.5],
172
+ ["Current", 16],
173
+ ]);
174
+ expect(trailingQuarterly.map((row) => [row.category, row.value])).toEqual([
175
+ ["2025 Q4", 15],
176
+ ["2026 Q1", 180 / 14],
177
+ ]);
178
+ });
179
+
86
180
  test("builds relationship analysis from overlapping price history", () => {
87
181
  const dates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05", "2026-01-06"];
88
182
  const makeHistory = (closes: number[]) => dates.map((date, index) => ({
@@ -5,6 +5,7 @@ import { normalizeTickerInput } from "../../../tickers/search";
5
5
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
6
6
  import { TickerChartPane, ChartResearchTab } from "./chart-pane";
7
7
  import {
8
+ buildGraphPaneSettingsDef,
8
9
  createGraphPaneTemplate,
9
10
  FundamentalGraphPane,
10
11
  FundamentalGraphsResearchTab,
@@ -128,6 +129,7 @@ export const tickerDetailPlugin: GloomPlugin = {
128
129
  defaultPosition: "right",
129
130
  defaultMode: "floating",
130
131
  defaultFloatingSize: { width: 82, height: 22 },
132
+ settings: buildGraphPaneSettingsDef(),
131
133
  },
132
134
  {
133
135
  id: "provider-search-results",
@@ -6,7 +6,7 @@ import {
6
6
  } from "@stoqey/ib";
7
7
  import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
8
8
  import type { Quote } from "../../../types/financials";
9
- import { canonicalExchange, normalizeSymbol } from "../../../utils/exchanges";
9
+ import { normalizeSymbol } from "../../../utils/exchanges";
10
10
  import type { IbkrGatewayConfig } from "./types";
11
11
  import { getIbkrPriceDivisor, normalizeIbkrPriceValue } from "./price-normalization";
12
12
 
@@ -35,7 +35,7 @@ export function normalizeQuoteStreamTarget(target: QuoteSubscriptionTarget): Quo
35
35
  return {
36
36
  ...target,
37
37
  symbol,
38
- exchange: canonicalExchange(target.exchange),
38
+ exchange: target.exchange?.trim().toUpperCase() ?? "",
39
39
  };
40
40
  }
41
41
 
@@ -136,15 +136,22 @@ export function applyTickByTickAllLastToQuote(
136
136
  const nextPrice = normalizeIbkrPriceValue(tick.price, priceDivisor);
137
137
  if (nextPrice == null || nextPrice <= 0) return null;
138
138
 
139
- const previousClose = current?.previousClose ?? current?.price ?? nextPrice;
140
- const change = nextPrice - previousClose;
139
+ const canInferPreviousClose = current?.price != null
140
+ && current.change != null
141
+ && (current.change !== 0 || current.changePercent !== 0);
142
+ const inferredPreviousClose = current?.previousClose
143
+ ?? (canInferPreviousClose ? current.price - current.change : undefined);
144
+ const previousClose = inferredPreviousClose != null && Number.isFinite(inferredPreviousClose) && inferredPreviousClose > 0
145
+ ? inferredPreviousClose
146
+ : undefined;
147
+ const change = previousClose != null ? nextPrice - previousClose : current?.change ?? 0;
141
148
  return {
142
149
  symbol: current?.symbol ?? contract.localSymbol ?? contract.symbol ?? "",
143
150
  providerId: "ibkr",
144
151
  price: nextPrice,
145
152
  currency: current?.currency ?? contract.currency ?? "USD",
146
153
  change,
147
- changePercent: previousClose ? (change / previousClose) * 100 : 0,
154
+ changePercent: previousClose ? (change / previousClose) * 100 : current?.changePercent ?? 0,
148
155
  previousClose,
149
156
  high52w: current?.high52w,
150
157
  low52w: current?.low52w,
@@ -312,6 +312,37 @@ describe("tick-by-tick quote updates", () => {
312
312
  expect(next?.lastUpdated).toBe(1_700_000_000_000);
313
313
  });
314
314
 
315
+ test("does not use the previous tick as the daily close when no close is known", () => {
316
+ const current = {
317
+ symbol: "AAPL",
318
+ providerId: "ibkr" as const,
319
+ price: 249,
320
+ currency: "USD",
321
+ change: 0,
322
+ changePercent: 0,
323
+ name: "Apple Inc.",
324
+ lastUpdated: 1000,
325
+ dataSource: "live" as const,
326
+ };
327
+ const tick: TickByTickAllLast = {
328
+ time: 1_700_000_000,
329
+ price: 250.5,
330
+ size: 100,
331
+ tickType: 1,
332
+ tickAttribLast: {},
333
+ exchange: "NASDAQ",
334
+ specialConditions: "",
335
+ contract: contract as any,
336
+ };
337
+
338
+ const next = applyTickByTickAllLastToQuote(current, contract as any, details, tick, 1, "live");
339
+
340
+ expect(next?.price).toBe(250.5);
341
+ expect(next?.previousClose).toBeUndefined();
342
+ expect(next?.change).toBe(0);
343
+ expect(next?.changePercent).toBe(0);
344
+ });
345
+
315
346
  test("applies bid/ask ticks without disturbing the current trade price", () => {
316
347
  const current = {
317
348
  symbol: "AAPL",
@@ -14,6 +14,7 @@ import type {
14
14
  } from "../../types/plugin";
15
15
  import type { PaneRuntimeState } from "../../core/state/app/state";
16
16
  import type { NewsQuery, NewsQueryState } from "../../types/news-source";
17
+ import type { SyncContributor, SyncTransport } from "../../sync/types";
17
18
  import { debugLog } from "../../utils/debug-log";
18
19
  import { createPluginPersistence } from "../plugin-persistence";
19
20
  import type { PluginEvents } from "../event-bus";
@@ -35,6 +36,8 @@ export interface RegistryPluginContextOptions {
35
36
  updateLayout: (layout: LayoutConfig) => void;
36
37
  resolvePaneTarget: (paneId: string) => string | undefined;
37
38
  registerCapabilityForPlugin: (pluginId: string, capability: PluginCapability, items: PluginItems) => void;
39
+ registerSyncContributorForPlugin: (pluginId: string, contributor: SyncContributor) => () => void;
40
+ registerSyncTransportForPlugin: (pluginId: string, transport: SyncTransport) => () => void;
38
41
  watchNewsQuery: (query: NewsQuery, listener: (state: NewsQueryState) => void) => () => void;
39
42
  getData: (ticker: string) => TickerFinancials | null;
40
43
  getTicker: (symbol: string) => TickerRecord | null;
@@ -82,6 +85,8 @@ export function createRegistryPluginContext({
82
85
  updateLayout,
83
86
  resolvePaneTarget,
84
87
  registerCapabilityForPlugin,
88
+ registerSyncContributorForPlugin,
89
+ registerSyncTransportForPlugin,
85
90
  watchNewsQuery,
86
91
  getData,
87
92
  getTicker,
@@ -143,6 +148,16 @@ export function createRegistryPluginContext({
143
148
  registerShortcut: (shortcut) => contributions.registerShortcut(pluginId, shortcut, items),
144
149
  registerTickerAction: (action) => contributions.registerTickerAction(pluginId, action, items),
145
150
  registerContextMenuProvider: (provider) => contributions.registerContextMenuProvider(pluginId, provider, items),
151
+ registerSyncContributor: (contributor) => {
152
+ const dispose = registerSyncContributorForPlugin(pluginId, contributor);
153
+ items.eventDisposers.push(dispose);
154
+ return dispose;
155
+ },
156
+ registerSyncTransport: (transport) => {
157
+ const dispose = registerSyncTransportForPlugin(pluginId, transport);
158
+ items.eventDisposers.push(dispose);
159
+ return dispose;
160
+ },
146
161
  watchNewsQuery: (query, listener) => {
147
162
  const dispose = watchNewsQuery(query, listener);
148
163
  items.newsQueryWatchDisposers.push(dispose);
@@ -27,6 +27,12 @@ import type {
27
27
  } from "../../types/plugin";
28
28
  import type { ContextMenuContext, ContextMenuItem } from "../../types/context-menu";
29
29
  import type { TickerRecord } from "../../types/ticker";
30
+ import type {
31
+ RegisteredSyncContributor,
32
+ RegisteredSyncTransport,
33
+ SyncContributor,
34
+ SyncTransport,
35
+ } from "../../sync/types";
30
36
  import { EventBus } from "../event-bus";
31
37
  import { resolvePaneInstance } from "../../types/config";
32
38
  import { debugLog } from "../../utils/debug-log";
@@ -49,6 +55,7 @@ import {
49
55
  releaseSharedRegistry,
50
56
  } from "./shared";
51
57
  import { RegistryResumeStateListeners } from "./plugin-state";
58
+ import { cloudSyncController } from "../../sync/controller";
52
59
 
53
60
  interface PluginRegistryOptions {
54
61
  enableCapabilityHandlers?: boolean;
@@ -215,6 +222,28 @@ export class PluginRegistry implements PluginRuntimeAccess {
215
222
  get tickerActions(): ReadonlyMap<string, TickerAction> { return this.contributions.tickerActionsMap; }
216
223
  get allPlugins(): ReadonlyMap<string, GloomPlugin> { return this.plugins; }
217
224
 
225
+ registerSyncContributorForPlugin(pluginId: string, contributor: SyncContributor): () => void {
226
+ return cloudSyncController.registerContributor(pluginId, contributor);
227
+ }
228
+
229
+ registerSyncTransportForPlugin(pluginId: string, transport: SyncTransport): () => void {
230
+ return cloudSyncController.registerTransport(pluginId, transport);
231
+ }
232
+
233
+ getEnabledSyncContributors(): RegisteredSyncContributor[] {
234
+ const disabledPlugins = new Set(this.getConfigFn().disabledPlugins ?? []);
235
+ return cloudSyncController
236
+ .getRegisteredContributors()
237
+ .filter((entry) => !disabledPlugins.has(entry.pluginId));
238
+ }
239
+
240
+ getActiveSyncTransport(): RegisteredSyncTransport | null {
241
+ const disabledPlugins = new Set(this.getConfigFn().disabledPlugins ?? []);
242
+ return cloudSyncController
243
+ .getRegisteredTransports()
244
+ .find((entry) => !disabledPlugins.has(entry.pluginId) && entry.transport.isAvailable()) ?? null;
245
+ }
246
+
218
247
  getContextMenuItems(context: ContextMenuContext): ContextMenuItem[] {
219
248
  return resolveRegistryContextMenuItems({
220
249
  context,
@@ -373,6 +402,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
373
402
  updateLayout: (layout) => this.updateLayoutFn(layout),
374
403
  resolvePaneTarget: (paneId) => this.resolvePaneTarget(paneId),
375
404
  registerCapabilityForPlugin: (targetPluginId, capability, pluginItems) => this.registerCapabilityForPlugin(targetPluginId, capability, pluginItems),
405
+ registerSyncContributorForPlugin: (targetPluginId, contributor) => this.registerSyncContributorForPlugin(targetPluginId, contributor),
406
+ registerSyncTransportForPlugin: (targetPluginId, transport) => this.registerSyncTransportForPlugin(targetPluginId, transport),
376
407
  watchNewsQuery: (query, listener) => this.watchNewsQueryFn(query, listener),
377
408
  getData: (ticker) => this.getDataFn(ticker),
378
409
  getTicker: (symbol) => this.getTickerFn(symbol),
@@ -103,6 +103,41 @@ function requireDesktopWorkspace(): DesktopWorkspace {
103
103
  return desktopWorkspace;
104
104
  }
105
105
 
106
+ const pendingDesktopDeepLinks: string[] = [];
107
+
108
+ function isGloomberbDeepLink(rawUrl: string): boolean {
109
+ try {
110
+ return new URL(rawUrl).protocol === "gloomberb:";
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function readOpenUrlEvent(event: unknown): string | null {
117
+ const data = event && typeof event === "object" ? (event as { data?: unknown }).data : null;
118
+ if (!data || typeof data !== "object") return null;
119
+ const url = (data as { url?: unknown }).url;
120
+ return typeof url === "string" && url ? url : null;
121
+ }
122
+
123
+ function sendDesktopDeepLink(rawUrl: string): void {
124
+ if (!isGloomberbDeepLink(rawUrl)) return;
125
+ const rpc = getWindowRpc(MAIN_WINDOW_RPC_KEY);
126
+ if (!rpc || !isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) {
127
+ pendingDesktopDeepLinks.push(rawUrl);
128
+ if (pendingDesktopDeepLinks.length > 20) pendingDesktopDeepLinks.shift();
129
+ return;
130
+ }
131
+ detachedWindowManager.focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY);
132
+ rpc.send["desktop.deepLink"]({ url: rawUrl });
133
+ }
134
+
135
+ function flushPendingDesktopDeepLinks(): void {
136
+ if (pendingDesktopDeepLinks.length === 0) return;
137
+ const urls = pendingDesktopDeepLinks.splice(0);
138
+ for (const url of urls) sendDesktopDeepLink(url);
139
+ }
140
+
106
141
  function remoteFailure(code: string, message: string): RemoteControlResponse {
107
142
  return { ok: false, error: { code, message } };
108
143
  }
@@ -377,6 +412,7 @@ async function initialize(
377
412
  syncConfigAccessors,
378
413
  });
379
414
  if (init.windowKind === "main") {
415
+ flushPendingDesktopDeepLinks();
380
416
  void ensureDesktopRemoteControlServer().catch((error) => {
381
417
  console.error("[remote] desktop control endpoint failed", summarizeError(error));
382
418
  });
@@ -502,6 +538,12 @@ Electrobun.events.on("context-menu-clicked", (event: unknown) => {
502
538
  });
503
539
  });
504
540
 
541
+ Electrobun.events.on("open-url", (event: unknown) => {
542
+ const url = readOpenUrlEvent(event);
543
+ if (!url) return;
544
+ sendDesktopDeepLink(url);
545
+ });
546
+
505
547
  ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
506
548
  const command = applicationMenuCommand(event);
507
549
  if (!command) return;
@@ -60,6 +60,10 @@ export interface DesktopRestartMessage {
60
60
  source?: string;
61
61
  }
62
62
 
63
+ export interface DesktopDeepLinkMessage {
64
+ url: string;
65
+ }
66
+
63
67
  export interface RemoteControlRequestMessage {
64
68
  request: RemoteControlRequest;
65
69
  }
@@ -89,6 +93,7 @@ export interface ElectrobunDesktopRpcSchema {
89
93
  "desktop.state": DesktopStateMessage;
90
94
  "desktop.dockPreview": DesktopDockPreviewMessage;
91
95
  "desktop.themePreview": DesktopThemePreviewMessage;
96
+ "desktop.deepLink": DesktopDeepLinkMessage;
92
97
  "update.progress": UpdateProgressMessage;
93
98
  "capability.event": CapabilityEventMessage;
94
99
  };
@@ -4,6 +4,7 @@ import {
4
4
  type ApplicationMenuSelectMessage,
5
5
  type CapabilityEventMessage,
6
6
  type ContextMenuSelectMessage,
7
+ type DesktopDeepLinkMessage,
7
8
  type DesktopDockPreviewMessage,
8
9
  type DesktopRestartMessage,
9
10
  type DesktopStateMessage,
@@ -18,6 +19,7 @@ import type { RemoteControlRequest, RemoteControlResponse } from "../../../remot
18
19
 
19
20
  type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void;
20
21
  type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void;
22
+ type DesktopDeepLinkListener = (message: DesktopDeepLinkMessage) => void;
21
23
  type DesktopStateListener = (message: DesktopStateMessage) => void;
22
24
  type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void;
23
25
  type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void;
@@ -29,6 +31,8 @@ let initSnapshot: ElectrobunBackendInit | null = null;
29
31
  let remoteControlRequestHandler: RemoteControlRequestHandler | null = null;
30
32
  const contextMenuSelectListeners = new Map<string, Set<ContextMenuSelectListener>>();
31
33
  const applicationMenuSelectListeners = new Set<ApplicationMenuSelectListener>();
34
+ const desktopDeepLinkListeners = new Set<DesktopDeepLinkListener>();
35
+ const pendingDesktopDeepLinks: DesktopDeepLinkMessage[] = [];
32
36
  const desktopStateListeners = new Set<DesktopStateListener>();
33
37
  const desktopDockPreviewListeners = new Set<DesktopDockPreviewListener>();
34
38
  const desktopThemePreviewListeners = new Set<DesktopThemePreviewListener>();
@@ -64,6 +68,17 @@ function subscribe<T>(
64
68
  };
65
69
  }
66
70
 
71
+ function dispatchDesktopDeepLink(message: DesktopDeepLinkMessage): void {
72
+ if (desktopDeepLinkListeners.size === 0) {
73
+ pendingDesktopDeepLinks.push(message);
74
+ if (pendingDesktopDeepLinks.length > 20) pendingDesktopDeepLinks.shift();
75
+ return;
76
+ }
77
+ for (const listener of desktopDeepLinkListeners) {
78
+ listener(message);
79
+ }
80
+ }
81
+
67
82
  const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
68
83
  maxRequestTime: 120_000,
69
84
  handlers: {
@@ -92,6 +107,10 @@ const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
92
107
  listener(decoded);
93
108
  }
94
109
  },
110
+ "desktop.deepLink": (message) => {
111
+ if (typeof message.url !== "string" || !message.url) return;
112
+ dispatchDesktopDeepLink({ url: message.url });
113
+ },
95
114
  "desktop.state": (message) => {
96
115
  for (const listener of desktopStateListeners) {
97
116
  listener({ snapshot: decodeRpcValue(message.snapshot) });
@@ -188,6 +207,16 @@ export function onApplicationMenuSelect(listener: ApplicationMenuSelectListener)
188
207
  };
189
208
  }
190
209
 
210
+ export function onDesktopDeepLink(listener: DesktopDeepLinkListener): () => void {
211
+ desktopDeepLinkListeners.add(listener);
212
+ for (const message of pendingDesktopDeepLinks.splice(0)) {
213
+ listener(message);
214
+ }
215
+ return () => {
216
+ desktopDeepLinkListeners.delete(listener);
217
+ };
218
+ }
219
+
191
220
  export function onDesktopState(listener: DesktopStateListener): () => void {
192
221
  desktopStateListeners.add(listener);
193
222
  return () => {
@@ -4,9 +4,11 @@ import { Box, Input, Text, Textarea, editableTextContextMenuItems, useRendererHo
4
4
  import { TextAttributes, type InputRenderable } from "../../../../ui";
5
5
  import { useShortcut } from "../../../../react/input";
6
6
  import { blendHex, colors } from "../../../../theme/colors";
7
+ import { contrastRatio } from "../../../../theme/color-utils";
7
8
  import { useThemeColors } from "../../../../theme/theme-context";
8
9
  import { isDetailBackNavigationKey } from "../../../../utils/back-navigation";
9
10
  import type { ButtonProps } from "../../../../components/ui/button";
11
+ import type { CheckboxProps } from "../../../../components/ui/checkbox";
10
12
  import type { TextFieldProps } from "../../../../components/ui/fields";
11
13
  import type { DialogFrameProps } from "../../../../components/ui/frame";
12
14
  import type { MessageComposerProps } from "../../../../components/ui/message-composer";
@@ -31,6 +33,7 @@ export function WebButton({
31
33
  active = false,
32
34
  shortcut,
33
35
  width,
36
+ height,
34
37
  }: ButtonProps) {
35
38
  useThemeColors();
36
39
  const palette = buttonPalette({ variant, active, disabled });
@@ -38,7 +41,7 @@ export function WebButton({
38
41
  return (
39
42
  <Box
40
43
  width={width}
41
- height={1}
44
+ height={height ?? 1}
42
45
  flexDirection="row"
43
46
  alignItems="center"
44
47
  justifyContent="center"
@@ -51,7 +54,8 @@ export function WebButton({
51
54
  style={{
52
55
  border: `1px solid ${palette.border}`,
53
56
  borderRadius: CONTROL_RADIUS,
54
- paddingInline: 8,
57
+ paddingLeft: 8,
58
+ paddingRight: 8,
55
59
  boxShadow: controlShadow(active),
56
60
  cursor: disabled ? "default" : "pointer",
57
61
  }}
@@ -59,7 +63,9 @@ export function WebButton({
59
63
  <Text
60
64
  fg={palette.fg}
61
65
  attributes={active ? TextAttributes.BOLD : 0}
62
- style={{ fontWeight: active || variant === "primary" ? 700 : 600 }}
66
+ style={{
67
+ fontWeight: active || variant === "primary" ? 700 : 600,
68
+ }}
63
69
  >
64
70
  {label}
65
71
  </Text>
@@ -75,6 +81,129 @@ export function WebButton({
75
81
  );
76
82
  }
77
83
 
84
+ function checkboxAccentColor(): string {
85
+ const base = colors.borderFocused;
86
+ const nativeCheckmark = "#ffffff";
87
+ const candidates = [
88
+ blendHex(base, colors.selected, 0.55),
89
+ blendHex(base, colors.selected, 0.65),
90
+ blendHex(base, "#000000", 0.45),
91
+ blendHex(base, "#000000", 0.52),
92
+ base,
93
+ ];
94
+ return candidates.find((candidate) => (
95
+ contrastRatio(candidate, nativeCheckmark) >= 4.5
96
+ && contrastRatio(candidate, colors.panel) >= 2.2
97
+ )) ?? candidates[1]!;
98
+ }
99
+
100
+ function checkboxCheckImage(): string {
101
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><path d="M3 7.3 5.8 10 11.2 3.7" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
102
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
103
+ }
104
+
105
+ export function WebCheckbox({
106
+ label,
107
+ displayLabel,
108
+ checked,
109
+ onChange,
110
+ disabled = false,
111
+ active = false,
112
+ description,
113
+ width,
114
+ }: CheckboxProps) {
115
+ useThemeColors();
116
+ const textColor = disabled
117
+ ? colors.textMuted
118
+ : active
119
+ ? colors.textBright
120
+ : colors.text;
121
+ const visibleLabel = displayLabel ?? label;
122
+ const accentColor = checkboxAccentColor();
123
+ const borderColor = checked ? accentColor : controlBorderColor(active, false);
124
+ return (
125
+ <Box
126
+ flexDirection="column"
127
+ width={width}
128
+ data-gloom-role="desktop-checkbox"
129
+ data-gloom-interactive={disabled ? undefined : "true"}
130
+ style={{ opacity: disabled ? 0.55 : 1 }}
131
+ >
132
+ <label
133
+ onMouseDown={(event) => {
134
+ event.preventDefault();
135
+ event.stopPropagation();
136
+ }}
137
+ onClick={(event) => {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ if (!disabled) onChange?.(!checked);
141
+ }}
142
+ style={{
143
+ display: "flex",
144
+ alignItems: "center",
145
+ gap: 8,
146
+ width: "100%",
147
+ minWidth: 0,
148
+ cursor: disabled ? "default" : "pointer",
149
+ color: textColor,
150
+ fontWeight: active ? 700 : 500,
151
+ lineHeight: "20px",
152
+ userSelect: "none",
153
+ position: "relative",
154
+ }}
155
+ >
156
+ <input
157
+ type="checkbox"
158
+ checked={checked}
159
+ readOnly
160
+ disabled={disabled}
161
+ style={{
162
+ appearance: "none",
163
+ WebkitAppearance: "none",
164
+ width: 14,
165
+ height: 14,
166
+ margin: 0,
167
+ backgroundColor: checked ? accentColor : panelFill(),
168
+ backgroundImage: checked ? checkboxCheckImage() : undefined,
169
+ backgroundPosition: "center",
170
+ backgroundRepeat: "no-repeat",
171
+ backgroundSize: "12px 12px",
172
+ border: `1px solid ${borderColor}`,
173
+ borderRadius: 4,
174
+ boxShadow: active
175
+ ? `0 0 0 2px ${blendHex(colors.bg, colors.borderFocused, 0.28)}, inset 0 1px 0 rgba(255,255,255,0.16)`
176
+ : "inset 0 1px 0 rgba(255,255,255,0.10)",
177
+ boxSizing: "border-box",
178
+ flexShrink: 0,
179
+ cursor: disabled ? "default" : "pointer",
180
+ verticalAlign: "middle",
181
+ }}
182
+ />
183
+ <span
184
+ style={{
185
+ overflow: "hidden",
186
+ textOverflow: "ellipsis",
187
+ whiteSpace: "nowrap",
188
+ }}
189
+ >
190
+ {visibleLabel}
191
+ </span>
192
+ </label>
193
+ {description ? (
194
+ <Text
195
+ fg={colors.textMuted}
196
+ wrapText
197
+ width={width}
198
+ style={{ paddingLeft: 22, lineHeight: "18px" }}
199
+ >
200
+ {description}
201
+ </Text>
202
+ ) : null}
203
+ </Box>
204
+ );
205
+ }
206
+
78
207
  export function WebTextField({
79
208
  label,
80
209
  value,
@@ -184,6 +313,7 @@ export function WebMessageComposer({
184
313
  height = 2,
185
314
  onFocusRequest,
186
315
  onInput,
316
+ onCursorChange,
187
317
  onSubmit,
188
318
  keyBindings,
189
319
  wrapText = false,
@@ -234,6 +364,7 @@ export function WebMessageComposer({
234
364
  onMouseDown={requestFocus}
235
365
  onFocus={requestFocus}
236
366
  onInput={handleInput}
367
+ onCursorChange={onCursorChange}
237
368
  keyBindings={keyBindings}
238
369
  onSubmit={onSubmit}
239
370
  wrapText={wrapText}
@@ -295,7 +426,7 @@ export function WebDialogFrame({
295
426
  title,
296
427
  children,
297
428
  footer,
298
- showTitleDivider = true,
429
+ showTitleDivider = false,
299
430
  }: DialogFrameProps) {
300
431
  useThemeColors();
301
432
  return (