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
|
@@ -73,8 +73,10 @@ import {
|
|
|
73
73
|
movePaneRelative,
|
|
74
74
|
removePane,
|
|
75
75
|
swapPanes,
|
|
76
|
+
type LayoutBounds,
|
|
76
77
|
} from "../../plugins/pane-manager";
|
|
77
78
|
import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
|
|
79
|
+
import { resolveAppHeaderHeightCells } from "../layout/shell";
|
|
78
80
|
import { CHART_RENDERER_PREFERENCES } from "../chart/chart-types";
|
|
79
81
|
import {
|
|
80
82
|
getEmptyState,
|
|
@@ -168,6 +170,7 @@ interface CommandBarProps {
|
|
|
168
170
|
pluginRegistry: PluginRegistry;
|
|
169
171
|
quitApp: () => void;
|
|
170
172
|
onCheckForUpdates?: () => void | Promise<void>;
|
|
173
|
+
onNativeOccluderChange?: (rect: LayoutBounds | null) => void;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
type WorkflowStringValues = Record<string, string>;
|
|
@@ -645,6 +648,7 @@ export function CommandBar({
|
|
|
645
648
|
pluginRegistry,
|
|
646
649
|
quitApp,
|
|
647
650
|
onCheckForUpdates,
|
|
651
|
+
onNativeOccluderChange,
|
|
648
652
|
}: CommandBarProps) {
|
|
649
653
|
const dispatch = useAppDispatch();
|
|
650
654
|
const themeColors = useThemeColors();
|
|
@@ -709,7 +713,7 @@ export function CommandBar({
|
|
|
709
713
|
}, []);
|
|
710
714
|
const { symbol: activeTickerSymbol, ticker: activeTickerData, financials: activeFinancials } = useFocusedTicker();
|
|
711
715
|
const { width: termWidth, height: termHeight } = useViewport();
|
|
712
|
-
const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
|
|
716
|
+
const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18, titleBarOverlay } = useUiCapabilities();
|
|
713
717
|
const availableCommands = useMemo(
|
|
714
718
|
() => nativePaneChrome
|
|
715
719
|
? commands.filter((command) => command.id !== "cycle-chart-renderer")
|
|
@@ -4000,7 +4004,11 @@ export function CommandBar({
|
|
|
4000
4004
|
rootResultModel.items,
|
|
4001
4005
|
rootTickerSearchArg,
|
|
4002
4006
|
]);
|
|
4003
|
-
const
|
|
4007
|
+
const rootSectionOrder = rootPlainTickerSearchArg ? "app-first" : "default";
|
|
4008
|
+
const orderedRootResults = useMemo(
|
|
4009
|
+
() => orderListResults(rootResults, { sectionOrder: rootSectionOrder }),
|
|
4010
|
+
[rootResults, rootSectionOrder],
|
|
4011
|
+
);
|
|
4004
4012
|
const activeRootProviderResultsKey = useMemo(() => {
|
|
4005
4013
|
if (!rootTickerSearchArg || rootProviderResultsQuery !== rootTickerSearchArg || !rootProviderResults) return null;
|
|
4006
4014
|
return [
|
|
@@ -4316,6 +4324,7 @@ export function CommandBar({
|
|
|
4316
4324
|
emptyDetail: emptyState.detail,
|
|
4317
4325
|
footerLeft: getScreenFooterLeft(null),
|
|
4318
4326
|
footerRight: getScreenFooterRight(null),
|
|
4327
|
+
sectionOrder: rootSectionOrder,
|
|
4319
4328
|
};
|
|
4320
4329
|
}
|
|
4321
4330
|
|
|
@@ -4463,6 +4472,7 @@ export function CommandBar({
|
|
|
4463
4472
|
rootModeInfo.kind,
|
|
4464
4473
|
rootQuery,
|
|
4465
4474
|
orderedRootResults,
|
|
4475
|
+
rootSectionOrder,
|
|
4466
4476
|
rootSearching,
|
|
4467
4477
|
rootSelectedIdx,
|
|
4468
4478
|
rootShortcutIntent,
|
|
@@ -5384,6 +5394,13 @@ export function CommandBar({
|
|
|
5384
5394
|
: bodyHeight + 7;
|
|
5385
5395
|
const barLeft = Math.max(4, Math.floor((termWidth - barWidth) / 2));
|
|
5386
5396
|
const barTop = Math.max(1, Math.floor((termHeight - barHeight) / 2));
|
|
5397
|
+
const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
|
|
5398
|
+
const nativeOccluderRect = useMemo<LayoutBounds>(() => ({
|
|
5399
|
+
x: barLeft,
|
|
5400
|
+
y: barTop - appHeaderHeight,
|
|
5401
|
+
width: barWidth,
|
|
5402
|
+
height: barHeight,
|
|
5403
|
+
}), [appHeaderHeight, barHeight, barLeft, barTop, barWidth]);
|
|
5387
5404
|
const resultsInnerWidth = Math.max(12, barWidth - nativePanelPaddingColumns - contentPadding * 2);
|
|
5388
5405
|
const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
|
|
5389
5406
|
const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
|
|
@@ -5413,6 +5430,13 @@ export function CommandBar({
|
|
|
5413
5430
|
}
|
|
5414
5431
|
}, [listBodyHeight, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]);
|
|
5415
5432
|
|
|
5433
|
+
useLayoutEffect(() => {
|
|
5434
|
+
onNativeOccluderChange?.(nativeOccluderRect);
|
|
5435
|
+
return () => {
|
|
5436
|
+
onNativeOccluderChange?.(null);
|
|
5437
|
+
};
|
|
5438
|
+
}, [nativeOccluderRect, onNativeOccluderChange]);
|
|
5439
|
+
|
|
5416
5440
|
useEffect(() => {
|
|
5417
5441
|
if (!nativePaneChrome || currentRoute?.kind !== "workflow") return;
|
|
5418
5442
|
const scrollBox = workflowScrollRef.current;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildSections } from "./view-model";
|
|
1
|
+
import { buildSections, type CommandBarSectionOrder } from "./view-model";
|
|
2
2
|
|
|
3
3
|
export interface ResultItem {
|
|
4
4
|
id: string;
|
|
@@ -31,6 +31,7 @@ export interface ListScreenState {
|
|
|
31
31
|
emptyDetail: string;
|
|
32
32
|
footerLeft: string;
|
|
33
33
|
footerRight: string;
|
|
34
|
+
sectionOrder?: CommandBarSectionOrder;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export type CommandBarListRow =
|
|
@@ -41,13 +42,16 @@ export type CommandBarListRow =
|
|
|
41
42
|
| { kind: "spinner"; id: string; label: string }
|
|
42
43
|
| { kind: "filler"; id: string };
|
|
43
44
|
|
|
44
|
-
export function orderListResults(
|
|
45
|
-
|
|
45
|
+
export function orderListResults(
|
|
46
|
+
results: ResultItem[],
|
|
47
|
+
options?: { sectionOrder?: CommandBarSectionOrder },
|
|
48
|
+
): ResultItem[] {
|
|
49
|
+
return buildSections(results, options).flatMap((section) => section.items);
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function buildListRows(listState: ListScreenState): CommandBarListRow[] {
|
|
49
53
|
const rows: CommandBarListRow[] = [];
|
|
50
|
-
const sections = buildSections(listState.results);
|
|
54
|
+
const sections = buildSections(listState.results, { sectionOrder: listState.sectionOrder });
|
|
51
55
|
let globalIdx = 0;
|
|
52
56
|
sections.forEach((section, sectionIndex) => {
|
|
53
57
|
if (sectionIndex > 0) {
|
|
@@ -47,6 +47,26 @@ describe("command bar view model helpers", () => {
|
|
|
47
47
|
expect(sections.map((section) => section.category)).toEqual(["Tickers", "Config", "Danger", "Debug"]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("keeps provider ticker suggestions behind app sections in app-first order", () => {
|
|
51
|
+
const sections = buildSections([
|
|
52
|
+
{ id: "pane", category: "Panes" },
|
|
53
|
+
{ id: "primary", category: "Primary Listing" },
|
|
54
|
+
{ id: "other", category: "Other Listings" },
|
|
55
|
+
{ id: "fund", category: "Funds & Derivatives" },
|
|
56
|
+
{ id: "saved", category: "Saved" },
|
|
57
|
+
{ id: "exact", category: "Exact Match" },
|
|
58
|
+
], { sectionOrder: "app-first" });
|
|
59
|
+
|
|
60
|
+
expect(sections.map((section) => section.category)).toEqual([
|
|
61
|
+
"Exact Match",
|
|
62
|
+
"Saved",
|
|
63
|
+
"Panes",
|
|
64
|
+
"Primary Listing",
|
|
65
|
+
"Other Listings",
|
|
66
|
+
"Funds & Derivatives",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
50
70
|
test("derives row presentation for toggles and current rows", () => {
|
|
51
71
|
expect(getRowPresentation({
|
|
52
72
|
id: "plugin:news",
|
|
@@ -15,6 +15,8 @@ export interface CommandBarSection<T> {
|
|
|
15
15
|
items: T[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type CommandBarSectionOrder = "default" | "app-first";
|
|
19
|
+
|
|
18
20
|
export interface CommandBarItemView {
|
|
19
21
|
id: string;
|
|
20
22
|
label: string;
|
|
@@ -59,7 +61,10 @@ export function resolveCommandBarMode(query: string, commandList?: Command[]): C
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
export function buildSections<T extends { category: string }>(
|
|
64
|
+
export function buildSections<T extends { category: string }>(
|
|
65
|
+
items: T[],
|
|
66
|
+
options?: { sectionOrder?: CommandBarSectionOrder },
|
|
67
|
+
): Array<CommandBarSection<T>> {
|
|
63
68
|
const sections: Array<CommandBarSection<T>> = [];
|
|
64
69
|
for (const item of items) {
|
|
65
70
|
let section = sections.find((candidate) => candidate.category === item.category);
|
|
@@ -72,7 +77,9 @@ export function buildSections<T extends { category: string }>(items: T[]): Array
|
|
|
72
77
|
return sections
|
|
73
78
|
.map((section, index) => ({ section, index }))
|
|
74
79
|
.sort((a, b) => {
|
|
75
|
-
const
|
|
80
|
+
const leftPriority = getCategoryPriority(a.section.category, options?.sectionOrder);
|
|
81
|
+
const rightPriority = getCategoryPriority(b.section.category, options?.sectionOrder);
|
|
82
|
+
const priorityDiff = leftPriority - rightPriority;
|
|
76
83
|
return priorityDiff !== 0 ? priorityDiff : a.index - b.index;
|
|
77
84
|
})
|
|
78
85
|
.map(({ section }) => section);
|
|
@@ -126,10 +133,15 @@ export function truncateText(text: string, width: number): string {
|
|
|
126
133
|
return `${text.slice(0, width - 3)}...`;
|
|
127
134
|
}
|
|
128
135
|
|
|
129
|
-
function getCategoryPriority(category: string): number {
|
|
136
|
+
function getCategoryPriority(category: string, sectionOrder: CommandBarSectionOrder = "default"): number {
|
|
130
137
|
const normalized = category.trim().toLowerCase();
|
|
131
138
|
if (normalized === "exact match") return -50;
|
|
132
139
|
if (normalized === "saved") return -40;
|
|
140
|
+
if (sectionOrder === "app-first") {
|
|
141
|
+
if (normalized === "primary listing") return 100;
|
|
142
|
+
if (normalized === "other listings") return 110;
|
|
143
|
+
if (normalized === "funds & derivatives") return 120;
|
|
144
|
+
}
|
|
133
145
|
if (normalized === "primary listing") return -30;
|
|
134
146
|
if (normalized === "other listings") return -20;
|
|
135
147
|
if (normalized === "funds & derivatives") return -10;
|
|
@@ -560,6 +560,32 @@ describe("Shell", () => {
|
|
|
560
560
|
]);
|
|
561
561
|
});
|
|
562
562
|
|
|
563
|
+
test("keeps command bar native occlusion scoped to the panel", () => {
|
|
564
|
+
const state = buildNativeWindowState(
|
|
565
|
+
["portfolio-list:main"],
|
|
566
|
+
[],
|
|
567
|
+
null,
|
|
568
|
+
{ open: false, width: 120, contentHeight: 40 },
|
|
569
|
+
[
|
|
570
|
+
{
|
|
571
|
+
id: "command-bar:panel",
|
|
572
|
+
rect: { x: 24, y: 8, width: 72, height: 14 },
|
|
573
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
expect(state.occluders).toEqual([
|
|
579
|
+
{
|
|
580
|
+
id: "command-bar:panel",
|
|
581
|
+
paneId: null,
|
|
582
|
+
rect: { x: 24, y: 9, width: 72, height: 14 },
|
|
583
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
584
|
+
},
|
|
585
|
+
]);
|
|
586
|
+
expect(state.occluders.some((occluder) => occluder.id === "overlay:global")).toBe(false);
|
|
587
|
+
});
|
|
588
|
+
|
|
563
589
|
test("adds dock dividers as global native occluders", () => {
|
|
564
590
|
const state = buildNativeWindowState(
|
|
565
591
|
["left:main", "right:main"],
|
|
@@ -76,6 +76,7 @@ interface ShellProps {
|
|
|
76
76
|
pluginRegistry: PluginRegistry;
|
|
77
77
|
desktopWindowBridge?: DesktopWindowBridge;
|
|
78
78
|
desktopDockPreview?: DesktopDockPreviewState | null;
|
|
79
|
+
commandBarNativeOccluder?: LayoutBounds | null;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
interface HoverOverlay {
|
|
@@ -192,6 +193,7 @@ const DEFAULT_HEADER_HEIGHT = 1;
|
|
|
192
193
|
const MENU_MIN_WIDTH = 18;
|
|
193
194
|
const MENU_MAX_WIDTH = 44;
|
|
194
195
|
const MENU_Z_INDEX = 10_000;
|
|
196
|
+
const DOCK_DIVIDER_SIZE = 1;
|
|
195
197
|
const PANE_DRAG_THRESHOLD = 2;
|
|
196
198
|
const PRECISE_PANE_DRAG_THRESHOLD = 0.15;
|
|
197
199
|
const PANE_MANAGEMENT_ACCELERATORS = {
|
|
@@ -498,6 +500,39 @@ export function resolveNativeDockDividers(
|
|
|
498
500
|
));
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
function resolveDividerPreviewRect(
|
|
504
|
+
axis: "horizontal" | "vertical",
|
|
505
|
+
bounds: LayoutBounds,
|
|
506
|
+
ratio: number,
|
|
507
|
+
nativePaneChrome: boolean,
|
|
508
|
+
): LayoutBounds {
|
|
509
|
+
if (axis === "horizontal") {
|
|
510
|
+
const offset = nativePaneChrome
|
|
511
|
+
? bounds.width * ratio
|
|
512
|
+
: bounds.width > DOCK_DIVIDER_SIZE
|
|
513
|
+
? Math.round((bounds.width - DOCK_DIVIDER_SIZE) * ratio)
|
|
514
|
+
: Math.max(0, Math.round(bounds.width * ratio) - DOCK_DIVIDER_SIZE);
|
|
515
|
+
return {
|
|
516
|
+
x: nativePaneChrome ? bounds.x + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.x + offset,
|
|
517
|
+
y: bounds.y,
|
|
518
|
+
width: DOCK_DIVIDER_SIZE,
|
|
519
|
+
height: bounds.height,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const offset = nativePaneChrome
|
|
524
|
+
? bounds.height * ratio
|
|
525
|
+
: bounds.height > DOCK_DIVIDER_SIZE
|
|
526
|
+
? Math.round((bounds.height - DOCK_DIVIDER_SIZE) * ratio)
|
|
527
|
+
: Math.max(0, Math.round(bounds.height * ratio) - DOCK_DIVIDER_SIZE);
|
|
528
|
+
return {
|
|
529
|
+
x: bounds.x,
|
|
530
|
+
y: nativePaneChrome ? bounds.y + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.y + offset,
|
|
531
|
+
width: bounds.width,
|
|
532
|
+
height: DOCK_DIVIDER_SIZE,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
501
536
|
export function finalizePaneDragRelease(
|
|
502
537
|
layout: LayoutConfig,
|
|
503
538
|
paneId: string,
|
|
@@ -789,7 +824,12 @@ function resolveExternalDockPreview(
|
|
|
789
824
|
}
|
|
790
825
|
}
|
|
791
826
|
|
|
792
|
-
export function Shell({
|
|
827
|
+
export function Shell({
|
|
828
|
+
pluginRegistry,
|
|
829
|
+
desktopWindowBridge,
|
|
830
|
+
desktopDockPreview,
|
|
831
|
+
commandBarNativeOccluder = null,
|
|
832
|
+
}: ShellProps) {
|
|
793
833
|
useThemeColors();
|
|
794
834
|
const dispatch = useAppDispatch();
|
|
795
835
|
const config = useAppSelector((state) => state.config);
|
|
@@ -1047,7 +1087,9 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1047
1087
|
event.stopPropagation();
|
|
1048
1088
|
});
|
|
1049
1089
|
|
|
1050
|
-
const dockGeometryOptions = useMemo(() => (
|
|
1090
|
+
const dockGeometryOptions = useMemo(() => (
|
|
1091
|
+
nativePaneChrome ? { precise: true } : { reserveDividerGutters: true }
|
|
1092
|
+
), [nativePaneChrome]);
|
|
1051
1093
|
const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
|
|
1052
1094
|
const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
|
|
1053
1095
|
const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
|
|
@@ -1092,8 +1134,29 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1092
1134
|
});
|
|
1093
1135
|
}
|
|
1094
1136
|
|
|
1137
|
+
if (menuState) {
|
|
1138
|
+
occluders.push({
|
|
1139
|
+
id: `pane-menu:${menuState.paneId}`,
|
|
1140
|
+
rect: {
|
|
1141
|
+
x: menuState.x,
|
|
1142
|
+
y: menuState.y,
|
|
1143
|
+
width: menuState.width,
|
|
1144
|
+
height: menuState.items.length + 2,
|
|
1145
|
+
},
|
|
1146
|
+
zIndex: MENU_Z_INDEX,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (commandBarNativeOccluder) {
|
|
1151
|
+
occluders.push({
|
|
1152
|
+
id: "command-bar:panel",
|
|
1153
|
+
rect: commandBarNativeOccluder,
|
|
1154
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1095
1158
|
return occluders;
|
|
1096
|
-
}, [activeHoverOverlay, activePaneDrag, dragFloatingRect, effectiveDockPreview]);
|
|
1159
|
+
}, [activeHoverOverlay, activePaneDrag, commandBarNativeOccluder, dragFloatingRect, effectiveDockPreview, menuState]);
|
|
1097
1160
|
const nativeDockDividers = useMemo(
|
|
1098
1161
|
() => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
|
|
1099
1162
|
[dividerPreview, dockDividerLayouts],
|
|
@@ -1107,12 +1170,12 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1107
1170
|
zIndex: pane.floating?.zIndex ?? 50,
|
|
1108
1171
|
})),
|
|
1109
1172
|
dragFloatingRect,
|
|
1110
|
-
{ open:
|
|
1173
|
+
{ open: dialogOpen, width, contentHeight },
|
|
1111
1174
|
nativeTransientOccluders,
|
|
1112
1175
|
nativeDockDividers,
|
|
1113
1176
|
appHeaderHeight,
|
|
1114
1177
|
),
|
|
1115
|
-
[appHeaderHeight, contentHeight, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders,
|
|
1178
|
+
[appHeaderHeight, contentHeight, dialogOpen, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, visibleFloatingPanes, width],
|
|
1116
1179
|
);
|
|
1117
1180
|
|
|
1118
1181
|
useEffect(() => {
|
|
@@ -1194,22 +1257,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1194
1257
|
const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
|
|
1195
1258
|
const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
|
|
1196
1259
|
const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
|
|
1197
|
-
const
|
|
1198
|
-
? drag.bounds.width * nextRatio
|
|
1199
|
-
: drag.bounds.height * nextRatio;
|
|
1200
|
-
const nextRect = drag.axis === "horizontal"
|
|
1201
|
-
? {
|
|
1202
|
-
x: nativePaneChrome ? drag.bounds.x + offset - 0.5 : drag.bounds.x + Math.round(offset) - 1,
|
|
1203
|
-
y: drag.bounds.y,
|
|
1204
|
-
width: 1,
|
|
1205
|
-
height: drag.bounds.height,
|
|
1206
|
-
}
|
|
1207
|
-
: {
|
|
1208
|
-
x: drag.bounds.x,
|
|
1209
|
-
y: nativePaneChrome ? drag.bounds.y + offset - 0.5 : drag.bounds.y + Math.round(offset) - 1,
|
|
1210
|
-
width: drag.bounds.width,
|
|
1211
|
-
height: 1,
|
|
1212
|
-
};
|
|
1260
|
+
const nextRect = resolveDividerPreviewRect(drag.axis, drag.bounds, nextRatio, nativePaneChrome);
|
|
1213
1261
|
updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
|
|
1214
1262
|
} else if (drag.type === "pane-drag") {
|
|
1215
1263
|
if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
|
|
@@ -56,17 +56,6 @@ const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
|
|
|
56
56
|
"pnl_pct",
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
|
-
function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: string): string {
|
|
60
|
-
switch (direction) {
|
|
61
|
-
case "up":
|
|
62
|
-
return colors.positive;
|
|
63
|
-
case "down":
|
|
64
|
-
return colors.negative;
|
|
65
|
-
default:
|
|
66
|
-
return fallbackColor === colors.textDim ? colors.text : colors.textBright;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
59
|
export interface TickerListTableProps {
|
|
71
60
|
columns: ColumnConfig[];
|
|
72
61
|
tickers: TickerRecord[];
|
|
@@ -239,13 +228,13 @@ const TickerListRow = memo(function TickerListRow({
|
|
|
239
228
|
const { text, color } = resolveCell(column, ticker, financials);
|
|
240
229
|
const baseFg = color || (isSelected ? colors.selectedText : colors.text);
|
|
241
230
|
const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
|
|
242
|
-
const cellFg = shouldFlash
|
|
243
|
-
? resolveQuoteFlashColor(flashDirection, baseFg)
|
|
244
|
-
: baseFg;
|
|
245
231
|
|
|
246
232
|
return (
|
|
247
233
|
<Box key={column.id} width={column.width + 1}>
|
|
248
|
-
<Text
|
|
234
|
+
<Text
|
|
235
|
+
fg={baseFg}
|
|
236
|
+
attributes={shouldFlash ? TextAttributes.DIM : TextAttributes.NONE}
|
|
237
|
+
>
|
|
249
238
|
{padTo(text, column.width, column.align)}
|
|
250
239
|
</Text>
|
|
251
240
|
</Box>
|
|
@@ -73,6 +73,23 @@ function targetFromTicker(ticker: TickerRecord): HydrationTarget {
|
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function resolvePortfolioPaneCollectionId(
|
|
77
|
+
config: AppConfig,
|
|
78
|
+
paneState: Record<string, unknown> | undefined,
|
|
79
|
+
instance: AppConfig["layout"]["instances"][number],
|
|
80
|
+
): string | null {
|
|
81
|
+
const stateCollectionId = typeof paneState?.collectionId === "string" ? paneState.collectionId : null;
|
|
82
|
+
if (stateCollectionId) return stateCollectionId;
|
|
83
|
+
if (typeof instance.params?.collectionId === "string") return instance.params.collectionId;
|
|
84
|
+
return config.portfolios[0]?.id ?? config.watchlists[0]?.id ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tickerInCollection(ticker: TickerRecord, collectionId: string | null): boolean {
|
|
88
|
+
if (!collectionId) return false;
|
|
89
|
+
return ticker.metadata.portfolios.includes(collectionId)
|
|
90
|
+
|| ticker.metadata.watchlists.includes(collectionId);
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSnapshot {
|
|
77
94
|
const seen = new Set<string>();
|
|
78
95
|
const hydrationTargets: HydrationTarget[] = [];
|
|
@@ -104,6 +121,14 @@ export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSna
|
|
|
104
121
|
if (instance.binding?.kind === "fixed") {
|
|
105
122
|
pushTarget(state.tickers.get(instance.binding.symbol));
|
|
106
123
|
}
|
|
124
|
+
if (instance.paneId === "portfolio-list") {
|
|
125
|
+
const collectionId = resolvePortfolioPaneCollectionId(state.config, paneState, instance);
|
|
126
|
+
for (const ticker of state.tickers.values()) {
|
|
127
|
+
if (tickerInCollection(ticker, collectionId)) {
|
|
128
|
+
pushTarget(ticker);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
return {
|
|
@@ -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
|
});
|