gloomberb 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, rm, writeFile } from "fs/promises";
1
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
2
+ import { homedir } from "os";
2
3
  import { dirname, join } from "path";
3
4
  import type {
4
5
  AppConfig,
@@ -20,6 +21,15 @@ import { isLayoutConfig, sanitizeLayout } from "./config-layout";
20
21
 
21
22
  const configLog = debugLog.createLogger("config");
22
23
  const LEGACY_MAIN_PORTFOLIO_COLUMN_IDS = DEFAULT_COLUMNS.map((column) => column.id);
24
+ const PRE_SPARKLINE_PORTFOLIO_COLUMN_IDS = [
25
+ ...DEFAULT_COLUMNS.map((column) => column.id),
26
+ "shares",
27
+ "avg_cost",
28
+ "cost_basis",
29
+ "mkt_value",
30
+ "pnl",
31
+ "pnl_pct",
32
+ ];
23
33
  const BUILTIN_SOURCE_IDS = new Set(["yahoo", "gloomberb-cloud"]);
24
34
  const BUILTIN_PLUGIN_GROUP_ALIASES: Record<string, string> = {
25
35
  "comparison-chart": "market-overview",
@@ -37,13 +47,25 @@ const BUILTIN_PLUGIN_GROUP_ALIASES: Record<string, string> = {
37
47
  };
38
48
 
39
49
  function getGlobalConfigDir(): string {
40
- return join(process.env.HOME || "~", ".gloomberb");
50
+ return join(getHomeDir(), ".gloomberb");
41
51
  }
42
52
 
43
53
  function getGlobalConfigFile(): string {
44
54
  return join(getGlobalConfigDir(), "config.json");
45
55
  }
46
56
 
57
+ function getHomeDir(): string {
58
+ return process.env.HOME || homedir();
59
+ }
60
+
61
+ function expandHomePath(filePath: string): string {
62
+ if (filePath === "~") return getHomeDir();
63
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
64
+ return join(getHomeDir(), filePath.slice(2));
65
+ }
66
+ return filePath;
67
+ }
68
+
47
69
  export async function getDataDir(): Promise<string | null> {
48
70
  try {
49
71
  const raw = await readFile(getGlobalConfigFile(), "utf-8");
@@ -89,7 +111,9 @@ function normalizeConfig(saved: Record<string, unknown>, dataDir: string): { con
89
111
  const activeLayoutIndex = sanitizeActiveLayoutIndex(saved.activeLayoutIndex, layouts.length);
90
112
  const layout = cloneLayout(layouts[activeLayoutIndex]?.layout ?? directLayout);
91
113
  const syncedLayouts = layouts.map((entry, index) => (
92
- index === activeLayoutIndex ? { ...entry, layout: cloneLayout(layout) } : entry
114
+ index === activeLayoutIndex
115
+ ? { ...entry, layout: cloneLayout(layout), paneState: sanitizeSavedPaneState(entry.paneState, layout) }
116
+ : entry
93
117
  ));
94
118
 
95
119
  const disabledPlugins = sanitizeDisabledPlugins(saved, defaults.disabledPlugins, {
@@ -138,7 +162,9 @@ export async function saveConfig(config: AppConfig): Promise<void> {
138
162
  const activeLayoutIndex = sanitizeActiveLayoutIndex(config.activeLayoutIndex, config.layouts.length || 1);
139
163
  const layout = sanitizeLayout(config.layout, createDefaultConfig(config.dataDir).layout);
140
164
  const layouts = sanitizeSavedLayouts(config.layouts, layout).map((entry, index) => (
141
- index === activeLayoutIndex ? { ...entry, layout: cloneLayout(layout) } : entry
165
+ index === activeLayoutIndex
166
+ ? { ...entry, layout: cloneLayout(layout), paneState: sanitizeSavedPaneState(entry.paneState, layout) }
167
+ : entry
142
168
  ));
143
169
 
144
170
  const persisted: AppConfig = {
@@ -158,7 +184,14 @@ export async function saveConfig(config: AppConfig): Promise<void> {
158
184
  recentTickers: sanitizeStringArray(config.recentTickers, []),
159
185
  };
160
186
 
161
- await writeFile(configPath, JSON.stringify(persisted, null, 2), "utf-8");
187
+ const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
188
+ try {
189
+ await writeFile(tempPath, JSON.stringify(persisted, null, 2), "utf-8");
190
+ await rename(tempPath, configPath);
191
+ } catch (error) {
192
+ await rm(tempPath, { force: true }).catch(() => {});
193
+ throw error;
194
+ }
162
195
  }
163
196
 
164
197
  export async function initDataDir(dataDir: string): Promise<AppConfig> {
@@ -177,11 +210,11 @@ export async function resetAllData(dataDir: string): Promise<void> {
177
210
 
178
211
  export async function exportConfig(config: AppConfig, destPath: string): Promise<void> {
179
212
  const { dataDir, ...rest } = config;
180
- await writeFile(destPath, JSON.stringify(rest, null, 2), "utf-8");
213
+ await writeFile(expandHomePath(destPath), JSON.stringify(rest, null, 2), "utf-8");
181
214
  }
182
215
 
183
216
  export async function importConfig(dataDir: string, srcPath: string): Promise<AppConfig> {
184
- const raw = await readFile(srcPath, "utf-8");
217
+ const raw = await readFile(expandHomePath(srcPath), "utf-8");
185
218
  const saved = JSON.parse(raw) as Record<string, unknown>;
186
219
  const { config } = normalizeConfig(saved, dataDir);
187
220
  await saveConfig(config);
@@ -238,6 +271,41 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
238
271
  return !!value && typeof value === "object" && !Array.isArray(value);
239
272
  }
240
273
 
274
+ function sanitizeSerializableValue(value: unknown): unknown {
275
+ if (value == null) return value;
276
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
277
+ return value;
278
+ }
279
+ if (Array.isArray(value)) {
280
+ return value
281
+ .map((entry) => sanitizeSerializableValue(entry))
282
+ .filter((entry) => entry !== undefined);
283
+ }
284
+ if (isPlainRecord(value)) {
285
+ return Object.fromEntries(
286
+ Object.entries(value)
287
+ .map(([key, entry]) => [key, sanitizeSerializableValue(entry)])
288
+ .filter(([, entry]) => entry !== undefined),
289
+ );
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ function sanitizeSavedPaneState(
295
+ value: unknown,
296
+ layout: LayoutConfig,
297
+ ): Record<string, Record<string, unknown>> | undefined {
298
+ if (!isPlainRecord(value)) return undefined;
299
+ const validPaneIds = new Set(layout.instances.map((instance) => instance.instanceId));
300
+ const paneState = Object.fromEntries(
301
+ Object.entries(value)
302
+ .filter(([paneId, entry]) => validPaneIds.has(paneId) && isPlainRecord(entry))
303
+ .map(([paneId, entry]) => [paneId, sanitizeSerializableValue(entry)])
304
+ .filter((entry): entry is [string, Record<string, unknown>] => isPlainRecord(entry[1])),
305
+ );
306
+ return paneState;
307
+ }
308
+
241
309
  function isPluginConfigMap(value: unknown): value is Record<string, Record<string, unknown>> {
242
310
  if (!isPlainRecord(value)) return false;
243
311
  return Object.values(value).every((entry) => isPlainRecord(entry));
@@ -266,7 +334,8 @@ function isChartPreferences(value: unknown): value is ChartPreferences {
266
334
  return (defaultRenderMode === "area"
267
335
  || defaultRenderMode === "line"
268
336
  || defaultRenderMode === "candles"
269
- || defaultRenderMode === "ohlc")
337
+ || defaultRenderMode === "ohlc"
338
+ || defaultRenderMode === "hlc")
270
339
  && (renderer === "auto" || renderer === "kitty" || renderer === "braille");
271
340
  }
272
341
 
@@ -278,6 +347,7 @@ function sanitizeChartPreferences(value: unknown, fallback: ChartPreferences): C
278
347
  || candidate.defaultRenderMode === "line"
279
348
  || candidate.defaultRenderMode === "candles"
280
349
  || candidate.defaultRenderMode === "ohlc"
350
+ || candidate.defaultRenderMode === "hlc"
281
351
  ? candidate.defaultRenderMode
282
352
  : fallback.defaultRenderMode;
283
353
  const renderer = candidate.renderer === "auto"
@@ -342,24 +412,31 @@ function hasExactColumnIds(value: unknown, expected: string[]): boolean {
342
412
  && value.every((entry, index) => entry === expected[index]);
343
413
  }
344
414
 
415
+ function shouldMigratePortfolioColumnIds(value: unknown): boolean {
416
+ return hasExactColumnIds(value, LEGACY_MAIN_PORTFOLIO_COLUMN_IDS)
417
+ || hasExactColumnIds(value, PRE_SPARKLINE_PORTFOLIO_COLUMN_IDS);
418
+ }
419
+
345
420
  function migrateLegacyPortfolioDefaultColumns(layout: LayoutConfig, enabled: boolean): LayoutConfig {
346
421
  if (!enabled) return layout;
347
422
 
348
- const instanceIndex = layout.instances.findIndex((instance) =>
349
- instance.instanceId === "portfolio-list:main"
350
- && instance.paneId === "portfolio-list"
351
- && hasExactColumnIds(instance.settings?.columnIds, LEGACY_MAIN_PORTFOLIO_COLUMN_IDS)
352
- );
353
- if (instanceIndex < 0) return layout;
423
+ const instanceIndices = layout.instances.flatMap((instance, index) => (
424
+ instance.paneId === "portfolio-list" && shouldMigratePortfolioColumnIds(instance.settings?.columnIds)
425
+ ? [index]
426
+ : []
427
+ ));
428
+ if (instanceIndices.length === 0) return layout;
354
429
 
355
430
  const nextLayout = cloneLayout(layout);
356
- nextLayout.instances[instanceIndex] = {
357
- ...nextLayout.instances[instanceIndex]!,
358
- settings: {
359
- ...(nextLayout.instances[instanceIndex]?.settings ?? {}),
360
- columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
361
- },
362
- };
431
+ for (const instanceIndex of instanceIndices) {
432
+ nextLayout.instances[instanceIndex] = {
433
+ ...nextLayout.instances[instanceIndex]!,
434
+ settings: {
435
+ ...(nextLayout.instances[instanceIndex]?.settings ?? {}),
436
+ columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
437
+ },
438
+ };
439
+ }
363
440
  return nextLayout;
364
441
  }
365
442
 
@@ -374,10 +451,21 @@ function sanitizeSavedLayouts(value: unknown, fallbackLayout: LayoutConfig): Sav
374
451
  && typeof entry === "object"
375
452
  && typeof (entry as SavedLayout).name === "string",
376
453
  )
377
- .map((entry) => ({
378
- name: entry.name,
379
- layout: sanitizeLayout(entry.layout, fallbackLayout),
380
- }));
454
+ .map((entry) => {
455
+ const layout = sanitizeLayout(entry.layout, fallbackLayout);
456
+ return {
457
+ id: typeof entry.id === "string" ? entry.id : undefined,
458
+ name: entry.name,
459
+ layout,
460
+ paneState: sanitizeSavedPaneState((entry as { paneState?: unknown }).paneState, layout),
461
+ focusedPaneId: typeof entry.focusedPaneId === "string" || entry.focusedPaneId === null
462
+ ? entry.focusedPaneId
463
+ : undefined,
464
+ activePanel: entry.activePanel === "right" || entry.activePanel === "left"
465
+ ? entry.activePanel
466
+ : undefined,
467
+ };
468
+ });
381
469
 
382
470
  return layouts.length > 0 ? layouts : [{ name: "Default", layout: cloneLayout(fallbackLayout) }];
383
471
  }
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
3
3
  import { join } from "path";
4
4
  import { tmpdir } from "os";
5
- import { loadConfig, sanitizeLayout, saveConfig } from "./config-store";
5
+ import { exportConfig, importConfig, loadConfig, sanitizeLayout, saveConfig } from "./config-store";
6
6
  import {
7
7
  CURRENT_CONFIG_VERSION,
8
8
  DEFAULT_COLUMNS,
@@ -219,7 +219,7 @@ describe("loadConfig", () => {
219
219
  await writeConfigJson(dataDir, createSavedConfig({
220
220
  configVersion: 7,
221
221
  chartPreferences: {
222
- defaultRenderMode: "line",
222
+ defaultRenderMode: "hlc",
223
223
  renderer: "nope",
224
224
  },
225
225
  }));
@@ -227,7 +227,7 @@ describe("loadConfig", () => {
227
227
  const config = await loadConfig(dataDir);
228
228
 
229
229
  expect(config.chartPreferences).toEqual({
230
- defaultRenderMode: "line",
230
+ defaultRenderMode: "hlc",
231
231
  renderer: "auto",
232
232
  });
233
233
  expect(config.pluginConfig).toEqual({});
@@ -342,6 +342,40 @@ describe("loadConfig", () => {
342
342
  });
343
343
  });
344
344
 
345
+ test("preserves saved layout pane state and focus metadata", async () => {
346
+ const dataDir = await createTempConfigDir();
347
+ await writeConfigJson(dataDir, createSavedConfig({
348
+ layouts: [{
349
+ name: "Chart",
350
+ layout: DEFAULT_LAYOUT,
351
+ paneState: {
352
+ "ticker-detail:main": { activeTabId: "chart" },
353
+ "missing:pane": { activeTabId: "overview" },
354
+ },
355
+ focusedPaneId: "ticker-detail:main",
356
+ activePanel: "right",
357
+ }],
358
+ }));
359
+
360
+ const config = await loadConfig(dataDir);
361
+
362
+ expect(config.layouts[0]?.paneState).toEqual({
363
+ "ticker-detail:main": { activeTabId: "chart" },
364
+ });
365
+ expect(config.layouts[0]?.focusedPaneId).toBe("ticker-detail:main");
366
+ expect(config.layouts[0]?.activePanel).toBe("right");
367
+
368
+ await saveConfig(config);
369
+ const persisted = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")) as {
370
+ layouts: Array<{ paneState?: Record<string, unknown>; focusedPaneId?: string | null; activePanel?: string }>;
371
+ };
372
+ expect(persisted.layouts[0]?.paneState).toEqual({
373
+ "ticker-detail:main": { activeTabId: "chart" },
374
+ });
375
+ expect(persisted.layouts[0]?.focusedPaneId).toBe("ticker-detail:main");
376
+ expect(persisted.layouts[0]?.activePanel).toBe("right");
377
+ });
378
+
345
379
  test("migrates legacy main portfolio panes to the portfolio default columns", async () => {
346
380
  const dataDir = await createTempConfigDir();
347
381
  const legacyColumnIds = DEFAULT_COLUMNS.map((column) => column.id);
@@ -417,3 +451,31 @@ describe("loadConfig", () => {
417
451
  expect(persisted.layouts[1]?.layout).toEqual(DEFAULT_LAYOUT);
418
452
  });
419
453
  });
454
+
455
+ describe("config backup files", () => {
456
+ test("expands a leading tilde when exporting and importing", async () => {
457
+ const originalHome = process.env.HOME;
458
+ const homeDir = await createTempConfigDir();
459
+ const dataDir = await createTempConfigDir();
460
+ const importDataDir = await createTempConfigDir();
461
+ process.env.HOME = homeDir;
462
+
463
+ try {
464
+ const config = await loadConfig(dataDir);
465
+ await exportConfig({ ...config, baseCurrency: "EUR" }, "~/gloomberb-config-backup.json");
466
+
467
+ const backupPath = join(homeDir, "gloomberb-config-backup.json");
468
+ const exported = JSON.parse(await readFile(backupPath, "utf-8")) as Record<string, unknown>;
469
+ expect(exported.baseCurrency).toBe("EUR");
470
+ expect(exported.dataDir).toBeUndefined();
471
+
472
+ await writeFile(backupPath, JSON.stringify({ ...exported, baseCurrency: "JPY" }), "utf-8");
473
+ const imported = await importConfig(importDataDir, "~/gloomberb-config-backup.json");
474
+
475
+ expect(imported.baseCurrency).toBe("JPY");
476
+ expect(imported.dataDir).toBe(importDataDir);
477
+ } finally {
478
+ process.env.HOME = originalHome;
479
+ }
480
+ });
481
+ });
@@ -10,6 +10,13 @@ export interface PluginStateRecord<T = unknown> {
10
10
  updatedAt: number;
11
11
  }
12
12
 
13
+ export interface PluginStateSetEntry {
14
+ pluginId: string;
15
+ key: string;
16
+ value: unknown;
17
+ schemaVersion?: number;
18
+ }
19
+
13
20
  interface CachedPluginStateRecord {
14
21
  value: unknown;
15
22
  schemaVersion: number;
@@ -64,22 +71,41 @@ export class PluginStateStore {
64
71
  }
65
72
 
66
73
  set(pluginId: string, key: string, value: unknown, schemaVersion = DEFAULT_PLUGIN_STATE_SCHEMA_VERSION): void {
67
- const rawValue = serializeJson(value);
74
+ this.setMany([{ pluginId, key, value, schemaVersion }]);
75
+ }
76
+
77
+ setMany(entries: PluginStateSetEntry[]): void {
78
+ if (entries.length === 0) return;
68
79
  const updatedAt = Date.now();
80
+ const records = entries.map((entry) => ({
81
+ pluginId: entry.pluginId,
82
+ key: entry.key,
83
+ value: entry.value,
84
+ schemaVersion: entry.schemaVersion ?? DEFAULT_PLUGIN_STATE_SCHEMA_VERSION,
85
+ rawValue: serializeJson(entry.value),
86
+ }));
87
+
69
88
  withSqliteBusyRetry("save plugin state", () => {
70
- this.db
71
- .query(
72
- `INSERT OR REPLACE INTO plugin_state (plugin_id, key, schema_version, value, updated_at)
73
- VALUES (?, ?, ?, ?, ?)`,
74
- )
75
- .run(pluginId, key, schemaVersion, rawValue, updatedAt);
76
- });
77
- this.cache.set(`${pluginId}:${key}`, {
78
- value,
79
- schemaVersion,
80
- updatedAt,
81
- rawValue,
89
+ const insert = this.db.query(
90
+ `INSERT OR REPLACE INTO plugin_state (plugin_id, key, schema_version, value, updated_at)
91
+ VALUES (?, ?, ?, ?, ?)`,
92
+ );
93
+ const tx = this.db.transaction(() => {
94
+ for (const record of records) {
95
+ insert.run(record.pluginId, record.key, record.schemaVersion, record.rawValue, updatedAt);
96
+ }
97
+ });
98
+ tx();
82
99
  });
100
+
101
+ for (const record of records) {
102
+ this.cache.set(`${record.pluginId}:${record.key}`, {
103
+ value: record.value,
104
+ schemaVersion: record.schemaVersion,
105
+ updatedAt,
106
+ rawValue: record.rawValue,
107
+ });
108
+ }
83
109
  }
84
110
 
85
111
  delete(pluginId: string, key: string): void {
@@ -127,6 +127,19 @@ describe("AppPersistence", () => {
127
127
  persistence.close();
128
128
  });
129
129
 
130
+ test("stores multiple plugin state records in one batch", () => {
131
+ const dbPath = createTempDbPath("plugin-state-batch");
132
+ const persistence = new AppPersistence(dbPath);
133
+ persistence.pluginState.setMany([
134
+ { pluginId: "ai", key: "provider", value: "openai", schemaVersion: 1 },
135
+ { pluginId: "news", key: "resume:read", value: { ids: ["1"] }, schemaVersion: 2 },
136
+ ]);
137
+
138
+ expect(persistence.pluginState.get<string>("ai", "provider", 1)?.value).toBe("openai");
139
+ expect(persistence.pluginState.get<{ ids: string[] }>("news", "resume:read", 2)?.value.ids).toEqual(["1"]);
140
+ persistence.close();
141
+ });
142
+
130
143
  test("stores and returns session snapshots", () => {
131
144
  const dbPath = createTempDbPath("session-snapshot");
132
145
  const persistence = new AppPersistence(dbPath);
@@ -43,6 +43,79 @@ async function flushCoordinator(): Promise<void> {
43
43
  }
44
44
 
45
45
  describe("MarketDataCoordinator key subscriptions", () => {
46
+ test("subscribes active quote targets as one provider batch", () => {
47
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
48
+ const provider = createTestDataProvider({
49
+ id: "test-provider",
50
+ subscribeQuotes: (targets) => {
51
+ subscriptions.push(targets);
52
+ return () => {};
53
+ },
54
+ });
55
+ const coordinator = new MarketDataCoordinator(provider);
56
+
57
+ coordinator.subscribeQuotes([
58
+ {
59
+ instrument: { symbol: "AAPL", exchange: "NASDAQ" },
60
+ priority: { surface: "portfolio", visible: true, selected: true, weight: 100 },
61
+ },
62
+ { instrument: { symbol: "MSFT", exchange: "NASDAQ" } },
63
+ ]);
64
+
65
+ expect(subscriptions).toHaveLength(1);
66
+ expect(subscriptions[0]?.map((target) => target.symbol).sort()).toEqual(["AAPL", "MSFT"]);
67
+ expect(subscriptions[0]?.find((target) => target.symbol === "AAPL")).toMatchObject({
68
+ surface: "portfolio",
69
+ visible: true,
70
+ selected: true,
71
+ weight: 100,
72
+ });
73
+ });
74
+
75
+ test("keeps the highest-priority target when duplicate surfaces subscribe", () => {
76
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
77
+ let disposals = 0;
78
+ const provider = createTestDataProvider({
79
+ id: "test-provider",
80
+ subscribeQuotes: (targets) => {
81
+ subscriptions.push(targets);
82
+ return () => {
83
+ disposals += 1;
84
+ };
85
+ },
86
+ });
87
+ const coordinator = new MarketDataCoordinator(provider);
88
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
89
+
90
+ const unsubscribeDetail = coordinator.subscribeQuotes([{
91
+ instrument,
92
+ priority: { surface: "detail", visible: true, selected: true, weight: 100 },
93
+ }]);
94
+ coordinator.subscribeQuotes([{
95
+ instrument,
96
+ priority: { surface: "portfolio", visible: false, selected: false, weight: 10 },
97
+ }]);
98
+
99
+ expect(subscriptions).toHaveLength(1);
100
+ expect(subscriptions[0]?.[0]).toMatchObject({
101
+ surface: "detail",
102
+ visible: true,
103
+ selected: true,
104
+ weight: 100,
105
+ });
106
+
107
+ unsubscribeDetail();
108
+
109
+ expect(subscriptions).toHaveLength(2);
110
+ expect(disposals).toBe(1);
111
+ expect(subscriptions[1]?.[0]).toMatchObject({
112
+ surface: "portfolio",
113
+ visible: false,
114
+ selected: false,
115
+ weight: 10,
116
+ });
117
+ });
118
+
46
119
  test("notifies listeners for changed keys only", async () => {
47
120
  const { provider, emitQuote } = createProvider();
48
121
  const coordinator = new MarketDataCoordinator(provider);
@@ -97,29 +170,40 @@ describe("MarketDataCoordinator key subscriptions", () => {
97
170
  expect(coordinator.getVersion()).toBe(1);
98
171
  });
99
172
 
100
- test("ignores repeated stream quotes that only refresh timestamp noise", async () => {
173
+ test("applies repeated stream quotes that refresh quote freshness", async () => {
174
+ const realDateNow = Date.now;
101
175
  const { provider, emitQuote } = createProvider();
102
176
  const coordinator = new MarketDataCoordinator(provider);
103
177
  const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
104
178
  let calls = 0;
105
179
 
106
- coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
107
- coordinator.subscribeQuotes([{ instrument: aapl }]);
108
-
109
180
  const firstTimestamp = 1_700_000_000_000;
110
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
111
- await flushCoordinator();
112
-
113
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
114
- await flushCoordinator();
115
-
116
- expect(calls).toBe(1);
117
- expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp);
118
-
119
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 20_000 }));
120
- await flushCoordinator();
121
-
122
- expect(calls).toBe(2);
123
- expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
181
+ try {
182
+ coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
183
+ coordinator.subscribeQuotes([{ instrument: aapl }]);
184
+
185
+ Date.now = () => firstTimestamp;
186
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
187
+ await flushCoordinator();
188
+
189
+ Date.now = () => firstTimestamp + 10_000;
190
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
191
+ await flushCoordinator();
192
+
193
+ expect(calls).toBe(2);
194
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
195
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 10_000);
196
+
197
+ Date.now = () => firstTimestamp + 20_000;
198
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 10_000 }));
199
+ await flushCoordinator();
200
+
201
+ expect(calls).toBe(3);
202
+ expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
203
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
204
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 20_000);
205
+ } finally {
206
+ Date.now = realDateNow;
207
+ }
124
208
  });
125
209
  });