gloomberb 0.7.0 → 0.7.2
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 +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { act, useState } from "react";
|
|
2
|
+
import { act, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { testRender } from "../renderers/opentui/test-utils";
|
|
4
4
|
import type { ColumnConfig } from "../types/config";
|
|
5
5
|
import type { TickerFinancials } from "../types/financials";
|
|
6
6
|
import type { TickerRecord } from "../types/ticker";
|
|
7
|
+
import type { ScrollBoxRenderable } from "../ui";
|
|
7
8
|
import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
|
|
9
|
+
import { TickerListTableView } from "./ticker-list-table-view";
|
|
8
10
|
|
|
9
11
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
10
12
|
let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
|
|
13
|
+
let setHarnessTickers: ((tickers: TickerRecord[]) => void) | null = null;
|
|
14
|
+
let tableScrollRef: ScrollBoxRenderable | null = null;
|
|
11
15
|
let resolveCellCallCount = 0;
|
|
12
16
|
|
|
13
17
|
const columns: ColumnConfig[] = [
|
|
@@ -60,6 +64,16 @@ function TickerListTableHarness() {
|
|
|
60
64
|
|
|
61
65
|
function LargeTickerListTableHarness() {
|
|
62
66
|
const [cursorSymbol, setCursorSymbol] = useState("T0");
|
|
67
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
tableScrollRef = scrollRef.current;
|
|
71
|
+
return () => {
|
|
72
|
+
if (tableScrollRef === scrollRef.current) {
|
|
73
|
+
tableScrollRef = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
});
|
|
63
77
|
|
|
64
78
|
return (
|
|
65
79
|
<TickerListTable
|
|
@@ -71,6 +85,35 @@ function LargeTickerListTableHarness() {
|
|
|
71
85
|
setCursorSymbol={setCursorSymbol}
|
|
72
86
|
resolveCell={resolveCell}
|
|
73
87
|
financialsMap={financialsMap}
|
|
88
|
+
scrollRef={scrollRef}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ReorderingTickerListTableViewHarness() {
|
|
94
|
+
const [rows, setRows] = useState(manyTickers);
|
|
95
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
96
|
+
setHarnessTickers = setRows;
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
tableScrollRef = scrollRef.current;
|
|
100
|
+
return () => {
|
|
101
|
+
if (tableScrollRef === scrollRef.current) {
|
|
102
|
+
tableScrollRef = null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<TickerListTableView
|
|
109
|
+
focused
|
|
110
|
+
columns={columns}
|
|
111
|
+
tickers={rows}
|
|
112
|
+
cursorSymbol="T0"
|
|
113
|
+
setCursorSymbol={() => {}}
|
|
114
|
+
resolveCell={resolveCell}
|
|
115
|
+
financialsMap={financialsMap}
|
|
116
|
+
scrollRef={scrollRef}
|
|
74
117
|
/>
|
|
75
118
|
);
|
|
76
119
|
}
|
|
@@ -84,6 +127,8 @@ afterEach(async () => {
|
|
|
84
127
|
}
|
|
85
128
|
resolveCellCallCount = 0;
|
|
86
129
|
setHarnessCursorSymbol = null;
|
|
130
|
+
setHarnessTickers = null;
|
|
131
|
+
tableScrollRef = null;
|
|
87
132
|
});
|
|
88
133
|
|
|
89
134
|
describe("TickerListTable", () => {
|
|
@@ -125,5 +170,41 @@ describe("TickerListTable", () => {
|
|
|
125
170
|
expect(frame).toContain("T0");
|
|
126
171
|
expect(frame).not.toContain("T999");
|
|
127
172
|
expect(resolveCellCallCount).toBeLessThan(40);
|
|
173
|
+
|
|
174
|
+
await act(async () => {
|
|
175
|
+
for (let index = 0; index < 12; index++) {
|
|
176
|
+
await testSetup!.mockMouse.scroll(2, 2, "down");
|
|
177
|
+
}
|
|
178
|
+
await Promise.resolve();
|
|
179
|
+
await testSetup!.renderOnce();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const scrollTop = tableScrollRef?.scrollTop ?? 0;
|
|
183
|
+
expect(scrollTop).toBeGreaterThan(0);
|
|
184
|
+
expect(testSetup.captureCharFrame()).toContain(`T${scrollTop}`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("preserves manual scroll when market data reorders rows around the same cursor", async () => {
|
|
188
|
+
testSetup = await testRender(
|
|
189
|
+
<ReorderingTickerListTableViewHarness />,
|
|
190
|
+
{ width: 20, height: 8 },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
await testSetup!.renderOnce();
|
|
195
|
+
await testSetup!.renderOnce();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
tableScrollRef!.scrollTop = 20;
|
|
199
|
+
expect(tableScrollRef?.scrollTop).toBe(20);
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
setHarnessTickers?.([...manyTickers.slice(1), manyTickers[0]!]);
|
|
203
|
+
await Promise.resolve();
|
|
204
|
+
await testSetup!.renderOnce();
|
|
205
|
+
await testSetup!.renderOnce();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(tableScrollRef?.scrollTop).toBe(20);
|
|
128
209
|
});
|
|
129
210
|
});
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Box,
|
|
3
|
+
ScrollBox,
|
|
4
|
+
Text,
|
|
5
|
+
tickerContextMenuItems,
|
|
6
|
+
useContextMenu,
|
|
7
|
+
useNativeRenderer,
|
|
8
|
+
useRendererHost,
|
|
9
|
+
useUiCapabilities,
|
|
10
|
+
} from "../ui";
|
|
2
11
|
import { memo, useCallback, useMemo, useRef, useState, type RefObject } from "react";
|
|
3
12
|
import { TextAttributes, type ScrollBoxRenderable } from "../ui";
|
|
4
13
|
import { EmptyState } from "./ui";
|
|
5
14
|
import { colors, hoverBg } from "../theme/colors";
|
|
15
|
+
import { useThemeColors } from "../theme/theme-context";
|
|
6
16
|
import { getSharedRegistry } from "../plugins/registry";
|
|
7
17
|
import type { ColumnConfig } from "../types/config";
|
|
8
18
|
import type { TickerFinancials, PricePoint } from "../types/financials";
|
|
@@ -12,8 +22,8 @@ import { renderChart, resolveChartPalette, type StyledContent } from "./chart/ch
|
|
|
12
22
|
import type { ProjectedChartPoint } from "./chart/chart-data";
|
|
13
23
|
import { tableContentWidthProps, useMeasuredTableContentWidth } from "./ui/table-layout";
|
|
14
24
|
import { useViewport } from "../react/input";
|
|
15
|
-
import { useRafCallback } from "../react/use-raf-callback";
|
|
16
25
|
import { measurePerf } from "../utils/perf-marks";
|
|
26
|
+
import { useScrollBoxScrollActivity } from "./table-view-shared";
|
|
17
27
|
|
|
18
28
|
export interface TickerTableCell {
|
|
19
29
|
text: string;
|
|
@@ -102,6 +112,7 @@ const TickerListHeader = memo(function TickerListHeader({
|
|
|
102
112
|
onHeaderClick?: (columnId: string) => void;
|
|
103
113
|
onSizeChange?: () => void;
|
|
104
114
|
}) {
|
|
115
|
+
useThemeColors();
|
|
105
116
|
return (
|
|
106
117
|
<ScrollBox
|
|
107
118
|
ref={headerScrollRef}
|
|
@@ -211,6 +222,7 @@ const TickerListRow = memo(function TickerListRow({
|
|
|
211
222
|
priceHistory?: PricePoint[];
|
|
212
223
|
contentWidth: number;
|
|
213
224
|
}) {
|
|
225
|
+
useThemeColors();
|
|
214
226
|
const lastActivatedAtRef = useRef<number | null>(null);
|
|
215
227
|
const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
|
|
216
228
|
|
|
@@ -328,6 +340,7 @@ export function TickerListTable({
|
|
|
328
340
|
const hoveredIdxRef = useRef(hoveredIdx);
|
|
329
341
|
const appViewport = useViewport();
|
|
330
342
|
const renderer = useRendererHost();
|
|
343
|
+
const nativeRenderer = useNativeRenderer();
|
|
331
344
|
const { showContextMenu } = useContextMenu();
|
|
332
345
|
const { nativeContextMenu } = useUiCapabilities();
|
|
333
346
|
const [scrollVersion, setScrollVersion] = useState(0);
|
|
@@ -387,12 +400,16 @@ export function TickerListTable({
|
|
|
387
400
|
setScrollVersion((current) => current + 1);
|
|
388
401
|
}
|
|
389
402
|
onBodyScrollActivity?.();
|
|
390
|
-
|
|
403
|
+
nativeRenderer.requestRender();
|
|
404
|
+
}, [nativeRenderer, onBodyScrollActivity, virtualize]);
|
|
391
405
|
const syncHeader = useCallback(() => {
|
|
392
406
|
syncHeaderScroll?.();
|
|
393
407
|
}, [syncHeaderScroll]);
|
|
394
|
-
|
|
395
|
-
|
|
408
|
+
useScrollBoxScrollActivity({
|
|
409
|
+
scrollRef: effectiveScrollRef,
|
|
410
|
+
onVerticalScroll: handleBodyScrollActivity,
|
|
411
|
+
onHorizontalScroll: syncHeader,
|
|
412
|
+
});
|
|
396
413
|
const setHoveredIdxIfChanged = useCallback((index: number | null) => {
|
|
397
414
|
if (hoveredIdxRef.current === index) return;
|
|
398
415
|
setHoveredIdx(index);
|
|
@@ -448,10 +465,6 @@ export function TickerListTable({
|
|
|
448
465
|
scrollX
|
|
449
466
|
scrollY
|
|
450
467
|
focusable={false}
|
|
451
|
-
onMouseDown={scheduleHeaderScrollSync}
|
|
452
|
-
onMouseUp={scheduleBodyScrollActivity}
|
|
453
|
-
onMouseDrag={scheduleBodyScrollActivity}
|
|
454
|
-
onMouseScroll={scheduleBodyScrollActivity}
|
|
455
468
|
onSizeChange={measureContentWidth}
|
|
456
469
|
>
|
|
457
470
|
{tickers.length === 0 ? (
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { Box, ScrollBox, Text, useUiHost } from "../../ui";
|
|
1
|
+
import { Box, ScrollBox, Text, useNativeRenderer, useUiHost } from "../../ui";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, type RefObject } from "react";
|
|
3
3
|
import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
|
|
4
4
|
import { colors, hoverBg } from "../../theme/colors";
|
|
5
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
5
6
|
import type { ColumnConfig } from "../../types/config";
|
|
6
7
|
import { useAppDispatch, usePaneInstance } from "../../state/app-context";
|
|
7
8
|
import { useViewport } from "../../react/input";
|
|
8
9
|
import { padTo } from "../../utils/format";
|
|
9
|
-
import { useRafCallback } from "../../react/use-raf-callback";
|
|
10
10
|
import { measurePerf } from "../../utils/perf-marks";
|
|
11
11
|
import { useDoubleClickActivation } from "../use-double-click-activation";
|
|
12
|
+
import { useScrollBoxScrollActivity } from "../table-view-shared";
|
|
12
13
|
import { EmptyState } from "./status";
|
|
13
14
|
import {
|
|
14
15
|
expandTableColumns,
|
|
@@ -155,9 +156,11 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
155
156
|
scrollToIndexAlign = "nearest",
|
|
156
157
|
scrollToIndexVersion = 0,
|
|
157
158
|
}: DataTableProps<T, C>) {
|
|
159
|
+
useThemeColors();
|
|
158
160
|
const dispatch = useAppDispatch();
|
|
159
161
|
const paneInstanceId = usePaneInstance()?.instanceId ?? null;
|
|
160
162
|
const appViewport = useViewport();
|
|
163
|
+
const nativeRenderer = useNativeRenderer();
|
|
161
164
|
const [scrollVersion, setScrollVersion] = useState(0);
|
|
162
165
|
const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
|
|
163
166
|
const measuredViewportHeight = scrollRef.current?.viewport?.height;
|
|
@@ -228,9 +231,13 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
228
231
|
setScrollVersion((current) => current + 1);
|
|
229
232
|
}
|
|
230
233
|
onBodyScrollActivity();
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
nativeRenderer.requestRender();
|
|
235
|
+
}, [nativeRenderer, onBodyScrollActivity, virtualize]);
|
|
236
|
+
useScrollBoxScrollActivity({
|
|
237
|
+
scrollRef,
|
|
238
|
+
onVerticalScroll: handleBodyScrollActivity,
|
|
239
|
+
onHorizontalScroll: syncHeaderScroll,
|
|
240
|
+
});
|
|
234
241
|
const focusPane = useCallback(() => {
|
|
235
242
|
if (!paneInstanceId) return;
|
|
236
243
|
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
@@ -376,11 +383,7 @@ function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
376
383
|
focusable={false}
|
|
377
384
|
onMouseDown={() => {
|
|
378
385
|
focusPane();
|
|
379
|
-
scheduleHeaderScrollSync();
|
|
380
386
|
}}
|
|
381
|
-
onMouseUp={scheduleBodyScrollActivity}
|
|
382
|
-
onMouseDrag={scheduleBodyScrollActivity}
|
|
383
|
-
onMouseScroll={scheduleBodyScrollActivity}
|
|
384
387
|
onSizeChange={measureContentWidth}
|
|
385
388
|
>
|
|
386
389
|
{items.length === 0 ? (
|
|
@@ -2,6 +2,7 @@ import { Box, Text, useUiHost } from "../../ui";
|
|
|
2
2
|
import { TextAttributes } from "../../ui";
|
|
3
3
|
import { type ComponentType, type ReactNode } from "react";
|
|
4
4
|
import { colors } from "../../theme/colors";
|
|
5
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
5
6
|
|
|
6
7
|
export interface DialogFrameProps {
|
|
7
8
|
title: string;
|
|
@@ -11,6 +12,7 @@ export interface DialogFrameProps {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function DialogFrame({ title, children, footer, showTitleDivider = true }: DialogFrameProps) {
|
|
15
|
+
useThemeColors();
|
|
14
16
|
const HostDialogFrame = useUiHost().DialogFrame as ComponentType<DialogFrameProps> | undefined;
|
|
15
17
|
if (HostDialogFrame) {
|
|
16
18
|
return (
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildTableGridTemplateColumns,
|
|
4
|
+
getTableWidth,
|
|
5
|
+
hasMeaningfulTableHorizontalOverflow,
|
|
6
|
+
} from "./table-layout";
|
|
3
7
|
|
|
4
8
|
describe("table layout", () => {
|
|
5
9
|
test("detects only meaningful horizontal table overflow", () => {
|
|
@@ -13,4 +17,26 @@ describe("table layout", () => {
|
|
|
13
17
|
expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 1)).toBe(false);
|
|
14
18
|
expect(hasMeaningfulTableHorizontalOverflow(tableWidth, tableWidth - 2)).toBe(true);
|
|
15
19
|
});
|
|
20
|
+
|
|
21
|
+
test("keeps non-flex web table columns fixed", () => {
|
|
22
|
+
const template = buildTableGridTemplateColumns([
|
|
23
|
+
{ width: 4, align: "right" },
|
|
24
|
+
{ width: 40, flexGrow: 1 },
|
|
25
|
+
{ width: 24 },
|
|
26
|
+
{ width: 5, align: "right" },
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
expect(template).toBe(
|
|
30
|
+
"minmax(4ch, 4ch) minmax(14ch, 40fr) minmax(8ch, 24ch) minmax(5ch, 5ch)",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("keeps proportional web table columns when no column is flexible", () => {
|
|
35
|
+
const template = buildTableGridTemplateColumns([
|
|
36
|
+
{ width: 12 },
|
|
37
|
+
{ width: 8, align: "right" },
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
expect(template).toBe("minmax(8ch, 12fr) minmax(8ch, 8fr)");
|
|
41
|
+
});
|
|
16
42
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState, type RefObject } from "react";
|
|
2
2
|
import type { ScrollBoxRenderable } from "../../ui";
|
|
3
3
|
|
|
4
|
-
interface TableWidthColumn {
|
|
4
|
+
export interface TableWidthColumn {
|
|
5
5
|
width: number;
|
|
6
6
|
flexGrow?: number;
|
|
7
|
+
align?: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const TRAILING_COLUMN_GUTTER_WIDTH = 1;
|
|
@@ -32,6 +33,43 @@ export function expandTableColumns<C extends TableWidthColumn>(
|
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function normalizedColumnWidth(column: TableWidthColumn): number {
|
|
37
|
+
return Math.max(1, Math.floor(column.width));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function columnMinCh(column: TableWidthColumn): number {
|
|
41
|
+
const width = normalizedColumnWidth(column);
|
|
42
|
+
if ((column.flexGrow ?? 0) > 0) {
|
|
43
|
+
return Math.max(8, Math.min(18, Math.floor(width * 0.35)));
|
|
44
|
+
}
|
|
45
|
+
if (column.align === "right") {
|
|
46
|
+
return Math.max(3, Math.min(width, 8));
|
|
47
|
+
}
|
|
48
|
+
if (width >= 16) {
|
|
49
|
+
return Math.max(8, Math.min(16, Math.floor(width * 0.35)));
|
|
50
|
+
}
|
|
51
|
+
return Math.max(1, Math.min(width, 8));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function columnFlexWeight(column: TableWidthColumn): number {
|
|
55
|
+
const flexGrow = column.flexGrow ?? 0;
|
|
56
|
+
const baseWeight = normalizedColumnWidth(column);
|
|
57
|
+
return flexGrow > 0 ? baseWeight * Math.max(1, flexGrow) : baseWeight;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildTableGridTemplateColumns(columns: readonly TableWidthColumn[]): string {
|
|
61
|
+
const hasFlexColumn = columns.some((column) => (column.flexGrow ?? 0) > 0);
|
|
62
|
+
return columns
|
|
63
|
+
.map((column) => {
|
|
64
|
+
const width = normalizedColumnWidth(column);
|
|
65
|
+
if (!hasFlexColumn || (column.flexGrow ?? 0) > 0) {
|
|
66
|
+
return `minmax(${columnMinCh(column)}ch, ${columnFlexWeight(column)}fr)`;
|
|
67
|
+
}
|
|
68
|
+
return `minmax(${columnMinCh(column)}ch, ${width}ch)`;
|
|
69
|
+
})
|
|
70
|
+
.join(" ");
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
export function useMeasuredTableContentWidth(
|
|
36
74
|
tableWidth: number,
|
|
37
75
|
headerScrollRef: RefObject<ScrollBoxRenderable | null> | undefined,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useShortcut } from "../../react/input";
|
|
2
3
|
import { Box, ScrollBox, Text, useUiHost } from "../../ui";
|
|
3
4
|
import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
|
|
4
5
|
import { colors, hoverBg } from "../../theme/colors";
|
|
@@ -21,6 +22,8 @@ export interface TabsProps {
|
|
|
21
22
|
closeMode?: "active" | "always";
|
|
22
23
|
addLabel?: string;
|
|
23
24
|
onAdd?: () => void;
|
|
25
|
+
focused?: boolean;
|
|
26
|
+
keyboardNavigation?: boolean;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
const WHEEL_DELTA_PER_CELL = 8;
|
|
@@ -34,6 +37,8 @@ export function Tabs({
|
|
|
34
37
|
closeMode = "always",
|
|
35
38
|
addLabel = "+",
|
|
36
39
|
onAdd,
|
|
40
|
+
focused = false,
|
|
41
|
+
keyboardNavigation = true,
|
|
37
42
|
}: TabsProps) {
|
|
38
43
|
const ui = useUiHost();
|
|
39
44
|
const NativeTabs = ui.Tabs;
|
|
@@ -51,6 +56,34 @@ export function Tabs({
|
|
|
51
56
|
closeFg: colors.textMuted,
|
|
52
57
|
addFg: colors.textMuted,
|
|
53
58
|
};
|
|
59
|
+
const selectAdjacentTab = useCallback((direction: -1 | 1) => {
|
|
60
|
+
const enabledTabs = tabs.filter((tab) => !tab.disabled);
|
|
61
|
+
if (enabledTabs.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const activeIndex = enabledTabs.findIndex((tab) => tab.value === activeValue);
|
|
64
|
+
const nextIndex = activeIndex >= 0
|
|
65
|
+
? Math.max(0, Math.min(activeIndex + direction, enabledTabs.length - 1))
|
|
66
|
+
: direction > 0 ? 0 : enabledTabs.length - 1;
|
|
67
|
+
const nextTab = enabledTabs[nextIndex];
|
|
68
|
+
if (!nextTab || nextTab.value === activeValue) return;
|
|
69
|
+
onSelect(nextTab.value);
|
|
70
|
+
}, [activeValue, onSelect, tabs]);
|
|
71
|
+
|
|
72
|
+
useShortcut((event) => {
|
|
73
|
+
if (event.ctrl || event.meta || event.alt || event.targetEditable) return;
|
|
74
|
+
|
|
75
|
+
if (event.name === "h" || event.name === "left") {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
event.stopPropagation();
|
|
78
|
+
selectAdjacentTab(-1);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (event.name === "l" || event.name === "right") {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
selectAdjacentTab(1);
|
|
85
|
+
}
|
|
86
|
+
}, { enabled: focused && keyboardNavigation });
|
|
54
87
|
|
|
55
88
|
if (NativeTabs) {
|
|
56
89
|
return (
|
|
@@ -179,6 +179,15 @@ function DataTableVirtualizationHarness() {
|
|
|
179
179
|
name: `Row ${index}`,
|
|
180
180
|
}));
|
|
181
181
|
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
tableScrollBoxForTest = scrollRef.current;
|
|
184
|
+
return () => {
|
|
185
|
+
if (tableScrollBoxForTest === scrollRef.current) {
|
|
186
|
+
tableScrollBoxForTest = null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
182
191
|
return (
|
|
183
192
|
<DataTable
|
|
184
193
|
columns={[{ id: "name", label: "NAME", width: 12, align: "left" }]}
|
|
@@ -333,6 +342,46 @@ describe("shared UI kit", () => {
|
|
|
333
342
|
expect(frame).toContain("Insider");
|
|
334
343
|
});
|
|
335
344
|
|
|
345
|
+
test("moves focused tabs with arrow keys and leaves Tab for pane focus", async () => {
|
|
346
|
+
let lastSelected = "overview";
|
|
347
|
+
|
|
348
|
+
function KeyboardTabsHarness() {
|
|
349
|
+
const [activeValue, setActiveValue] = useState("overview");
|
|
350
|
+
return (
|
|
351
|
+
<Tabs
|
|
352
|
+
tabs={[
|
|
353
|
+
{ label: "Overview", value: "overview" },
|
|
354
|
+
{ label: "News", value: "news" },
|
|
355
|
+
{ label: "Chart", value: "chart" },
|
|
356
|
+
]}
|
|
357
|
+
activeValue={activeValue}
|
|
358
|
+
onSelect={(value) => {
|
|
359
|
+
lastSelected = value;
|
|
360
|
+
setActiveValue(value);
|
|
361
|
+
}}
|
|
362
|
+
focused
|
|
363
|
+
/>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
testSetup = await testRender(<KeyboardTabsHarness />, { width: 40, height: 4 });
|
|
368
|
+
|
|
369
|
+
await act(async () => {
|
|
370
|
+
await testSetup!.renderOnce();
|
|
371
|
+
testSetup!.mockInput.pressArrow("right");
|
|
372
|
+
await testSetup!.renderOnce();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(lastSelected).toBe("news");
|
|
376
|
+
|
|
377
|
+
await act(async () => {
|
|
378
|
+
testSetup!.mockInput.pressTab();
|
|
379
|
+
await testSetup!.renderOnce();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(lastSelected).toBe("news");
|
|
383
|
+
});
|
|
384
|
+
|
|
336
385
|
test("renders tab actions for editable tab sets", async () => {
|
|
337
386
|
testSetup = await testRender(
|
|
338
387
|
<Tabs
|
|
@@ -747,8 +796,9 @@ describe("shared UI kit", () => {
|
|
|
747
796
|
expect(tableScrollBoxForTest?.verticalScrollBar.visible).toBe(true);
|
|
748
797
|
});
|
|
749
798
|
|
|
750
|
-
test("virtualizes data table rows
|
|
799
|
+
test("virtualizes data table rows and refreshes after wheel scrolling", async () => {
|
|
751
800
|
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
801
|
+
tableScrollBoxForTest = null;
|
|
752
802
|
testSetup = await testRender(
|
|
753
803
|
<AppContext value={{ state, dispatch: () => {} }}>
|
|
754
804
|
<PaneInstanceProvider paneId="portfolio-list:main">
|
|
@@ -765,5 +815,22 @@ describe("shared UI kit", () => {
|
|
|
765
815
|
const frame = testSetup.captureCharFrame();
|
|
766
816
|
expect(frame).toContain("Row 0");
|
|
767
817
|
expect(frame).not.toContain("Row 99");
|
|
818
|
+
expect(tableScrollBoxForTest?.scrollTop).toBe(0);
|
|
819
|
+
|
|
820
|
+
await act(async () => {
|
|
821
|
+
for (let index = 0; index < 12; index++) {
|
|
822
|
+
await testSetup!.mockMouse.scroll(2, 2, "down");
|
|
823
|
+
}
|
|
824
|
+
await Promise.resolve();
|
|
825
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
826
|
+
await testSetup!.renderOnce();
|
|
827
|
+
});
|
|
828
|
+
await testSetup.renderOnce();
|
|
829
|
+
|
|
830
|
+
const scrollTop = tableScrollBoxForTest?.scrollTop ?? 0;
|
|
831
|
+
expect(scrollTop).toBeGreaterThan(0);
|
|
832
|
+
|
|
833
|
+
const scrolledFrame = testSetup.captureCharFrame();
|
|
834
|
+
expect(scrolledFrame).toContain(`Row ${scrollTop}`);
|
|
768
835
|
});
|
|
769
836
|
});
|
|
@@ -318,23 +318,13 @@ function getTopFloatingPaneId(layout: LayoutConfig): string | null {
|
|
|
318
318
|
return topInstanceId;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
function resolveFocusedPaneId(
|
|
322
|
-
previousLayout: LayoutConfig,
|
|
323
|
-
nextLayout: LayoutConfig,
|
|
324
|
-
focusedPaneId: string | null,
|
|
325
|
-
): string | null {
|
|
321
|
+
function resolveFocusedPaneId(nextLayout: LayoutConfig, focusedPaneId: string | null): string | null {
|
|
326
322
|
const paneOrder = getPaneOrder(nextLayout);
|
|
327
323
|
if (paneOrder.length === 0) return null;
|
|
328
324
|
if (focusedPaneId && paneOrder.includes(focusedPaneId)) {
|
|
329
325
|
return focusedPaneId;
|
|
330
326
|
}
|
|
331
|
-
|
|
332
|
-
? previousLayout.floating.some((entry) => entry.instanceId === focusedPaneId)
|
|
333
|
-
: false;
|
|
334
|
-
if (previouslyFloating) {
|
|
335
|
-
return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
|
|
336
|
-
}
|
|
337
|
-
return paneOrder[0] ?? null;
|
|
327
|
+
return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
|
|
338
328
|
}
|
|
339
329
|
|
|
340
330
|
function getPanelFocusTarget(layout: LayoutConfig, panel: "left" | "right"): string | null {
|
|
@@ -446,7 +436,7 @@ function withFocusedPane(state: AppState, config: AppConfig): AppState {
|
|
|
446
436
|
layouts: syncLayouts(config.layouts, config.activeLayoutIndex, normalizedLayout),
|
|
447
437
|
};
|
|
448
438
|
const nextPaneState = reconcilePaneState(nextConfig, state.paneState);
|
|
449
|
-
const focusedPaneId = resolveFocusedPaneId(
|
|
439
|
+
const focusedPaneId = resolveFocusedPaneId(nextConfig.layout, state.focusedPaneId);
|
|
450
440
|
const focusedConfig = bringFocusedFloatingPaneToFront(nextConfig, focusedPaneId);
|
|
451
441
|
return {
|
|
452
442
|
...state,
|
|
@@ -854,7 +844,7 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
854
844
|
|
|
855
845
|
export function createInitialState(config: AppConfig, sessionSnapshot: AppSessionSnapshot | null = null): AppState {
|
|
856
846
|
const paneState = reconcilePaneState(config, sessionSnapshot?.paneState ?? {});
|
|
857
|
-
const defaultFocusedPaneId = getPaneOrder(config.layout)[0] ?? null;
|
|
847
|
+
const defaultFocusedPaneId = getTopFloatingPaneId(config.layout) ?? getPaneOrder(config.layout)[0] ?? null;
|
|
858
848
|
const focusedPaneId = sessionSnapshot?.focusedPaneId
|
|
859
849
|
&& config.layout.instances.some((instance) => instance.instanceId === sessionSnapshot.focusedPaneId)
|
|
860
850
|
? sessionSnapshot.focusedPaneId
|
|
@@ -632,4 +632,79 @@ describe("MarketDataCoordinator", () => {
|
|
|
632
632
|
expect(financials?.quote?.marketCap).toBe(3_640_775_908_600);
|
|
633
633
|
expect(financials?.fundamentals?.trailingPE).toBe(31.4);
|
|
634
634
|
});
|
|
635
|
+
|
|
636
|
+
it("batch loads only missing quotes when fresh quote data is already stored", async () => {
|
|
637
|
+
const batchTargets: QuoteSubscriptionTarget[][] = [];
|
|
638
|
+
const provider = createProvider({
|
|
639
|
+
getQuote: async () => ({
|
|
640
|
+
symbol: "AAPL",
|
|
641
|
+
price: 100,
|
|
642
|
+
currency: "USD",
|
|
643
|
+
change: 0,
|
|
644
|
+
changePercent: 0,
|
|
645
|
+
lastUpdated: 1_700_000_000_000,
|
|
646
|
+
}),
|
|
647
|
+
getQuotesBatch: async (targets) => {
|
|
648
|
+
batchTargets.push(targets);
|
|
649
|
+
return targets.map((target) => ({
|
|
650
|
+
target,
|
|
651
|
+
quote: {
|
|
652
|
+
symbol: target.symbol,
|
|
653
|
+
price: target.symbol === "MSFT" ? 200 : 300,
|
|
654
|
+
currency: "USD",
|
|
655
|
+
change: 0,
|
|
656
|
+
changePercent: 0,
|
|
657
|
+
lastUpdated: 1_700_000_000_000,
|
|
658
|
+
},
|
|
659
|
+
}));
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
663
|
+
const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
664
|
+
const msft = { symbol: "MSFT", exchange: "NASDAQ" };
|
|
665
|
+
|
|
666
|
+
await coordinator.loadQuote(aapl);
|
|
667
|
+
await coordinator.loadQuotesBatch([aapl, msft]);
|
|
668
|
+
|
|
669
|
+
expect(batchTargets).toHaveLength(1);
|
|
670
|
+
expect(batchTargets[0]?.map((target) => target.symbol)).toEqual(["MSFT"]);
|
|
671
|
+
expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(100);
|
|
672
|
+
expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("batch loads snapshots through provider batch support", async () => {
|
|
676
|
+
const batchSymbols: string[][] = [];
|
|
677
|
+
const provider = createProvider({
|
|
678
|
+
getTickerFinancialsBatch: async (targets) => {
|
|
679
|
+
batchSymbols.push(targets.map((target) => target.symbol));
|
|
680
|
+
return targets.map((target) => ({
|
|
681
|
+
target,
|
|
682
|
+
financials: {
|
|
683
|
+
quote: {
|
|
684
|
+
symbol: target.symbol,
|
|
685
|
+
price: target.symbol === "AAPL" ? 150 : 250,
|
|
686
|
+
currency: "USD",
|
|
687
|
+
change: 0,
|
|
688
|
+
changePercent: 0,
|
|
689
|
+
lastUpdated: 1_700_000_000_000,
|
|
690
|
+
},
|
|
691
|
+
fundamentals: { trailingPE: target.symbol === "AAPL" ? 20 : 30 },
|
|
692
|
+
annualStatements: [],
|
|
693
|
+
quarterlyStatements: [],
|
|
694
|
+
priceHistory: [],
|
|
695
|
+
},
|
|
696
|
+
}));
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
700
|
+
|
|
701
|
+
await coordinator.loadSnapshotsBatch([
|
|
702
|
+
{ symbol: "AAPL", exchange: "NASDAQ" },
|
|
703
|
+
{ symbol: "MSFT", exchange: "NASDAQ" },
|
|
704
|
+
]);
|
|
705
|
+
|
|
706
|
+
expect(batchSymbols).toEqual([["AAPL", "MSFT"]]);
|
|
707
|
+
expect(coordinator.getTickerFinancialsSync({ symbol: "AAPL", exchange: "NASDAQ" })?.quote?.price).toBe(150);
|
|
708
|
+
expect(coordinator.getTickerFinancialsSync({ symbol: "MSFT", exchange: "NASDAQ" })?.fundamentals?.trailingPE).toBe(30);
|
|
709
|
+
});
|
|
635
710
|
});
|