gloomberb 0.7.3 → 0.7.5
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/package.json +1 -1
- package/src/app.tsx +59 -30
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- 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 +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +26 -2
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -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 +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- 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 +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- 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 +26 -10
- 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/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -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.ts +72 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from "./settings";
|
|
44
44
|
import { formatPercentRaw } from "../../../utils/format";
|
|
45
45
|
import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
|
|
46
|
+
import { isQuoteStaleForCurrentSession } from "../../../utils/quote-freshness";
|
|
46
47
|
import { priceColor } from "../../../theme/colors";
|
|
47
48
|
import { PortfolioTickerTable, type QuoteFlashDirection } from "./table";
|
|
48
49
|
import { useThrottledCursorSymbol } from "./use-throttled-cursor-symbol";
|
|
@@ -73,9 +74,8 @@ function visibleWarmupKey(kind: "quote" | "snapshot", ticker: TickerRecord): str
|
|
|
73
74
|
return `${kind}:${ticker.metadata.ticker}:${ticker.metadata.exchange ?? ""}`;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined): boolean {
|
|
77
|
-
|
|
78
|
-
return !financials?.quote;
|
|
77
|
+
function needsVisibleQuoteWarmup(ticker: TickerRecord, financials: TickerFinancials | undefined, now = Date.now()): boolean {
|
|
78
|
+
return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
function needsVisibleSnapshotWarmup(
|
|
@@ -280,12 +280,12 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
280
280
|
const marketFinancialsMap = useTickerFinancialsMap(tickers);
|
|
281
281
|
const sharedCoordinator = getSharedMarketDataCoordinator();
|
|
282
282
|
const financialsMap = useMemo(() => {
|
|
283
|
-
const merged =
|
|
283
|
+
const merged = new Map(cachedFinancials);
|
|
284
284
|
for (const [symbol, financials] of marketFinancialsMap) {
|
|
285
285
|
merged.set(symbol, financials);
|
|
286
286
|
}
|
|
287
287
|
return merged;
|
|
288
|
-
}, [cachedFinancials, marketFinancialsMap
|
|
288
|
+
}, [cachedFinancials, marketFinancialsMap]);
|
|
289
289
|
|
|
290
290
|
const accountStateInput = useMemo(() => ({ brokerAccounts, config }), [brokerAccounts, config]);
|
|
291
291
|
const accountState = usePortfolioAccountState(currentPortfolio, accountStateInput);
|
|
@@ -464,11 +464,28 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
464
464
|
() => selectStreamTickers(sortedTickers, streamWindow, cursorSymbol),
|
|
465
465
|
[cursorSymbol, sortedTickers, streamWindow],
|
|
466
466
|
);
|
|
467
|
+
const priorityStreamSymbols = useMemo(
|
|
468
|
+
() => new Set(streamTickers.map((ticker) => ticker.metadata.ticker)),
|
|
469
|
+
[streamTickers],
|
|
470
|
+
);
|
|
471
|
+
const streamSurface = isPortfolioTab ? "portfolio" : "watchlist";
|
|
467
472
|
const streamTargets = useMemo(() => (
|
|
468
|
-
|
|
469
|
-
.map((ticker) =>
|
|
473
|
+
sortedTickers
|
|
474
|
+
.map((ticker) => {
|
|
475
|
+
const target = quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker, "provider");
|
|
476
|
+
if (!target) return null;
|
|
477
|
+
const selected = ticker.metadata.ticker === cursorSymbol;
|
|
478
|
+
const visible = priorityStreamSymbols.has(ticker.metadata.ticker);
|
|
479
|
+
return {
|
|
480
|
+
...target,
|
|
481
|
+
surface: streamSurface,
|
|
482
|
+
visible,
|
|
483
|
+
selected,
|
|
484
|
+
weight: selected ? 100 : visible ? 80 : 10,
|
|
485
|
+
};
|
|
486
|
+
})
|
|
470
487
|
.filter((target): target is NonNullable<typeof target> => target != null)
|
|
471
|
-
), [
|
|
488
|
+
), [cursorSymbol, priorityStreamSymbols, sortedTickers, streamSurface]);
|
|
472
489
|
const visibleFinancialTickers = useMemo(
|
|
473
490
|
() => sortedTickers.slice(streamWindow.start, streamWindow.end),
|
|
474
491
|
[sortedTickers, streamWindow.end, streamWindow.start],
|
|
@@ -486,7 +503,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
486
503
|
const financials = financialsMap.get(ticker.metadata.ticker);
|
|
487
504
|
const quoteKey = visibleWarmupKey("quote", ticker);
|
|
488
505
|
if (
|
|
489
|
-
needsVisibleQuoteWarmup(ticker, financials)
|
|
506
|
+
needsVisibleQuoteWarmup(ticker, financials, nowTimestamp)
|
|
490
507
|
&& !warmupInFlightRef.current.has(quoteKey)
|
|
491
508
|
&& nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS
|
|
492
509
|
) {
|
|
@@ -3,6 +3,7 @@ import { existsSync, rmSync } from "fs";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { act, useReducer } from "react";
|
|
6
|
+
import { RGBA, TextAttributes } from "@opentui/core";
|
|
6
7
|
import { testRender } from "../../renderers/opentui/test-utils";
|
|
7
8
|
import { AppPersistence } from "../../data/app-persistence";
|
|
8
9
|
import { TickerRepository } from "../../data/ticker-repository";
|
|
@@ -18,6 +19,7 @@ import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
|
|
|
18
19
|
import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
|
|
19
20
|
import { PluginRegistry, setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
|
|
20
21
|
import type { BrokerAdapter } from "../../types/broker";
|
|
22
|
+
import { colors } from "../../theme/colors";
|
|
21
23
|
import { portfolioListPlugin } from "./portfolio-list";
|
|
22
24
|
|
|
23
25
|
const TEST_PANE_ID = "portfolio-list:test";
|
|
@@ -316,6 +318,10 @@ async function flushFrame() {
|
|
|
316
318
|
});
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
function lineText(line: { spans: Array<{ text: string }> }) {
|
|
322
|
+
return line.spans.map((span) => span.text).join("");
|
|
323
|
+
}
|
|
324
|
+
|
|
319
325
|
afterEach(async () => {
|
|
320
326
|
if (testSetup) {
|
|
321
327
|
testSetup.renderer.destroy();
|
|
@@ -1122,10 +1128,11 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1122
1128
|
quote: makeQuote({
|
|
1123
1129
|
symbol: "AAPL",
|
|
1124
1130
|
price: 125,
|
|
1125
|
-
marketCap:
|
|
1131
|
+
marketCap: 2_000_000_000,
|
|
1126
1132
|
}),
|
|
1127
1133
|
fundamentals: {
|
|
1128
1134
|
trailingPE: 25,
|
|
1135
|
+
forwardPE: 22,
|
|
1129
1136
|
},
|
|
1130
1137
|
profile: {
|
|
1131
1138
|
sector: "Technology",
|
|
@@ -1437,7 +1444,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1437
1444
|
expect(frame).toContain("+5.41%");
|
|
1438
1445
|
});
|
|
1439
1446
|
|
|
1440
|
-
test("quote flash
|
|
1447
|
+
test("quote flash dims cells without changing quote sign color or row background", async () => {
|
|
1441
1448
|
const config = createPortfolioConfigWithColumns(
|
|
1442
1449
|
"broker:ibkr-flex:DU12345",
|
|
1443
1450
|
["ticker", "price", "change", "latency"],
|
|
@@ -1454,7 +1461,18 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1454
1461
|
["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
|
|
1455
1462
|
]);
|
|
1456
1463
|
state.financials = new Map([
|
|
1457
|
-
["AAPL", {
|
|
1464
|
+
["AAPL", {
|
|
1465
|
+
annualStatements: [],
|
|
1466
|
+
quarterlyStatements: [],
|
|
1467
|
+
priceHistory: [],
|
|
1468
|
+
quote: makeQuote({
|
|
1469
|
+
price: 125,
|
|
1470
|
+
bid: 124.95,
|
|
1471
|
+
ask: 125.05,
|
|
1472
|
+
change: -5,
|
|
1473
|
+
changePercent: -4.17,
|
|
1474
|
+
}),
|
|
1475
|
+
}],
|
|
1458
1476
|
["MSFT", {
|
|
1459
1477
|
annualStatements: [],
|
|
1460
1478
|
quarterlyStatements: [],
|
|
@@ -1483,7 +1501,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1483
1501
|
|
|
1484
1502
|
await flushFrame();
|
|
1485
1503
|
const beforeFrame = testSetup.captureSpans();
|
|
1486
|
-
const beforeLine = beforeFrame.lines.find((line) => line
|
|
1504
|
+
const beforeLine = beforeFrame.lines.find((line) => lineText(line).includes("AAPL"));
|
|
1487
1505
|
expect(beforeLine).toBeDefined();
|
|
1488
1506
|
|
|
1489
1507
|
await act(async () => {
|
|
@@ -1494,8 +1512,8 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1494
1512
|
price: 126,
|
|
1495
1513
|
bid: 125.95,
|
|
1496
1514
|
ask: 126.05,
|
|
1497
|
-
change:
|
|
1498
|
-
changePercent:
|
|
1515
|
+
change: -4,
|
|
1516
|
+
changePercent: -3.08,
|
|
1499
1517
|
lastUpdated: Date.now() + 1_000,
|
|
1500
1518
|
}),
|
|
1501
1519
|
});
|
|
@@ -1503,11 +1521,15 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1503
1521
|
await flushFrame();
|
|
1504
1522
|
|
|
1505
1523
|
const frame = testSetup.captureSpans();
|
|
1506
|
-
const aaplLine = frame.lines.find((line) => line
|
|
1524
|
+
const aaplLine = frame.lines.find((line) => lineText(line).includes("AAPL"));
|
|
1507
1525
|
expect(aaplLine).toBeDefined();
|
|
1508
1526
|
|
|
1509
1527
|
const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
|
|
1510
1528
|
const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
|
|
1511
1529
|
expect(afterBackgrounds).toEqual(beforeBackgrounds);
|
|
1530
|
+
const priceSpan = aaplLine!.spans.find((span) => span.text.includes("126"));
|
|
1531
|
+
expect(priceSpan).toBeDefined();
|
|
1532
|
+
expect(priceSpan!.fg.toInts().join(",")).toBe(RGBA.fromHex(colors.negative).toInts().join(","));
|
|
1533
|
+
expect(priceSpan!.attributes & TextAttributes.DIM).not.toBe(0);
|
|
1512
1534
|
});
|
|
1513
1535
|
});
|
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
|
2
2
|
import { Box } from "../../../ui";
|
|
3
3
|
import { DataTableView, Tabs, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components";
|
|
4
4
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
5
|
-
import type { PricePoint } from "../../../types/financials";
|
|
5
|
+
import type { PricePoint, Quote } from "../../../types/financials";
|
|
6
6
|
import { colors, priceColor } from "../../../theme/colors";
|
|
7
7
|
import { formatCurrency, formatPercentRaw } from "../../../utils/format";
|
|
8
8
|
import { useAssetData, useDebouncedPluginPaneState, usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
|
|
@@ -300,14 +300,35 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
300
300
|
rows.map((row) => ({ ...row, loading: true }))
|
|
301
301
|
)));
|
|
302
302
|
|
|
303
|
-
const
|
|
303
|
+
const loadQuotes = async (): Promise<Map<string, Quote | null>> => {
|
|
304
|
+
const quotes = new Map<string, Quote | null>();
|
|
305
|
+
if (dataProvider.getQuotesBatch) {
|
|
306
|
+
const results = await dataProvider.getQuotesBatch(
|
|
307
|
+
sectorDefs.map((sector) => ({ symbol: sector.etf, exchange: "" })),
|
|
308
|
+
).catch(() => []);
|
|
309
|
+
for (const result of results) {
|
|
310
|
+
quotes.set(result.target.symbol, result.quote ?? null);
|
|
311
|
+
}
|
|
312
|
+
return quotes;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await Promise.all(sectorDefs.map(async (sector) => {
|
|
316
|
+
try {
|
|
317
|
+
quotes.set(sector.etf, await dataProvider.getQuote(sector.etf, ""));
|
|
318
|
+
} catch {
|
|
319
|
+
quotes.set(sector.etf, null);
|
|
320
|
+
}
|
|
321
|
+
}));
|
|
322
|
+
return quotes;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const fetches = loadQuotes().then((quotesByEtf) => Promise.allSettled(sectorDefs.map(async (sector) => {
|
|
304
326
|
try {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
]);
|
|
327
|
+
const historyResult = await dataProvider.getPriceHistory(sector.etf, "", "1Y")
|
|
328
|
+
.then((value) => ({ status: "fulfilled" as const, value }))
|
|
329
|
+
.catch(() => ({ status: "rejected" as const }));
|
|
309
330
|
if (fetchGenRef.current !== gen) return;
|
|
310
|
-
const quote =
|
|
331
|
+
const quote = quotesByEtf.get(sector.etf) ?? null;
|
|
311
332
|
const history = historyResult.status === "fulfilled" ? historyResult.value : [];
|
|
312
333
|
const historyClose = latestHistoryClose(history);
|
|
313
334
|
const price = quote?.price ?? historyClose;
|
|
@@ -336,9 +357,9 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
336
357
|
)
|
|
337
358
|
)));
|
|
338
359
|
}
|
|
339
|
-
});
|
|
360
|
+
})));
|
|
340
361
|
|
|
341
|
-
|
|
362
|
+
fetches.then(() => {
|
|
342
363
|
if (fetchGenRef.current === gen) {
|
|
343
364
|
setLastRefreshByCollection((prev) => ({
|
|
344
365
|
...prev,
|
|
@@ -62,7 +62,24 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
62
62
|
const paneInstance = usePaneInstance();
|
|
63
63
|
const { symbol, ticker, financials } = usePaneTicker();
|
|
64
64
|
const streamingTarget = quoteSubscriptionTargetFromTicker(ticker, ticker?.metadata.ticker, "provider");
|
|
65
|
-
const streamingTargets = useMemo(() => (
|
|
65
|
+
const streamingTargets = useMemo(() => (
|
|
66
|
+
streamingTarget
|
|
67
|
+
? [{
|
|
68
|
+
...streamingTarget,
|
|
69
|
+
surface: "detail" as const,
|
|
70
|
+
visible: true,
|
|
71
|
+
selected: true,
|
|
72
|
+
weight: 100,
|
|
73
|
+
}]
|
|
74
|
+
: []
|
|
75
|
+
), [
|
|
76
|
+
streamingTarget?.symbol,
|
|
77
|
+
streamingTarget?.exchange,
|
|
78
|
+
streamingTarget?.route,
|
|
79
|
+
streamingTarget?.context?.brokerId,
|
|
80
|
+
streamingTarget?.context?.brokerInstanceId,
|
|
81
|
+
streamingTarget?.context?.instrument,
|
|
82
|
+
]);
|
|
66
83
|
useQuoteStreaming(streamingTargets);
|
|
67
84
|
|
|
68
85
|
const { collectionId } = usePaneCollection();
|
|
@@ -371,7 +371,10 @@ export function QuoteMonitorPane({ paneId, focused, width, height }: PaneProps)
|
|
|
371
371
|
symbols
|
|
372
372
|
.map((symbol) => {
|
|
373
373
|
const ticker = tickersBySymbol.get(symbol) ?? (fallbackTicker?.metadata.ticker === symbol ? fallbackTicker : null);
|
|
374
|
-
|
|
374
|
+
const target = quoteSubscriptionTargetFromTicker(ticker, symbol, "provider");
|
|
375
|
+
return target
|
|
376
|
+
? { ...target, surface: "monitor" as const, visible: true, selected: symbol === fallbackSymbol, weight: symbol === fallbackSymbol ? 100 : 90 }
|
|
377
|
+
: null;
|
|
375
378
|
})
|
|
376
379
|
.filter((target): target is QuoteSubscriptionTarget => target != null)
|
|
377
380
|
), [fallbackTicker, symbols, tickersBySymbol]);
|
|
@@ -183,31 +183,70 @@ function WorldIndicesPane({ focused, width, height }: PaneProps) {
|
|
|
183
183
|
fetchGenRef.current += 1;
|
|
184
184
|
const gen = fetchGenRef.current;
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
setQuotes((prev) => {
|
|
187
|
+
const next = new Map(prev);
|
|
188
|
+
for (const entry of WORLD_INDICES) {
|
|
189
189
|
const existing = next.get(entry.symbol);
|
|
190
190
|
next.set(entry.symbol, { quote: existing?.quote ?? null, loading: true, error: null });
|
|
191
|
+
}
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const loadQuotes = async (): Promise<QuoteMap> => {
|
|
196
|
+
const next = new Map<string, IndexQuoteState>();
|
|
197
|
+
if (dataProvider.getQuotesBatch) {
|
|
198
|
+
const results = await dataProvider.getQuotesBatch(
|
|
199
|
+
WORLD_INDICES.map((entry) => ({ symbol: entry.symbol, exchange: "" })),
|
|
200
|
+
);
|
|
201
|
+
const bySymbol = new Map(results.map((result) => [result.target.symbol, result]));
|
|
202
|
+
for (const entry of WORLD_INDICES) {
|
|
203
|
+
const result = bySymbol.get(entry.symbol);
|
|
204
|
+
next.set(entry.symbol, {
|
|
205
|
+
quote: result?.quote ?? null,
|
|
206
|
+
loading: false,
|
|
207
|
+
error: result?.error
|
|
208
|
+
? result.error instanceof Error ? result.error.message : String(result.error)
|
|
209
|
+
: null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
191
212
|
return next;
|
|
192
|
-
}
|
|
213
|
+
}
|
|
193
214
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const next = new Map(prev);
|
|
215
|
+
await Promise.all(WORLD_INDICES.map(async (entry) => {
|
|
216
|
+
try {
|
|
217
|
+
const quote = await dataProvider.getQuote(entry.symbol, "");
|
|
198
218
|
next.set(entry.symbol, { quote, loading: false, error: null });
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
219
|
+
} catch (err: unknown) {
|
|
220
|
+
next.set(entry.symbol, {
|
|
221
|
+
quote: null,
|
|
222
|
+
loading: false,
|
|
223
|
+
error: err instanceof Error ? err.message : String(err),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}));
|
|
227
|
+
return next;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
loadQuotes().then((nextQuotes) => {
|
|
231
|
+
if (fetchGenRef.current !== gen) return;
|
|
232
|
+
setQuotes((prev) => {
|
|
233
|
+
const next = new Map(prev);
|
|
234
|
+
for (const [symbol, state] of nextQuotes) {
|
|
235
|
+
next.set(symbol, state);
|
|
236
|
+
}
|
|
237
|
+
return next;
|
|
209
238
|
});
|
|
210
|
-
}
|
|
239
|
+
}).catch((err: unknown) => {
|
|
240
|
+
if (fetchGenRef.current !== gen) return;
|
|
241
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
242
|
+
setQuotes((prev) => {
|
|
243
|
+
const next = new Map(prev);
|
|
244
|
+
for (const entry of WORLD_INDICES) {
|
|
245
|
+
next.set(entry.symbol, { quote: null, loading: false, error: message });
|
|
246
|
+
}
|
|
247
|
+
return next;
|
|
248
|
+
});
|
|
249
|
+
});
|
|
211
250
|
}, [dataProvider]);
|
|
212
251
|
|
|
213
252
|
useEffect(() => {
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
applyDrop,
|
|
6
6
|
floatAtRect,
|
|
7
7
|
gridlockAllPanes,
|
|
8
|
+
getDockDividerLayouts,
|
|
9
|
+
getDockLeafLayouts,
|
|
8
10
|
getDockedPaneIds,
|
|
9
11
|
getLeafRect,
|
|
10
12
|
simulateDrop,
|
|
@@ -44,6 +46,35 @@ describe("pane-manager split-tree drops", () => {
|
|
|
44
46
|
expect(getDockedPaneIds(next)).toContain(notesPane.instanceId);
|
|
45
47
|
});
|
|
46
48
|
|
|
49
|
+
test("can reserve a gutter for dock dividers without overlapping pane rects", () => {
|
|
50
|
+
const layout = {
|
|
51
|
+
dockRoot: {
|
|
52
|
+
kind: "split" as const,
|
|
53
|
+
axis: "horizontal" as const,
|
|
54
|
+
ratio: 0.5,
|
|
55
|
+
first: { kind: "pane" as const, instanceId: "left:main" },
|
|
56
|
+
second: { kind: "pane" as const, instanceId: "right:main" },
|
|
57
|
+
},
|
|
58
|
+
instances: [
|
|
59
|
+
createPaneInstance("left", { instanceId: "left:main" }),
|
|
60
|
+
createPaneInstance("right", { instanceId: "right:main" }),
|
|
61
|
+
],
|
|
62
|
+
floating: [],
|
|
63
|
+
detached: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const leaves = getDockLeafLayouts(layout, BOUNDS, { reserveDividerGutters: true });
|
|
67
|
+
const divider = getDockDividerLayouts(layout, BOUNDS, { reserveDividerGutters: true })[0];
|
|
68
|
+
const left = leaves.find((leaf) => leaf.instanceId === "left:main");
|
|
69
|
+
const right = leaves.find((leaf) => leaf.instanceId === "right:main");
|
|
70
|
+
|
|
71
|
+
expect(left).toBeDefined();
|
|
72
|
+
expect(right).toBeDefined();
|
|
73
|
+
expect(divider).toBeDefined();
|
|
74
|
+
expect(divider!.rect.x).toBe(left!.rect.x + left!.rect.width);
|
|
75
|
+
expect(right!.rect.x).toBe(divider!.rect.x + divider!.rect.width);
|
|
76
|
+
});
|
|
77
|
+
|
|
47
78
|
test("splits the hovered pane on leaf drops and matches the preview", () => {
|
|
48
79
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
49
80
|
const notesPane = createPaneInstance("chat");
|
|
@@ -84,6 +84,7 @@ export interface DockDividerLayout {
|
|
|
84
84
|
export interface DockGeometryOptions {
|
|
85
85
|
precise?: boolean;
|
|
86
86
|
dividerSize?: number;
|
|
87
|
+
reserveDividerGutters?: boolean;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export interface LayoutSimulation {
|
|
@@ -430,18 +431,31 @@ function collectDockGeometry(
|
|
|
430
431
|
|
|
431
432
|
const precise = options.precise === true;
|
|
432
433
|
const dividerSize = options.dividerSize ?? 1;
|
|
434
|
+
const reserveDividerGutters = options.reserveDividerGutters === true && !precise;
|
|
433
435
|
|
|
434
436
|
if (node.axis === "horizontal") {
|
|
435
|
-
const
|
|
437
|
+
const reserveDividerGutter = reserveDividerGutters && bounds.width > dividerSize;
|
|
438
|
+
const splitWidth = reserveDividerGutter ? bounds.width - dividerSize : bounds.width;
|
|
439
|
+
const [firstWidth, secondWidth] = resolveSplitSizes(splitWidth, node.ratio, MIN_PANE_WIDTH, precise);
|
|
436
440
|
const firstBounds = { x: bounds.x, y: bounds.y, width: firstWidth, height: bounds.height };
|
|
437
|
-
const
|
|
441
|
+
const dividerX = reserveDividerGutter
|
|
442
|
+
? bounds.x + firstWidth
|
|
443
|
+
: precise
|
|
444
|
+
? bounds.x + firstWidth - (dividerSize / 2)
|
|
445
|
+
: bounds.x + firstWidth - 1;
|
|
446
|
+
const secondBounds = {
|
|
447
|
+
x: reserveDividerGutter ? bounds.x + firstWidth + dividerSize : bounds.x + firstWidth,
|
|
448
|
+
y: bounds.y,
|
|
449
|
+
width: secondWidth,
|
|
450
|
+
height: bounds.height,
|
|
451
|
+
};
|
|
438
452
|
dividers.push({
|
|
439
453
|
path,
|
|
440
454
|
axis: node.axis,
|
|
441
455
|
bounds: { ...bounds },
|
|
442
456
|
ratio: node.ratio,
|
|
443
457
|
rect: {
|
|
444
|
-
x:
|
|
458
|
+
x: dividerX,
|
|
445
459
|
y: bounds.y,
|
|
446
460
|
width: dividerSize,
|
|
447
461
|
height: bounds.height,
|
|
@@ -452,9 +466,21 @@ function collectDockGeometry(
|
|
|
452
466
|
return { leaves, dividers };
|
|
453
467
|
}
|
|
454
468
|
|
|
455
|
-
const
|
|
469
|
+
const reserveDividerGutter = reserveDividerGutters && bounds.height > dividerSize;
|
|
470
|
+
const splitHeight = reserveDividerGutter ? bounds.height - dividerSize : bounds.height;
|
|
471
|
+
const [firstHeight, secondHeight] = resolveSplitSizes(splitHeight, node.ratio, MIN_DOCKED_HEIGHT, precise);
|
|
456
472
|
const firstBounds = { x: bounds.x, y: bounds.y, width: bounds.width, height: firstHeight };
|
|
457
|
-
const
|
|
473
|
+
const dividerY = reserveDividerGutter
|
|
474
|
+
? bounds.y + firstHeight
|
|
475
|
+
: precise
|
|
476
|
+
? bounds.y + firstHeight - (dividerSize / 2)
|
|
477
|
+
: bounds.y + firstHeight - 1;
|
|
478
|
+
const secondBounds = {
|
|
479
|
+
x: bounds.x,
|
|
480
|
+
y: reserveDividerGutter ? bounds.y + firstHeight + dividerSize : bounds.y + firstHeight,
|
|
481
|
+
width: bounds.width,
|
|
482
|
+
height: secondHeight,
|
|
483
|
+
};
|
|
458
484
|
dividers.push({
|
|
459
485
|
path,
|
|
460
486
|
axis: node.axis,
|
|
@@ -462,7 +488,7 @@ function collectDockGeometry(
|
|
|
462
488
|
ratio: node.ratio,
|
|
463
489
|
rect: {
|
|
464
490
|
x: bounds.x,
|
|
465
|
-
y:
|
|
491
|
+
y: dividerY,
|
|
466
492
|
width: bounds.width,
|
|
467
493
|
height: dividerSize,
|
|
468
494
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
3
|
import { Buffer } from "node:buffer";
|
|
4
4
|
import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun";
|
|
5
5
|
import {
|
|
@@ -17,7 +17,7 @@ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemeP
|
|
|
17
17
|
import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
|
|
18
18
|
import { buildSoundCommand } from "../../../notifications/app-notifier";
|
|
19
19
|
import { isPaneDetached } from "../../../plugins/pane-manager";
|
|
20
|
-
import { ELECTROBUN_CONTEXT_MENU_ACTION, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
|
|
20
|
+
import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
|
|
21
21
|
import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
|
|
22
22
|
import { contextMenuSelectionMessage } from "./context-menu-click";
|
|
23
23
|
import { getContextMenuRequestId, normalizeContextMenuItems } from "./context-menu-normalize";
|
|
@@ -65,6 +65,7 @@ let desktopWorkspace: DesktopWorkspace | null = null;
|
|
|
65
65
|
let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
|
|
66
66
|
let currentThemePreview: DesktopThemePreviewState = { theme: null };
|
|
67
67
|
let desktopUpdateInProgress = false;
|
|
68
|
+
let desktopRestartInProgress = false;
|
|
68
69
|
|
|
69
70
|
const detachedWindows = new Map<string, BrowserWindow>();
|
|
70
71
|
const detachedFrameTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -82,6 +83,19 @@ function summarizeError(error: unknown): string {
|
|
|
82
83
|
return error instanceof Error ? error.stack ?? error.message : String(error);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function shellQuote(value: string): string {
|
|
87
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findMacAppBundle(execPath: string): string | null {
|
|
91
|
+
let current = dirname(execPath);
|
|
92
|
+
while (current && current !== dirname(current)) {
|
|
93
|
+
if (current.endsWith(".app")) return current;
|
|
94
|
+
current = dirname(current);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
function requireServices(): AppServices {
|
|
86
100
|
if (!services) throw new Error("Backend services have not been initialized.");
|
|
87
101
|
return services;
|
|
@@ -633,6 +647,62 @@ function teardownServices(): void {
|
|
|
633
647
|
services = null;
|
|
634
648
|
}
|
|
635
649
|
|
|
650
|
+
function spawnDetached(command: string[]): void {
|
|
651
|
+
const child = Bun.spawn(command, {
|
|
652
|
+
detached: true,
|
|
653
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
654
|
+
} as any);
|
|
655
|
+
child.unref();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function scheduleDesktopRelaunch(): void {
|
|
659
|
+
const pid = process.pid;
|
|
660
|
+
const macAppBundle = process.platform === "darwin" ? findMacAppBundle(process.execPath) : null;
|
|
661
|
+
|
|
662
|
+
if (macAppBundle) {
|
|
663
|
+
spawnDetached([
|
|
664
|
+
"sh",
|
|
665
|
+
"-c",
|
|
666
|
+
`while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; sleep 0.5; open ${shellQuote(macAppBundle)}`,
|
|
667
|
+
]);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (process.platform === "win32") {
|
|
672
|
+
spawnDetached([process.execPath, ...process.argv.slice(1)]);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const args = process.argv.slice(1).map(shellQuote).join(" ");
|
|
677
|
+
spawnDetached([
|
|
678
|
+
"sh",
|
|
679
|
+
"-c",
|
|
680
|
+
`while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; cd ${shellQuote(process.cwd())}; ${shellQuote(process.execPath)}${args ? ` ${args}` : ""}`,
|
|
681
|
+
]);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function restartDesktopApp(message: DesktopRestartMessage = {}): void {
|
|
685
|
+
if (desktopRestartInProgress) return;
|
|
686
|
+
desktopRestartInProgress = true;
|
|
687
|
+
console.error("[desktop-recovery] restart requested", {
|
|
688
|
+
reason: message.reason,
|
|
689
|
+
source: message.source,
|
|
690
|
+
pid: process.pid,
|
|
691
|
+
execPath: process.execPath,
|
|
692
|
+
argv: process.argv,
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
scheduleDesktopRelaunch();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
desktopRestartInProgress = false;
|
|
698
|
+
console.error("[desktop-recovery] failed to schedule restart", summarizeError(error));
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
closeAllDetachedWindows();
|
|
702
|
+
teardownServices();
|
|
703
|
+
Utils.quit();
|
|
704
|
+
}
|
|
705
|
+
|
|
636
706
|
function resolveDetachedWindowTitle(instanceId: string): string {
|
|
637
707
|
const instance = currentConfig ? findPaneInstance(currentConfig.layout, instanceId) : null;
|
|
638
708
|
if (!instance) return "Gloomberb";
|
|
@@ -1087,6 +1157,12 @@ async function handleBackendRequest(
|
|
|
1087
1157
|
requireServices().persistence.pluginState.delete(payload.pluginId as string, payload.key as string);
|
|
1088
1158
|
syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, null);
|
|
1089
1159
|
return null;
|
|
1160
|
+
case "host.restart":
|
|
1161
|
+
restartDesktopApp({
|
|
1162
|
+
reason: typeof payload.reason === "string" ? payload.reason : undefined,
|
|
1163
|
+
source: typeof payload.source === "string" ? payload.source : "backend-request",
|
|
1164
|
+
});
|
|
1165
|
+
return null;
|
|
1090
1166
|
case "host.exit":
|
|
1091
1167
|
closeAllDetachedWindows();
|
|
1092
1168
|
teardownServices();
|
|
@@ -1153,7 +1229,11 @@ function createWindowRpc(key: string): DesktopRpc {
|
|
|
1153
1229
|
requests: {
|
|
1154
1230
|
"backend.request": async ({ method, payload }) => encodeRpcValue(await handleBackendRequest(rpc, method, payload)),
|
|
1155
1231
|
},
|
|
1156
|
-
messages: {
|
|
1232
|
+
messages: {
|
|
1233
|
+
"host.restart": (message) => {
|
|
1234
|
+
restartDesktopApp(message);
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1157
1237
|
},
|
|
1158
1238
|
});
|
|
1159
1239
|
registerWindowRpc(key, rpc);
|
|
@@ -53,6 +53,11 @@ export interface CapabilityEventMessage {
|
|
|
53
53
|
event: unknown;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface DesktopRestartMessage {
|
|
57
|
+
reason?: string;
|
|
58
|
+
source?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export interface ElectrobunDesktopRpcSchema {
|
|
57
62
|
bun: {
|
|
58
63
|
requests: {
|
|
@@ -61,7 +66,9 @@ export interface ElectrobunDesktopRpcSchema {
|
|
|
61
66
|
response: unknown;
|
|
62
67
|
};
|
|
63
68
|
};
|
|
64
|
-
messages: {
|
|
69
|
+
messages: {
|
|
70
|
+
"host.restart": DesktopRestartMessage;
|
|
71
|
+
};
|
|
65
72
|
};
|
|
66
73
|
webview: {
|
|
67
74
|
requests: {};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CapabilityEventMessage,
|
|
6
6
|
type ContextMenuSelectMessage,
|
|
7
7
|
type DesktopDockPreviewMessage,
|
|
8
|
+
type DesktopRestartMessage,
|
|
8
9
|
type DesktopStateMessage,
|
|
9
10
|
type DesktopThemePreviewMessage,
|
|
10
11
|
type ElectrobunBackendInit,
|
|
@@ -131,6 +132,10 @@ export async function initElectrobunBackend(payload?: { kind?: "main" | "detache
|
|
|
131
132
|
return initSnapshot;
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
export function requestElectrobunRestart(message: DesktopRestartMessage = {}): void {
|
|
136
|
+
rpc.send["host.restart"](message);
|
|
137
|
+
}
|
|
138
|
+
|
|
134
139
|
export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null {
|
|
135
140
|
return initSnapshot;
|
|
136
141
|
}
|