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.
- package/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- 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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
349
|
-
instance.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
);
|
|
353
|
-
if (
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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("
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
});
|