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
|
@@ -65,6 +65,20 @@ async function emitKeypress(
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
async function waitForFrameToContain(text: string, attempts = 8): Promise<string> {
|
|
69
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
70
|
+
const frame = testSetup!.captureCharFrame();
|
|
71
|
+
if (frame.includes(text)) {
|
|
72
|
+
return frame;
|
|
73
|
+
}
|
|
74
|
+
await act(async () => {
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
await testSetup!.renderOnce();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Timed out waiting for "${text}".`);
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
afterEach(async () => {
|
|
69
83
|
if (testSetup) {
|
|
70
84
|
await act(async () => {
|
|
@@ -79,6 +93,57 @@ afterEach(async () => {
|
|
|
79
93
|
});
|
|
80
94
|
|
|
81
95
|
describe("OnboardingWizard", () => {
|
|
96
|
+
test("uses backspace to go back when not editing and keeps backspace for text deletion while editing", async () => {
|
|
97
|
+
const pluginRegistry = {
|
|
98
|
+
allPlugins: new Map(),
|
|
99
|
+
brokers: new Map(),
|
|
100
|
+
paneTemplates: new Map(),
|
|
101
|
+
getPaneTemplatePluginId: () => undefined,
|
|
102
|
+
tickerRepository: createTickerRepository(),
|
|
103
|
+
persistence: { resources: undefined },
|
|
104
|
+
} as unknown as PluginRegistry;
|
|
105
|
+
|
|
106
|
+
testSetup = await testRender(
|
|
107
|
+
<OnboardingWizard
|
|
108
|
+
config={createDefaultConfig("/tmp/gloomberb-onboarding-backspace")}
|
|
109
|
+
pluginRegistry={pluginRegistry}
|
|
110
|
+
onComplete={() => {}}
|
|
111
|
+
/>,
|
|
112
|
+
{ width: 90, height: 28 },
|
|
113
|
+
);
|
|
114
|
+
await testSetup.renderOnce();
|
|
115
|
+
|
|
116
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
117
|
+
let frame = await waitForFrameToContain("Theme");
|
|
118
|
+
expect(frame).toContain("Theme");
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
testSetup!.mockInput.pressBackspace();
|
|
122
|
+
await testSetup!.renderOnce();
|
|
123
|
+
await testSetup!.renderOnce();
|
|
124
|
+
});
|
|
125
|
+
frame = await waitForFrameToContain("Let's set things up (~30s).");
|
|
126
|
+
expect(frame).toContain("Let's set things up (~30s).");
|
|
127
|
+
|
|
128
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
129
|
+
frame = await waitForFrameToContain("Theme");
|
|
130
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
131
|
+
frame = await waitForFrameToContain("Create Manual Portfolio");
|
|
132
|
+
expect(frame).toContain("Create Manual Portfolio");
|
|
133
|
+
|
|
134
|
+
await emitKeypress(testSetup, { name: "return", sequence: "\r" });
|
|
135
|
+
frame = await waitForFrameToContain("Main Portfolio");
|
|
136
|
+
expect(frame).toContain("Main Portfolio");
|
|
137
|
+
|
|
138
|
+
await act(async () => {
|
|
139
|
+
testSetup!.mockInput.pressBackspace();
|
|
140
|
+
await testSetup!.renderOnce();
|
|
141
|
+
});
|
|
142
|
+
frame = testSetup.captureCharFrame();
|
|
143
|
+
expect(frame).toContain("Main Portfoli");
|
|
144
|
+
expect(frame).not.toContain("Create Manual Portfolio");
|
|
145
|
+
});
|
|
146
|
+
|
|
82
147
|
test("waits for broker positions to import before completing onboarding", async () => {
|
|
83
148
|
tempDataDir = await mkdtemp(join(tmpdir(), "gloomberb-onboarding-"));
|
|
84
149
|
const importDeferred = createDeferred<any[]>();
|
|
@@ -10,6 +10,7 @@ import type { PluginRegistry } from "../../plugins/registry";
|
|
|
10
10
|
import { resolveBrokerConfigFields, type BrokerAdapter, type BrokerConfigField } from "../../types/broker";
|
|
11
11
|
import { buildIbkrConfigFromValues } from "../../plugins/ibkr/config";
|
|
12
12
|
import { createBrokerInstanceId } from "../../utils/broker-instances";
|
|
13
|
+
import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation";
|
|
13
14
|
import { syncBrokerInstance } from "../../brokers/sync-broker-instance";
|
|
14
15
|
import { debugLog } from "../../utils/debug-log";
|
|
15
16
|
import { ToggleList, type ToggleListItem } from "../toggle-list";
|
|
@@ -415,7 +416,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
415
416
|
}
|
|
416
417
|
}
|
|
417
418
|
}
|
|
418
|
-
} else if (event
|
|
419
|
+
} else if (isPlainEscape(event)) {
|
|
419
420
|
setEditingField(false);
|
|
420
421
|
if (portfolioSub === "broker-fields") {
|
|
421
422
|
setPortfolioSub("broker-setup");
|
|
@@ -506,7 +507,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
506
507
|
}
|
|
507
508
|
}
|
|
508
509
|
nextStep();
|
|
509
|
-
} else if (event
|
|
510
|
+
} else if (isBackNavigationKey(event)) {
|
|
510
511
|
if (step === "portfolio" && portfolioSub === "broker-sync") {
|
|
511
512
|
resetBrokerSync();
|
|
512
513
|
setPortfolioSub("broker-fields");
|
|
@@ -693,9 +694,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
|
|
|
693
694
|
</box>
|
|
694
695
|
</box>
|
|
695
696
|
<box height={1} flexDirection="row" width={contentWidth}>
|
|
696
|
-
<box flexGrow={1}
|
|
697
|
-
{stepIdx > 0 && <text fg={colors.textMuted}>{"<- back"}</text>}
|
|
698
|
-
</box>
|
|
697
|
+
<box flexGrow={1} />
|
|
699
698
|
<box>
|
|
700
699
|
<text fg={colors.textMuted}>{hintText}</text>
|
|
701
700
|
</box>
|
|
@@ -1024,7 +1023,7 @@ function PortfolioStep({
|
|
|
1024
1023
|
<text fg={colors.textDim}>
|
|
1025
1024
|
{brokerSyncing
|
|
1026
1025
|
? "This happens now so your portfolio is ready before onboarding finishes."
|
|
1027
|
-
: "Press Enter to retry, or
|
|
1026
|
+
: "Press Enter to retry, or Backspace to edit the broker settings."}
|
|
1028
1027
|
</text>
|
|
1029
1028
|
</box>
|
|
1030
1029
|
</box>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import type { ColumnConfig } from "../types/config";
|
|
5
|
+
import type { TickerFinancials } from "../types/financials";
|
|
6
|
+
import type { TickerRecord } from "../types/ticker";
|
|
7
|
+
import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
|
|
8
|
+
|
|
9
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
10
|
+
let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
|
|
11
|
+
let resolveCellCallCount = 0;
|
|
12
|
+
|
|
13
|
+
const columns: ColumnConfig[] = [
|
|
14
|
+
{ id: "ticker", label: "Ticker", width: 6, align: "left" },
|
|
15
|
+
];
|
|
16
|
+
const financialsMap = new Map<string, TickerFinancials>();
|
|
17
|
+
const tickers: TickerRecord[] = [
|
|
18
|
+
{ metadata: { ticker: "AAPL", exchange: "NASDAQ", currency: "USD", name: "Apple", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
19
|
+
{ metadata: { ticker: "MSFT", exchange: "NASDAQ", currency: "USD", name: "Microsoft", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
20
|
+
{ metadata: { ticker: "NVDA", exchange: "NASDAQ", currency: "USD", name: "NVIDIA", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function noop(_index: number | null): void {}
|
|
24
|
+
|
|
25
|
+
function resolveCell(_column: ColumnConfig, ticker: TickerRecord, _financials: TickerFinancials | undefined): TickerTableCell {
|
|
26
|
+
resolveCellCallCount += 1;
|
|
27
|
+
return { text: ticker.metadata.ticker };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TickerListTableHarness() {
|
|
31
|
+
const [cursorSymbol, setCursorSymbol] = useState("AAPL");
|
|
32
|
+
setHarnessCursorSymbol = (symbol: string) => setCursorSymbol(symbol);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<TickerListTable
|
|
36
|
+
columns={columns}
|
|
37
|
+
tickers={tickers}
|
|
38
|
+
cursorSymbol={cursorSymbol}
|
|
39
|
+
hoveredIdx={null}
|
|
40
|
+
setHoveredIdx={noop}
|
|
41
|
+
setCursorSymbol={setCursorSymbol}
|
|
42
|
+
resolveCell={resolveCell}
|
|
43
|
+
financialsMap={financialsMap}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
if (testSetup) {
|
|
50
|
+
await act(async () => {
|
|
51
|
+
testSetup!.renderer.destroy();
|
|
52
|
+
});
|
|
53
|
+
testSetup = undefined;
|
|
54
|
+
}
|
|
55
|
+
resolveCellCallCount = 0;
|
|
56
|
+
setHarnessCursorSymbol = null;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("TickerListTable", () => {
|
|
60
|
+
test("recomputes cells only for rows whose selection state changed", async () => {
|
|
61
|
+
testSetup = await testRender(
|
|
62
|
+
<TickerListTableHarness />,
|
|
63
|
+
{ width: 20, height: 6 },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await act(async () => {
|
|
67
|
+
await testSetup!.renderOnce();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const initialCallCount = resolveCellCallCount;
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
setHarnessCursorSymbol?.("MSFT");
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
});
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await testSetup!.renderOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(initialCallCount).toBeGreaterThan(0);
|
|
81
|
+
expect(resolveCellCallCount - initialCallCount).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, type RefObject } from "react";
|
|
1
|
+
import { memo, useRef, type RefObject } from "react";
|
|
2
2
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
3
|
import { EmptyState } from "./ui";
|
|
4
4
|
import { colors, hoverBg } from "../theme/colors";
|
|
@@ -13,6 +13,11 @@ export interface TickerTableCell {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export type QuoteFlashDirection = "up" | "down" | "flat";
|
|
16
|
+
type ResolveTickerTableCell = (
|
|
17
|
+
column: ColumnConfig,
|
|
18
|
+
ticker: TickerRecord,
|
|
19
|
+
financials: TickerFinancials | undefined,
|
|
20
|
+
) => TickerTableCell;
|
|
16
21
|
|
|
17
22
|
const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
|
|
18
23
|
"price",
|
|
@@ -39,6 +44,160 @@ function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: s
|
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
interface TickerListTableProps {
|
|
48
|
+
columns: ColumnConfig[];
|
|
49
|
+
tickers: TickerRecord[];
|
|
50
|
+
cursorSymbol: string | null;
|
|
51
|
+
hoveredIdx: number | null;
|
|
52
|
+
setHoveredIdx: (index: number | null) => void;
|
|
53
|
+
setCursorSymbol: (symbol: string) => void;
|
|
54
|
+
resolveCell: ResolveTickerTableCell;
|
|
55
|
+
financialsMap: Map<string, TickerFinancials>;
|
|
56
|
+
headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
57
|
+
scrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
58
|
+
syncHeaderScroll?: () => void;
|
|
59
|
+
onBodyScrollActivity?: () => void;
|
|
60
|
+
flashSymbols?: Map<string, QuoteFlashDirection>;
|
|
61
|
+
sortColumnId?: string | null;
|
|
62
|
+
sortDirection?: "asc" | "desc";
|
|
63
|
+
onHeaderClick?: (columnId: string) => void;
|
|
64
|
+
onRowActivate?: (ticker: TickerRecord) => void;
|
|
65
|
+
emptyTitle?: string;
|
|
66
|
+
emptyHint?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TickerListHeader = memo(function TickerListHeader({
|
|
70
|
+
columns,
|
|
71
|
+
headerScrollRef,
|
|
72
|
+
sortColumnId,
|
|
73
|
+
sortDirection,
|
|
74
|
+
onHeaderClick,
|
|
75
|
+
}: {
|
|
76
|
+
columns: ColumnConfig[];
|
|
77
|
+
headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
78
|
+
sortColumnId?: string | null;
|
|
79
|
+
sortDirection?: "asc" | "desc";
|
|
80
|
+
onHeaderClick?: (columnId: string) => void;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<scrollbox
|
|
84
|
+
ref={headerScrollRef}
|
|
85
|
+
height={1}
|
|
86
|
+
scrollX
|
|
87
|
+
focusable={false}
|
|
88
|
+
>
|
|
89
|
+
<box
|
|
90
|
+
flexDirection="row"
|
|
91
|
+
height={1}
|
|
92
|
+
width="100%"
|
|
93
|
+
paddingX={1}
|
|
94
|
+
backgroundColor={colors.panel}
|
|
95
|
+
>
|
|
96
|
+
{columns.map((column) => {
|
|
97
|
+
const isSorted = sortColumnId === column.id;
|
|
98
|
+
const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
|
|
99
|
+
const labelText = column.label + indicator;
|
|
100
|
+
return (
|
|
101
|
+
<box
|
|
102
|
+
key={column.id}
|
|
103
|
+
width={column.width + 1}
|
|
104
|
+
backgroundColor={colors.panel}
|
|
105
|
+
onMouseDown={onHeaderClick ? (event) => {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
onHeaderClick(column.id);
|
|
108
|
+
} : undefined}
|
|
109
|
+
>
|
|
110
|
+
<text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
|
|
111
|
+
{padTo(labelText, column.width, column.align)}
|
|
112
|
+
</text>
|
|
113
|
+
</box>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</box>
|
|
117
|
+
</scrollbox>
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const TickerListRow = memo(function TickerListRow({
|
|
122
|
+
columns,
|
|
123
|
+
ticker,
|
|
124
|
+
index,
|
|
125
|
+
isSelected,
|
|
126
|
+
isHovered,
|
|
127
|
+
financials,
|
|
128
|
+
flashDirection,
|
|
129
|
+
setHoveredIdx,
|
|
130
|
+
setCursorSymbol,
|
|
131
|
+
resolveCell,
|
|
132
|
+
onRowActivate,
|
|
133
|
+
}: {
|
|
134
|
+
columns: ColumnConfig[];
|
|
135
|
+
ticker: TickerRecord;
|
|
136
|
+
index: number;
|
|
137
|
+
isSelected: boolean;
|
|
138
|
+
isHovered: boolean;
|
|
139
|
+
financials: TickerFinancials | undefined;
|
|
140
|
+
flashDirection: QuoteFlashDirection | undefined;
|
|
141
|
+
setHoveredIdx: (index: number | null) => void;
|
|
142
|
+
setCursorSymbol: (symbol: string) => void;
|
|
143
|
+
resolveCell: ResolveTickerTableCell;
|
|
144
|
+
onRowActivate?: (ticker: TickerRecord) => void;
|
|
145
|
+
}) {
|
|
146
|
+
const lastActivatedAtRef = useRef<number | null>(null);
|
|
147
|
+
const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<box
|
|
151
|
+
flexDirection="row"
|
|
152
|
+
height={1}
|
|
153
|
+
paddingX={1}
|
|
154
|
+
backgroundColor={rowBg}
|
|
155
|
+
onMouseMove={() => setHoveredIdx(index)}
|
|
156
|
+
onMouseDown={(event) => {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
setCursorSymbol(ticker.metadata.ticker);
|
|
159
|
+
if (!onRowActivate) return;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (lastActivatedAtRef.current != null && now - lastActivatedAtRef.current <= 350) {
|
|
162
|
+
lastActivatedAtRef.current = null;
|
|
163
|
+
onRowActivate(ticker);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
lastActivatedAtRef.current = now;
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{columns.map((column) => {
|
|
170
|
+
const { text, color } = resolveCell(column, ticker, financials);
|
|
171
|
+
const baseFg = color || (isSelected ? colors.selectedText : colors.text);
|
|
172
|
+
const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
|
|
173
|
+
const cellFg = shouldFlash
|
|
174
|
+
? resolveQuoteFlashColor(flashDirection, baseFg)
|
|
175
|
+
: baseFg;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<box key={column.id} width={column.width + 1}>
|
|
179
|
+
<text fg={cellFg}>
|
|
180
|
+
{padTo(text, column.width, column.align)}
|
|
181
|
+
</text>
|
|
182
|
+
</box>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</box>
|
|
186
|
+
);
|
|
187
|
+
}, (previous, next) => (
|
|
188
|
+
previous.columns === next.columns
|
|
189
|
+
&& previous.ticker === next.ticker
|
|
190
|
+
&& previous.index === next.index
|
|
191
|
+
&& previous.isSelected === next.isSelected
|
|
192
|
+
&& previous.isHovered === next.isHovered
|
|
193
|
+
&& previous.financials === next.financials
|
|
194
|
+
&& previous.flashDirection === next.flashDirection
|
|
195
|
+
&& previous.setHoveredIdx === next.setHoveredIdx
|
|
196
|
+
&& previous.setCursorSymbol === next.setCursorSymbol
|
|
197
|
+
&& previous.resolveCell === next.resolveCell
|
|
198
|
+
&& previous.onRowActivate === next.onRowActivate
|
|
199
|
+
));
|
|
200
|
+
|
|
42
201
|
export function TickerListTable({
|
|
43
202
|
columns,
|
|
44
203
|
tickers,
|
|
@@ -59,60 +218,18 @@ export function TickerListTable({
|
|
|
59
218
|
onRowActivate,
|
|
60
219
|
emptyTitle = "No tickers.",
|
|
61
220
|
emptyHint = "Press Ctrl+P to add one.",
|
|
62
|
-
}: {
|
|
63
|
-
columns: ColumnConfig[];
|
|
64
|
-
tickers: TickerRecord[];
|
|
65
|
-
cursorSymbol: string | null;
|
|
66
|
-
hoveredIdx: number | null;
|
|
67
|
-
setHoveredIdx: (index: number | null) => void;
|
|
68
|
-
setCursorSymbol: (symbol: string) => void;
|
|
69
|
-
resolveCell: (column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => TickerTableCell;
|
|
70
|
-
financialsMap: Map<string, TickerFinancials>;
|
|
71
|
-
headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
72
|
-
scrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
73
|
-
syncHeaderScroll?: () => void;
|
|
74
|
-
onBodyScrollActivity?: () => void;
|
|
75
|
-
flashSymbols?: Map<string, QuoteFlashDirection>;
|
|
76
|
-
sortColumnId?: string | null;
|
|
77
|
-
sortDirection?: "asc" | "desc";
|
|
78
|
-
onHeaderClick?: (columnId: string) => void;
|
|
79
|
-
onRowActivate?: (ticker: TickerRecord) => void;
|
|
80
|
-
emptyTitle?: string;
|
|
81
|
-
emptyHint?: string;
|
|
82
|
-
}) {
|
|
221
|
+
}: TickerListTableProps) {
|
|
83
222
|
const safeFlashSymbols = flashSymbols ?? new Map<string, QuoteFlashDirection>();
|
|
84
|
-
const lastActivatedRef = useRef<{ symbol: string; at: number } | null>(null);
|
|
85
223
|
|
|
86
224
|
return (
|
|
87
225
|
<>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{columns.map((column) => {
|
|
96
|
-
const isSorted = sortColumnId === column.id;
|
|
97
|
-
const indicator = isSorted ? (sortDirection === "asc" ? " \u25B2" : " \u25BC") : "";
|
|
98
|
-
const labelText = column.label + indicator;
|
|
99
|
-
return (
|
|
100
|
-
<box
|
|
101
|
-
key={column.id}
|
|
102
|
-
width={column.width + 1}
|
|
103
|
-
onMouseDown={onHeaderClick ? (event) => {
|
|
104
|
-
event.preventDefault();
|
|
105
|
-
onHeaderClick(column.id);
|
|
106
|
-
} : undefined}
|
|
107
|
-
>
|
|
108
|
-
<text attributes={TextAttributes.BOLD} fg={isSorted ? colors.text : colors.textDim}>
|
|
109
|
-
{padTo(labelText, column.width, column.align)}
|
|
110
|
-
</text>
|
|
111
|
-
</box>
|
|
112
|
-
);
|
|
113
|
-
})}
|
|
114
|
-
</box>
|
|
115
|
-
</scrollbox>
|
|
226
|
+
<TickerListHeader
|
|
227
|
+
columns={columns}
|
|
228
|
+
headerScrollRef={headerScrollRef}
|
|
229
|
+
sortColumnId={sortColumnId}
|
|
230
|
+
sortDirection={sortDirection}
|
|
231
|
+
onHeaderClick={onHeaderClick}
|
|
232
|
+
/>
|
|
116
233
|
|
|
117
234
|
<scrollbox
|
|
118
235
|
ref={scrollRef}
|
|
@@ -131,54 +248,21 @@ export function TickerListTable({
|
|
|
131
248
|
</box>
|
|
132
249
|
) : (
|
|
133
250
|
tickers.map((ticker, index) => {
|
|
134
|
-
const isSelected = ticker.metadata.ticker === cursorSymbol;
|
|
135
|
-
const isHovered = index === hoveredIdx && !isSelected;
|
|
136
|
-
const financials = financialsMap.get(ticker.metadata.ticker);
|
|
137
|
-
const rowBg = isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg;
|
|
138
|
-
const flashDirection = safeFlashSymbols.get(ticker.metadata.ticker);
|
|
139
|
-
|
|
140
251
|
return (
|
|
141
|
-
<
|
|
252
|
+
<TickerListRow
|
|
142
253
|
key={ticker.metadata.ticker}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
lastActivatedRef.current = null;
|
|
156
|
-
onRowActivate(ticker);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
lastActivatedRef.current = {
|
|
160
|
-
symbol: ticker.metadata.ticker,
|
|
161
|
-
at: now,
|
|
162
|
-
};
|
|
163
|
-
}}
|
|
164
|
-
>
|
|
165
|
-
{columns.map((column) => {
|
|
166
|
-
const { text, color } = resolveCell(column, ticker, financials);
|
|
167
|
-
const baseFg = color || (isSelected ? colors.selectedText : colors.text);
|
|
168
|
-
const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
|
|
169
|
-
const cellFg = shouldFlash
|
|
170
|
-
? resolveQuoteFlashColor(flashDirection, baseFg)
|
|
171
|
-
: baseFg;
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<box key={column.id} width={column.width + 1}>
|
|
175
|
-
<text fg={cellFg}>
|
|
176
|
-
{padTo(text, column.width, column.align)}
|
|
177
|
-
</text>
|
|
178
|
-
</box>
|
|
179
|
-
);
|
|
180
|
-
})}
|
|
181
|
-
</box>
|
|
254
|
+
columns={columns}
|
|
255
|
+
ticker={ticker}
|
|
256
|
+
index={index}
|
|
257
|
+
isSelected={ticker.metadata.ticker === cursorSymbol}
|
|
258
|
+
isHovered={index === hoveredIdx && ticker.metadata.ticker !== cursorSymbol}
|
|
259
|
+
financials={financialsMap.get(ticker.metadata.ticker)}
|
|
260
|
+
flashDirection={safeFlashSymbols.get(ticker.metadata.ticker)}
|
|
261
|
+
setHoveredIdx={setHoveredIdx}
|
|
262
|
+
setCursorSymbol={setCursorSymbol}
|
|
263
|
+
resolveCell={resolveCell}
|
|
264
|
+
onRowActivate={onRowActivate}
|
|
265
|
+
/>
|
|
182
266
|
);
|
|
183
267
|
})
|
|
184
268
|
)}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useMemo, useState, type RefObject } from "react";
|
|
1
|
+
import { useCallback, useMemo, useState, type RefObject } from "react";
|
|
2
2
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
3
|
import { colors, hoverBg } from "../../theme/colors";
|
|
4
4
|
import type { ColumnConfig } from "../../types/config";
|
|
5
|
+
import { useAppDispatch, usePaneInstance } from "../../state/app-context";
|
|
5
6
|
import { padTo } from "../../utils/format";
|
|
7
|
+
import { useDoubleClickActivation } from "../use-double-click-activation";
|
|
6
8
|
import { EmptyState } from "./status";
|
|
7
9
|
|
|
8
10
|
export type DataTableColumn = Pick<
|
|
@@ -34,6 +36,7 @@ export interface DataTableProps<
|
|
|
34
36
|
getItemKey: (item: T, index: number) => string;
|
|
35
37
|
isSelected: (item: T, index: number) => boolean;
|
|
36
38
|
onSelect: (item: T, index: number) => void;
|
|
39
|
+
onActivate?: (item: T, index: number) => void;
|
|
37
40
|
renderCell: (
|
|
38
41
|
item: T,
|
|
39
42
|
column: C,
|
|
@@ -46,6 +49,11 @@ export interface DataTableProps<
|
|
|
46
49
|
overscan?: number;
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
interface DataTableRowPointerTarget<T> {
|
|
53
|
+
item: T;
|
|
54
|
+
index: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
50
58
|
columns,
|
|
51
59
|
items,
|
|
@@ -61,12 +69,15 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
61
69
|
getItemKey,
|
|
62
70
|
isSelected,
|
|
63
71
|
onSelect,
|
|
72
|
+
onActivate,
|
|
64
73
|
renderCell,
|
|
65
74
|
emptyStateTitle,
|
|
66
75
|
emptyStateHint,
|
|
67
76
|
virtualize = false,
|
|
68
77
|
overscan = 3,
|
|
69
78
|
}: DataTableProps<T, C>) {
|
|
79
|
+
const dispatch = useAppDispatch();
|
|
80
|
+
const paneInstanceId = usePaneInstance()?.instanceId ?? null;
|
|
70
81
|
const [scrollVersion, setScrollVersion] = useState(0);
|
|
71
82
|
const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
|
|
72
83
|
const viewportHeight = virtualize
|
|
@@ -80,6 +91,17 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
80
91
|
() => items.slice(startIndex, endIndex),
|
|
81
92
|
[endIndex, items, scrollVersion, startIndex],
|
|
82
93
|
);
|
|
94
|
+
const handleRowMouseDown =
|
|
95
|
+
useDoubleClickActivation<DataTableRowPointerTarget<T>>({
|
|
96
|
+
onSelect: ({ item, index }) => {
|
|
97
|
+
onSelect(item, index);
|
|
98
|
+
},
|
|
99
|
+
onActivate: onActivate
|
|
100
|
+
? ({ item, index }) => {
|
|
101
|
+
onActivate(item, index);
|
|
102
|
+
}
|
|
103
|
+
: undefined,
|
|
104
|
+
});
|
|
83
105
|
|
|
84
106
|
const handleBodyScrollActivity = () => {
|
|
85
107
|
if (virtualize) {
|
|
@@ -87,11 +109,21 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
87
109
|
}
|
|
88
110
|
onBodyScrollActivity();
|
|
89
111
|
};
|
|
112
|
+
const focusPane = useCallback(() => {
|
|
113
|
+
if (!paneInstanceId) return;
|
|
114
|
+
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
115
|
+
}, [dispatch, paneInstanceId]);
|
|
90
116
|
|
|
91
117
|
return (
|
|
92
118
|
<>
|
|
93
119
|
<scrollbox ref={headerScrollRef} height={1} scrollX focusable={false}>
|
|
94
|
-
<box
|
|
120
|
+
<box
|
|
121
|
+
flexDirection="row"
|
|
122
|
+
height={1}
|
|
123
|
+
width="100%"
|
|
124
|
+
paddingX={1}
|
|
125
|
+
backgroundColor={colors.panel}
|
|
126
|
+
>
|
|
95
127
|
{columns.map((column) => {
|
|
96
128
|
const isSorted = sortColumnId === column.id;
|
|
97
129
|
const indicator = isSorted
|
|
@@ -108,7 +140,9 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
108
140
|
<box
|
|
109
141
|
key={column.id}
|
|
110
142
|
width={column.width + 1}
|
|
143
|
+
backgroundColor={colors.panel}
|
|
111
144
|
onMouseDown={(event) => {
|
|
145
|
+
focusPane();
|
|
112
146
|
event.preventDefault();
|
|
113
147
|
onHeaderClick(column.id);
|
|
114
148
|
}}
|
|
@@ -131,7 +165,10 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
131
165
|
scrollX
|
|
132
166
|
scrollY
|
|
133
167
|
focusable={false}
|
|
134
|
-
onMouseDown={() =>
|
|
168
|
+
onMouseDown={() => {
|
|
169
|
+
focusPane();
|
|
170
|
+
queueMicrotask(syncHeaderScroll);
|
|
171
|
+
}}
|
|
135
172
|
onMouseUp={() => queueMicrotask(handleBodyScrollActivity)}
|
|
136
173
|
onMouseDrag={() => queueMicrotask(handleBodyScrollActivity)}
|
|
137
174
|
onMouseScroll={() => queueMicrotask(handleBodyScrollActivity)}
|
|
@@ -162,8 +199,12 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
162
199
|
backgroundColor={rowBg}
|
|
163
200
|
onMouseMove={() => setHoveredIdx(index)}
|
|
164
201
|
onMouseDown={(event) => {
|
|
202
|
+
focusPane();
|
|
165
203
|
event.preventDefault();
|
|
166
|
-
|
|
204
|
+
handleRowMouseDown(getItemKey(item, index), {
|
|
205
|
+
item,
|
|
206
|
+
index,
|
|
207
|
+
});
|
|
167
208
|
}}
|
|
168
209
|
>
|
|
169
210
|
{columns.map((column) => {
|
|
@@ -176,13 +217,17 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>({
|
|
|
176
217
|
key={column.id}
|
|
177
218
|
width={column.width + 1}
|
|
178
219
|
onMouseDown={(event) => {
|
|
220
|
+
focusPane();
|
|
179
221
|
if (cell.onMouseDown) {
|
|
180
222
|
cell.onMouseDown(event);
|
|
181
223
|
return;
|
|
182
224
|
}
|
|
183
225
|
event.preventDefault();
|
|
184
226
|
event.stopPropagation?.();
|
|
185
|
-
|
|
227
|
+
handleRowMouseDown(getItemKey(item, index), {
|
|
228
|
+
item,
|
|
229
|
+
index,
|
|
230
|
+
});
|
|
186
231
|
}}
|
|
187
232
|
>
|
|
188
233
|
<text
|
|
@@ -43,6 +43,7 @@ export { Section, FieldRow, DialogFrame } from "./frame";
|
|
|
43
43
|
export type { SectionProps, FieldRowProps, DialogFrameProps } from "./frame";
|
|
44
44
|
|
|
45
45
|
export { ExternalLink, openUrl } from "./external-link";
|
|
46
|
+
export { PageStackView } from "./page-stack-view";
|
|
46
47
|
|
|
47
48
|
export { Spinner, ProgressBar, SkeletonRow, LoadingBlock } from "./loading";
|
|
48
49
|
export type {
|