gloomberb 0.3.7 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +45 -69
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +363 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +4 -2
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +67 -52
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +181 -97
- package/src/components/ui/data-table.tsx +50 -5
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +400 -3
- package/src/plugins/builtin/chat-controller.ts +328 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +180 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +67 -1
- package/src/plugins/ibkr/gateway-service.ts +147 -7
- package/src/plugins/ibkr/index.tsx +7 -1
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +132 -17
- package/src/plugins/prediction-markets/controller.ts +142 -122
- package/src/plugins/prediction-markets/index.tsx +7 -1
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +197 -57
- package/src/plugins/prediction-markets/pane.tsx +80 -155
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/plugins/registry.ts +23 -0
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +47 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -31,7 +31,7 @@ function UpdateStatus() {
|
|
|
31
31
|
<box flexDirection="row" gap={1}>
|
|
32
32
|
<spinner name="dots" color={colors.headerText} />
|
|
33
33
|
<text fg={colors.headerText}>
|
|
34
|
-
Downloading v{updateAvailable?.version}
|
|
34
|
+
Downloading v{updateAvailable?.version}: {updateProgress.percent ?? 0}%
|
|
35
35
|
</text>
|
|
36
36
|
</box>
|
|
37
37
|
);
|
|
@@ -45,7 +45,7 @@ function UpdateStatus() {
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
if (updateProgress.phase === "done") {
|
|
48
|
-
return <text fg={colors.headerText}>Update installed
|
|
48
|
+
return <text fg={colors.headerText}>Update installed, restart to apply</text>;
|
|
49
49
|
}
|
|
50
50
|
if (updateProgress.phase === "error") {
|
|
51
51
|
return <text fg={colors.headerText}>Update failed: {updateProgress.error}</text>;
|
|
@@ -71,7 +71,7 @@ function UpdateStatus() {
|
|
|
71
71
|
}
|
|
72
72
|
return (
|
|
73
73
|
<text fg={colors.headerText}>
|
|
74
|
-
v{updateAvailable.version} available —
|
|
74
|
+
v{updateAvailable.version} available — starting download...
|
|
75
75
|
</text>
|
|
76
76
|
);
|
|
77
77
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
|
|
1
|
+
import { colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
|
|
2
2
|
|
|
3
3
|
export const PANE_HEADER_HEIGHT = 1;
|
|
4
4
|
export const PANE_HEADER_GRIP = ":: ";
|
|
@@ -30,13 +30,37 @@ export function PaneHeader({
|
|
|
30
30
|
const backgroundColor = floating ? floatingPaneTitleBg(focused) : paneTitleBg(focused);
|
|
31
31
|
const actionText = showActions ? PANE_HEADER_ACTION : " ";
|
|
32
32
|
const closeText = floating ? PANE_HEADER_CLOSE : "";
|
|
33
|
+
const bc = colors.borderFocused;
|
|
34
|
+
const textColor = paneTitleText(focused, floating);
|
|
35
|
+
|
|
36
|
+
if (focused) {
|
|
37
|
+
// Build: ┌─:: Title ─────────── ... x─┐
|
|
38
|
+
// Reserve 2 for corners, 1 for ─ after ┌, 1 for ─ before ┐
|
|
39
|
+
const innerWidth = Math.max(0, width - 4);
|
|
40
|
+
const contentWidth = PANE_HEADER_GRIP.length + closeText.length + actionText.length;
|
|
41
|
+
const titleWidth = Math.max(0, innerWidth - contentWidth);
|
|
42
|
+
const clippedTitle = truncateTitle(title, titleWidth);
|
|
43
|
+
const fillLen = Math.max(0, innerWidth - PANE_HEADER_GRIP.length - clippedTitle.length - actionText.length - closeText.length);
|
|
44
|
+
const fill = "─".repeat(fillLen);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
|
|
48
|
+
<text fg={bc} selectable={false}>{"┌─"}</text>
|
|
49
|
+
<text fg={textColor} selectable={false}>{`${PANE_HEADER_GRIP}${clippedTitle}`}</text>
|
|
50
|
+
<text fg={bc} selectable={false}>{fill}</text>
|
|
51
|
+
<text fg={textColor} selectable={false}>{`${actionText}${closeText}`}</text>
|
|
52
|
+
<text fg={bc} selectable={false}>{"─┐"}</text>
|
|
53
|
+
</box>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
const titleWidth = Math.max(0, width - PANE_HEADER_GRIP.length - actionText.length - closeText.length);
|
|
34
58
|
const clippedTitle = truncateTitle(title, titleWidth);
|
|
35
59
|
const padding = " ".repeat(Math.max(0, titleWidth - clippedTitle.length));
|
|
36
60
|
|
|
37
61
|
return (
|
|
38
62
|
<box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
|
|
39
|
-
<text fg={
|
|
63
|
+
<text fg={textColor} selectable={false}>
|
|
40
64
|
{`${PANE_HEADER_GRIP}${clippedTitle}${padding}${actionText}${closeText}`}
|
|
41
65
|
</text>
|
|
42
66
|
</box>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { paneBg } from "../../theme/colors";
|
|
3
3
|
import { PaneHeader } from "./pane-header";
|
|
4
|
+
import { getPaneBodyHeight } from "./pane-sizing";
|
|
4
5
|
|
|
5
6
|
interface PaneWrapperProps {
|
|
6
7
|
title?: string;
|
|
@@ -26,6 +27,9 @@ export function PaneWrapper({
|
|
|
26
27
|
children,
|
|
27
28
|
}: PaneWrapperProps) {
|
|
28
29
|
const bg = paneBg(focused);
|
|
30
|
+
const bodyHeight = typeof height === "number"
|
|
31
|
+
? title ? getPaneBodyHeight(height) : height
|
|
32
|
+
: undefined;
|
|
29
33
|
|
|
30
34
|
return (
|
|
31
35
|
<box
|
|
@@ -41,7 +45,7 @@ export function PaneWrapper({
|
|
|
41
45
|
{title && (
|
|
42
46
|
<PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
|
|
43
47
|
)}
|
|
44
|
-
<box flexGrow={1} overflow="hidden">
|
|
48
|
+
<box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
|
|
45
49
|
{children}
|
|
46
50
|
</box>
|
|
47
51
|
</box>
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { testRender } from "@opentui/react/test-utils";
|
|
3
3
|
import { DialogProvider } from "@opentui-ui/dialog/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
4
5
|
import { act, useReducer } from "react";
|
|
5
6
|
import { AppContext, appReducer, createInitialState } from "../../state/app-context";
|
|
6
7
|
import { cloneLayout, createDefaultConfig } from "../../types/config";
|
|
7
8
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
8
9
|
import { setSharedRegistryForTests } from "../../plugins/registry";
|
|
9
10
|
import { portfolioListPlugin } from "../../plugins/builtin/portfolio-list";
|
|
11
|
+
import type { PaneProps } from "../../types/plugin";
|
|
10
12
|
import { StatusBar } from "./status-bar";
|
|
11
13
|
import { Header } from "./header";
|
|
12
14
|
import { buildNativeWindowState, finalizePaneDragRelease, resolveNativeDockDividers, Shell } from "./shell";
|
|
@@ -26,7 +28,9 @@ afterEach(() => {
|
|
|
26
28
|
setSharedRegistryForTests(undefined);
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
function createShellPluginRegistry(
|
|
31
|
+
function createShellPluginRegistry(options?: {
|
|
32
|
+
tickerDetailComponent?: (props: PaneProps) => ReactNode;
|
|
33
|
+
}): PluginRegistry {
|
|
30
34
|
return {
|
|
31
35
|
panes: new Map([
|
|
32
36
|
["portfolio-list", {
|
|
@@ -38,7 +42,7 @@ function createShellPluginRegistry(): PluginRegistry {
|
|
|
38
42
|
["ticker-detail", {
|
|
39
43
|
id: "ticker-detail",
|
|
40
44
|
name: "Ticker Detail",
|
|
41
|
-
component: () => <text>Ticker Detail Body</text
|
|
45
|
+
component: options?.tickerDetailComponent ?? (() => <text>Ticker Detail Body</text>),
|
|
42
46
|
defaultPosition: "right",
|
|
43
47
|
defaultMode: "floating",
|
|
44
48
|
}],
|
|
@@ -160,15 +164,18 @@ function createHeaderDataProvider(): DataProvider {
|
|
|
160
164
|
|
|
161
165
|
function HeaderHarness({
|
|
162
166
|
updateAvailable = null,
|
|
167
|
+
updateProgress = null,
|
|
163
168
|
updateCheckInProgress = false,
|
|
164
169
|
updateNotice = null,
|
|
165
170
|
}: {
|
|
166
171
|
updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
|
|
172
|
+
updateProgress?: ReturnType<typeof createInitialState>["updateProgress"];
|
|
167
173
|
updateCheckInProgress?: boolean;
|
|
168
174
|
updateNotice?: string | null;
|
|
169
175
|
}) {
|
|
170
176
|
const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
|
|
171
177
|
initialState.updateAvailable = updateAvailable;
|
|
178
|
+
initialState.updateProgress = updateProgress;
|
|
172
179
|
initialState.updateCheckInProgress = updateCheckInProgress;
|
|
173
180
|
initialState.updateNotice = updateNotice;
|
|
174
181
|
const [state, dispatch] = useReducer(appReducer, initialState);
|
|
@@ -258,7 +265,7 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
|
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
describe("Header", () => {
|
|
261
|
-
test("shows
|
|
268
|
+
test("shows automatic self-update status for standalone binaries", async () => {
|
|
262
269
|
testSetup = await testRender(
|
|
263
270
|
<HeaderHarness updateAvailable={{
|
|
264
271
|
version: "0.3.0",
|
|
@@ -274,7 +281,8 @@ describe("Header", () => {
|
|
|
274
281
|
const frame = testSetup.captureCharFrame();
|
|
275
282
|
|
|
276
283
|
expect(frame).toContain("v0.3.0 available");
|
|
277
|
-
expect(frame).toContain("
|
|
284
|
+
expect(frame).toContain("starting download");
|
|
285
|
+
expect(frame).not.toContain("press u to update");
|
|
278
286
|
});
|
|
279
287
|
|
|
280
288
|
test("shows the manual npm command when self-update is disabled", async () => {
|
|
@@ -297,7 +305,26 @@ describe("Header", () => {
|
|
|
297
305
|
expect(frame).not.toContain("press u to update");
|
|
298
306
|
});
|
|
299
307
|
|
|
300
|
-
test("shows update
|
|
308
|
+
test("shows update download progress and notices", async () => {
|
|
309
|
+
testSetup = await testRender(
|
|
310
|
+
<HeaderHarness
|
|
311
|
+
updateAvailable={{
|
|
312
|
+
version: "0.3.0",
|
|
313
|
+
tagName: "v0.3.0",
|
|
314
|
+
downloadUrl: "https://example.com/gloomberb",
|
|
315
|
+
publishedAt: "2026-04-01T00:00:00.000Z",
|
|
316
|
+
updateAction: { kind: "self" },
|
|
317
|
+
}}
|
|
318
|
+
updateProgress={{ phase: "downloading", percent: 42 }}
|
|
319
|
+
/>,
|
|
320
|
+
{ width: 120, height: 2 },
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await testSetup.renderOnce();
|
|
324
|
+
expect(testSetup.captureCharFrame()).toContain("Downloading v0.3.0: 42%");
|
|
325
|
+
|
|
326
|
+
testSetup.renderer.destroy();
|
|
327
|
+
|
|
301
328
|
testSetup = await testRender(
|
|
302
329
|
<HeaderHarness updateCheckInProgress />,
|
|
303
330
|
{ width: 120, height: 2 },
|
|
@@ -648,4 +675,49 @@ describe("Shell", () => {
|
|
|
648
675
|
expect(updateLayout?.layout.floating).toEqual([]);
|
|
649
676
|
expect(updateLayout?.layout.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
|
|
650
677
|
});
|
|
678
|
+
|
|
679
|
+
test("keeps the last floating pane body row visible above the border", async () => {
|
|
680
|
+
const config = createDefaultConfig("/tmp/gloomberb-shell-footer-test");
|
|
681
|
+
const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
|
|
682
|
+
if (!detailPane) throw new Error("missing detail pane");
|
|
683
|
+
|
|
684
|
+
const floatingOnlyLayout = {
|
|
685
|
+
dockRoot: null,
|
|
686
|
+
instances: [{ ...detailPane }],
|
|
687
|
+
floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
|
|
688
|
+
};
|
|
689
|
+
const state = {
|
|
690
|
+
...createInitialState({
|
|
691
|
+
...config,
|
|
692
|
+
layout: cloneLayout(floatingOnlyLayout),
|
|
693
|
+
layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
|
|
694
|
+
}),
|
|
695
|
+
focusedPaneId: "ticker-detail:main",
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
testSetup = await testRender(
|
|
699
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
700
|
+
<DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
|
|
701
|
+
<Shell pluginRegistry={createShellPluginRegistry({
|
|
702
|
+
tickerDetailComponent: ({ width, height }) => (
|
|
703
|
+
<box flexDirection="column" width={width} height={height}>
|
|
704
|
+
<box flexGrow={1} />
|
|
705
|
+
<box paddingLeft={1}>
|
|
706
|
+
<text>Footer Probe</text>
|
|
707
|
+
</box>
|
|
708
|
+
</box>
|
|
709
|
+
),
|
|
710
|
+
})} />
|
|
711
|
+
</DialogProvider>
|
|
712
|
+
</AppContext>,
|
|
713
|
+
{ width: 40, height: 12 },
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
await act(async () => {
|
|
717
|
+
await testSetup!.renderOnce();
|
|
718
|
+
await testSetup!.renderOnce();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
expect(testSetup.captureCharFrame()).toContain("Footer Probe");
|
|
722
|
+
});
|
|
651
723
|
});
|
|
@@ -5,10 +5,7 @@ import { saveConfig } from "../../data/config-store";
|
|
|
5
5
|
import {
|
|
6
6
|
MIN_FLOAT_HEIGHT,
|
|
7
7
|
MIN_FLOAT_WIDTH,
|
|
8
|
-
addPaneFloating,
|
|
9
|
-
addPaneToLayout,
|
|
10
8
|
applyDrop,
|
|
11
|
-
bringToFront,
|
|
12
9
|
findDockLeaf,
|
|
13
10
|
floatAtRect,
|
|
14
11
|
floatPane,
|
|
@@ -29,7 +26,7 @@ import {
|
|
|
29
26
|
type ResolvedPane,
|
|
30
27
|
} from "../../plugins/pane-manager";
|
|
31
28
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
32
|
-
import { removePaneInstances,
|
|
29
|
+
import { removePaneInstances, type LayoutConfig } from "../../types/config";
|
|
33
30
|
import {
|
|
34
31
|
PaneInstanceProvider,
|
|
35
32
|
resolveCollectionForPane,
|
|
@@ -41,6 +38,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
|
|
|
41
38
|
import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
|
|
42
39
|
import { FloatingPaneWrapper } from "./floating-pane";
|
|
43
40
|
import { PaneWrapper } from "./pane";
|
|
41
|
+
import { getPaneBodyHeight } from "./pane-sizing";
|
|
44
42
|
|
|
45
43
|
interface ShellProps {
|
|
46
44
|
pluginRegistry: PluginRegistry;
|
|
@@ -435,12 +433,13 @@ function resolveSnapGuide(x: number, y: number, guides: SnapGuide[]): SnapGuide
|
|
|
435
433
|
|
|
436
434
|
function resolveHeaderHitAreas(
|
|
437
435
|
width: number,
|
|
438
|
-
options: { floating: boolean },
|
|
436
|
+
options: { floating: boolean; focused: boolean },
|
|
439
437
|
): {
|
|
440
438
|
actionStart: number | null;
|
|
441
439
|
closeStart: number | null;
|
|
442
440
|
} {
|
|
443
|
-
|
|
441
|
+
// When focused, the header renders ┌─...─┐ adding 2 chars on each side
|
|
442
|
+
let rightEdge = options.focused ? width - 2 : width;
|
|
444
443
|
let closeStart: number | null = null;
|
|
445
444
|
let actionStart: number | null = null;
|
|
446
445
|
|
|
@@ -463,7 +462,6 @@ function menuForPane(
|
|
|
463
462
|
persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
|
|
464
463
|
focusPane: (paneId: string) => void,
|
|
465
464
|
openPaneSettings: (paneId: string) => void,
|
|
466
|
-
openLayoutMenu: () => void,
|
|
467
465
|
) {
|
|
468
466
|
const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
|
|
469
467
|
if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
|
|
@@ -483,11 +481,6 @@ function menuForPane(
|
|
|
483
481
|
focusPane(pane.instance.instanceId);
|
|
484
482
|
},
|
|
485
483
|
});
|
|
486
|
-
baseActions.push({
|
|
487
|
-
id: "front",
|
|
488
|
-
label: "Bring To Front",
|
|
489
|
-
action: () => persistLayout(bringToFront(layout, pane.instance.instanceId), { pushHistory: false }),
|
|
490
|
-
});
|
|
491
484
|
} else {
|
|
492
485
|
baseActions.push({
|
|
493
486
|
id: "float",
|
|
@@ -499,37 +492,6 @@ function menuForPane(
|
|
|
499
492
|
});
|
|
500
493
|
}
|
|
501
494
|
|
|
502
|
-
baseActions.push({
|
|
503
|
-
id: "duplicate",
|
|
504
|
-
label: "Duplicate",
|
|
505
|
-
action: () => {
|
|
506
|
-
const duplicate = createPaneInstance(pane.instance.paneId, {
|
|
507
|
-
title: pane.instance.title,
|
|
508
|
-
binding: pane.instance.binding,
|
|
509
|
-
params: pane.instance.params,
|
|
510
|
-
settings: pane.instance.settings,
|
|
511
|
-
});
|
|
512
|
-
const nextLayout = pane.floating
|
|
513
|
-
? floatAtRect(addPaneFloating(layout, duplicate, width, contentHeight, pane.def), duplicate.instanceId, {
|
|
514
|
-
x: Math.max(0, pane.floating.x + 2),
|
|
515
|
-
y: Math.max(0, pane.floating.y + 1),
|
|
516
|
-
width: pane.floating.width,
|
|
517
|
-
height: pane.floating.height,
|
|
518
|
-
})
|
|
519
|
-
: addPaneToLayout(layout, duplicate, { relativeTo: pane.instance.instanceId, position: "right" });
|
|
520
|
-
persistLayout(nextLayout);
|
|
521
|
-
focusPane(duplicate.instanceId);
|
|
522
|
-
},
|
|
523
|
-
});
|
|
524
|
-
baseActions.push({ id: "swap", label: "Swap With...", action: openLayoutMenu });
|
|
525
|
-
baseActions.push({
|
|
526
|
-
id: "close",
|
|
527
|
-
label: "Close",
|
|
528
|
-
action: () => {
|
|
529
|
-
persistLayout(removePane(layout, pane.instance.instanceId));
|
|
530
|
-
},
|
|
531
|
-
});
|
|
532
|
-
|
|
533
495
|
return baseActions;
|
|
534
496
|
}
|
|
535
497
|
|
|
@@ -739,10 +701,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
739
701
|
persistLayout,
|
|
740
702
|
focusPane,
|
|
741
703
|
openPaneSettings,
|
|
742
|
-
openLayoutMenu,
|
|
743
704
|
),
|
|
744
705
|
});
|
|
745
|
-
}, [contentHeight, focusPane,
|
|
706
|
+
}, [contentHeight, focusPane, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
|
|
746
707
|
|
|
747
708
|
const handleFloatingClose = useCallback((paneId: string) => {
|
|
748
709
|
persistLayout(removePane(visibleLayout, paneId));
|
|
@@ -768,13 +729,12 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
768
729
|
if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
|
|
769
730
|
const relativeX = event.x - rect.x;
|
|
770
731
|
const relativeY = shellY - rect.y;
|
|
732
|
+
const isFocused = state.focusedPaneId === pane.instance.instanceId;
|
|
771
733
|
const headerAreas = resolveHeaderHitAreas(rect.width, {
|
|
772
734
|
floating: true,
|
|
735
|
+
focused: isFocused,
|
|
773
736
|
});
|
|
774
737
|
focusPane(pane.instance.instanceId);
|
|
775
|
-
if ((pane.floating?.zIndex ?? 50) < 999) {
|
|
776
|
-
persistLayout(bringToFront(visibleLayout, pane.instance.instanceId), { pushHistory: false });
|
|
777
|
-
}
|
|
778
738
|
if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
|
|
779
739
|
handleFloatingClose(pane.instance.instanceId);
|
|
780
740
|
event.stopPropagation();
|
|
@@ -850,8 +810,10 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
850
810
|
if (!pane) continue;
|
|
851
811
|
const relativeX = event.x - leaf.rect.x;
|
|
852
812
|
const relativeY = shellY - leaf.rect.y;
|
|
813
|
+
const isFocused = state.focusedPaneId === leaf.instanceId;
|
|
853
814
|
const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
|
|
854
815
|
floating: false,
|
|
816
|
+
focused: isFocused,
|
|
855
817
|
});
|
|
856
818
|
focusPane(leaf.instanceId);
|
|
857
819
|
if (relativeY === 0
|
|
@@ -1021,8 +983,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1021
983
|
{dockLeafLayouts.map((leaf) => {
|
|
1022
984
|
const pane = paneMap.get(leaf.instanceId);
|
|
1023
985
|
if (!pane) return null;
|
|
1024
|
-
const focused = state.focusedPaneId === leaf.instanceId && !overlayOpen;
|
|
986
|
+
const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
|
|
1025
987
|
const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
|
|
988
|
+
const bodyHeight = getPaneBodyHeight(leaf.rect.height);
|
|
1026
989
|
return (
|
|
1027
990
|
<box
|
|
1028
991
|
key={`dock:${leaf.instanceId}`}
|
|
@@ -1046,7 +1009,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1046
1009
|
paneType={pane.instance.paneId}
|
|
1047
1010
|
focused={focused}
|
|
1048
1011
|
width={leaf.rect.width}
|
|
1049
|
-
height={
|
|
1012
|
+
height={bodyHeight}
|
|
1050
1013
|
/>
|
|
1051
1014
|
</PaneInstanceProvider>
|
|
1052
1015
|
</PaneWrapper>
|
|
@@ -1056,8 +1019,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1056
1019
|
|
|
1057
1020
|
{floatingPanes.map((pane) => {
|
|
1058
1021
|
const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
|
|
1059
|
-
const focused = state.focusedPaneId === pane.instance.instanceId && !overlayOpen;
|
|
1022
|
+
const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
|
|
1060
1023
|
const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
|
|
1024
|
+
const bodyHeight = getPaneBodyHeight(preview.height);
|
|
1061
1025
|
return (
|
|
1062
1026
|
<FloatingPaneWrapper
|
|
1063
1027
|
key={`float:${pane.instance.instanceId}`}
|
|
@@ -1077,7 +1041,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1077
1041
|
paneType={pane.instance.paneId}
|
|
1078
1042
|
focused={focused}
|
|
1079
1043
|
width={preview.width}
|
|
1080
|
-
height={
|
|
1044
|
+
height={bodyHeight}
|
|
1081
1045
|
close={() => handleFloatingClose(pane.instance.instanceId)}
|
|
1082
1046
|
/>
|
|
1083
1047
|
</PaneInstanceProvider>
|
|
@@ -1108,6 +1072,57 @@ export function Shell({ pluginRegistry }: ShellProps) {
|
|
|
1108
1072
|
);
|
|
1109
1073
|
})}
|
|
1110
1074
|
|
|
1075
|
+
{/* Focus border overlay — rendered on top of the focused pane */}
|
|
1076
|
+
{(() => {
|
|
1077
|
+
if (!state.focusedPaneId) return null;
|
|
1078
|
+
// Hide border when command bar or dialog is open, but keep it when just the pane menu is open
|
|
1079
|
+
if (overlayOpen && !menuState) return null;
|
|
1080
|
+
let rect: { x: number; y: number; width: number; height: number } | null = null;
|
|
1081
|
+
let z = 3;
|
|
1082
|
+
// Check floating panes first (they render on top of docked panes)
|
|
1083
|
+
const floatingPane = floatingPanes.find((p) => p.instance.instanceId === state.focusedPaneId);
|
|
1084
|
+
if (floatingPane) {
|
|
1085
|
+
rect = dragFloatingRect?.paneId === floatingPane.instance.instanceId
|
|
1086
|
+
? dragFloatingRect.rect
|
|
1087
|
+
: floatingPane.floating!;
|
|
1088
|
+
z = (floatingPane.floating?.zIndex ?? 50) + 1;
|
|
1089
|
+
} else {
|
|
1090
|
+
// Check docked panes
|
|
1091
|
+
const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === state.focusedPaneId);
|
|
1092
|
+
if (dockedLeaf) {
|
|
1093
|
+
rect = dockedLeaf.rect;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (!rect || rect.height < 2) return null;
|
|
1097
|
+
const bc = colors.borderFocused;
|
|
1098
|
+
const isFloating = !!floatingPane;
|
|
1099
|
+
const bodyTop = rect.y + 1; // below header (header renders its own border edges)
|
|
1100
|
+
const bodyH = rect.height - 2; // rows between header and bottom edge
|
|
1101
|
+
return (
|
|
1102
|
+
<>
|
|
1103
|
+
{/* Left edge — body only */}
|
|
1104
|
+
{bodyH > 0 && (
|
|
1105
|
+
<box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
|
|
1106
|
+
<text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
|
|
1107
|
+
</box>
|
|
1108
|
+
)}
|
|
1109
|
+
{/* Right edge — body only */}
|
|
1110
|
+
{bodyH > 0 && (
|
|
1111
|
+
<box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
|
|
1112
|
+
<text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
|
|
1113
|
+
</box>
|
|
1114
|
+
)}
|
|
1115
|
+
{/* Bottom edge — for floating, leave last 2 chars for resize handle (rendered by FloatingPaneWrapper) */}
|
|
1116
|
+
<box key="focus-b" position="absolute" left={rect.x} top={rect.y + rect.height - 1} width={isFloating ? Math.max(0, rect.width - 2) : rect.width} height={1} zIndex={z}>
|
|
1117
|
+
<text fg={bc} selectable={false}>{isFloating
|
|
1118
|
+
? `└${"─".repeat(Math.max(0, rect.width - 4))}─`
|
|
1119
|
+
: `└${"─".repeat(Math.max(0, rect.width - 2))}┘`
|
|
1120
|
+
}</text>
|
|
1121
|
+
</box>
|
|
1122
|
+
</>
|
|
1123
|
+
);
|
|
1124
|
+
})()}
|
|
1125
|
+
|
|
1111
1126
|
{activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
|
|
1112
1127
|
const active = dockPreview?.kind === "dock"
|
|
1113
1128
|
&& dockPreview.target.kind === "leaf"
|
|
@@ -170,4 +170,70 @@ describe("StatusBar", () => {
|
|
|
170
170
|
globalThis.clearTimeout = originalClearTimeout;
|
|
171
171
|
}
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
test("separates listing venue, session, and source labels", async () => {
|
|
175
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
176
|
+
config.layout.instances = config.layout.instances.map((instance) => (
|
|
177
|
+
instance.instanceId === "ticker-detail:main"
|
|
178
|
+
? { ...instance, binding: { kind: "fixed" as const, symbol: "AMD" } }
|
|
179
|
+
: instance
|
|
180
|
+
));
|
|
181
|
+
const state = createInitialState(config);
|
|
182
|
+
state.statusBarVisible = true;
|
|
183
|
+
state.focusedPaneId = "ticker-detail:main";
|
|
184
|
+
state.tickers = new Map([["AMD", {
|
|
185
|
+
metadata: {
|
|
186
|
+
ticker: "AMD",
|
|
187
|
+
exchange: "NASDAQ",
|
|
188
|
+
currency: "USD",
|
|
189
|
+
name: "Advanced Micro Devices",
|
|
190
|
+
portfolios: [],
|
|
191
|
+
watchlists: [],
|
|
192
|
+
positions: [],
|
|
193
|
+
custom: {},
|
|
194
|
+
tags: [],
|
|
195
|
+
},
|
|
196
|
+
}]]);
|
|
197
|
+
state.financials = new Map([["AMD", {
|
|
198
|
+
annualStatements: [],
|
|
199
|
+
quarterlyStatements: [],
|
|
200
|
+
priceHistory: [],
|
|
201
|
+
quote: {
|
|
202
|
+
symbol: "AMD",
|
|
203
|
+
providerId: "ibkr",
|
|
204
|
+
dataSource: "live",
|
|
205
|
+
price: 100,
|
|
206
|
+
currency: "USD",
|
|
207
|
+
change: 1,
|
|
208
|
+
changePercent: 1,
|
|
209
|
+
lastUpdated: Date.now(),
|
|
210
|
+
listingExchangeName: "NASDAQ",
|
|
211
|
+
routingExchangeName: "SMART",
|
|
212
|
+
marketState: "PRE",
|
|
213
|
+
sessionConfidence: "derived",
|
|
214
|
+
preMarketPrice: 101,
|
|
215
|
+
preMarketChange: 2,
|
|
216
|
+
preMarketChangePercent: 2,
|
|
217
|
+
provenance: {
|
|
218
|
+
price: { providerId: "ibkr", dataSource: "live" },
|
|
219
|
+
session: { providerId: "yahoo", dataSource: "yahoo" },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
}]]);
|
|
223
|
+
|
|
224
|
+
testSetup = await testRender(
|
|
225
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
226
|
+
<StatusBar />
|
|
227
|
+
</AppContext>,
|
|
228
|
+
{ width: 120, height: 1 },
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await testSetup.renderOnce();
|
|
232
|
+
|
|
233
|
+
const frame = testSetup.captureCharFrame();
|
|
234
|
+
expect(frame).toContain("NASDAQ PRE-MKT");
|
|
235
|
+
expect(frame).toContain("px IBKR live");
|
|
236
|
+
expect(frame).toContain("ses Yahoo");
|
|
237
|
+
expect(frame).not.toContain("SMART OPEN");
|
|
238
|
+
});
|
|
173
239
|
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { colors, hoverBg } from "../../theme/colors";
|
|
3
3
|
import { useAppState, useFocusedTicker } from "../../state/app-context";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
marketStateLabel,
|
|
6
|
+
marketStateColor,
|
|
7
|
+
exchangeShortName,
|
|
8
|
+
getExtendedHoursInfo,
|
|
9
|
+
quoteSourceLabel,
|
|
10
|
+
} from "../../utils/market-status";
|
|
5
11
|
import { getSharedRegistry } from "../../plugins/registry";
|
|
6
12
|
import { gridlockAllPanes } from "../../plugins/pane-manager";
|
|
7
13
|
|
|
@@ -23,7 +29,12 @@ export function StatusBar() {
|
|
|
23
29
|
|
|
24
30
|
const q = focusedFinancials?.quote;
|
|
25
31
|
const mktState = q?.marketState;
|
|
26
|
-
const exchName = q
|
|
32
|
+
const exchName = q
|
|
33
|
+
? exchangeShortName(q.listingExchangeName ?? q.exchangeName, q.listingExchangeFullName ?? q.fullExchangeName)
|
|
34
|
+
: "";
|
|
35
|
+
const sessionLabel = mktState ? marketStateLabel(mktState) : "?";
|
|
36
|
+
const priceSourceLabel = q?.provenance?.price ? quoteSourceLabel(q.provenance.price, "price") : "";
|
|
37
|
+
const sessionSourceLabel = q?.provenance?.session ? quoteSourceLabel(q.provenance.session, "session") : "";
|
|
27
38
|
|
|
28
39
|
// Extended hours info for selected ticker
|
|
29
40
|
const extInfo = getExtendedHoursInfo(focusedFinancials?.quote);
|
|
@@ -124,13 +135,23 @@ export function StatusBar() {
|
|
|
124
135
|
<text fg={extColor}>{symbol} {extText}</text>
|
|
125
136
|
</box>
|
|
126
137
|
)}
|
|
127
|
-
{mktState && (
|
|
138
|
+
{(exchName || mktState || q?.provenance?.session) && (
|
|
128
139
|
<box paddingRight={1}>
|
|
129
|
-
<text fg={marketStateColor(mktState)}>
|
|
130
|
-
{exchName ? `${exchName} ` : ""}{
|
|
140
|
+
<text fg={mktState ? marketStateColor(mktState) : colors.textDim}>
|
|
141
|
+
{exchName ? `${exchName} ` : ""}{sessionLabel}
|
|
131
142
|
</text>
|
|
132
143
|
</box>
|
|
133
144
|
)}
|
|
145
|
+
{priceSourceLabel && (
|
|
146
|
+
<box paddingRight={1}>
|
|
147
|
+
<text fg={colors.textDim}>px {priceSourceLabel}</text>
|
|
148
|
+
</box>
|
|
149
|
+
)}
|
|
150
|
+
{sessionSourceLabel && sessionSourceLabel !== priceSourceLabel && (
|
|
151
|
+
<box paddingRight={1}>
|
|
152
|
+
<text fg={colors.textDim}>ses {sessionSourceLabel}</text>
|
|
153
|
+
</box>
|
|
154
|
+
)}
|
|
134
155
|
{/* Plugin status widgets */}
|
|
135
156
|
{registry && <registry.Slot name="status:widget" />}
|
|
136
157
|
</box>
|