gloomberb 0.9.2 → 0.9.3
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 +23 -2
- package/bin/gloomberb +1 -1
- package/package.json +1 -1
- package/src/app.tsx +10 -7
- package/src/capabilities/registry.ts +1 -0
- package/src/capabilities/types.ts +23 -0
- package/src/cli/commands/api.ts +119 -0
- package/src/cli/commands/automation.ts +173 -0
- package/src/cli/commands/command-utils.ts +53 -0
- package/src/cli/commands/market.ts +527 -0
- package/src/cli/commands/overview.ts +222 -0
- package/src/cli/commands/search.ts +39 -0
- package/src/cli/commands/system.ts +553 -0
- package/src/cli/commands/ticker.ts +104 -12
- package/src/cli/context.ts +35 -2
- package/src/cli/entry.ts +51 -0
- package/src/cli/errors.ts +56 -5
- package/src/cli/index.ts +73 -11
- package/src/cli/options.test.ts +38 -0
- package/src/cli/options.ts +95 -0
- package/src/cli/pane-functions/index.ts +10 -1
- package/src/cli/registry.test.ts +40 -0
- package/src/cli/registry.ts +46 -5
- package/src/cli/result.test.ts +82 -0
- package/src/cli/result.ts +149 -0
- package/src/cli/types.ts +11 -0
- package/src/cli.test.ts +11 -11
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
- package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
- package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
- package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
- package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
- package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
- package/src/components/chart/core/pane-settings.test.ts +27 -0
- package/src/components/chart/core/pane-settings.ts +60 -1
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
- package/src/components/chart/stock-chart/settings.ts +7 -3
- package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
- package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
- package/src/components/command-bar/surface/pane.test.tsx +76 -0
- package/src/components/data-table/view.tsx +13 -2
- package/src/components/feed-data-table/stack-view.tsx +9 -1
- package/src/components/index.ts +1 -1
- package/src/components/input-search-bar.test.tsx +103 -0
- package/src/components/input-search-bar.tsx +16 -0
- package/src/components/layout/shell/active-drag.ts +5 -1
- package/src/components/layout/shell/drag/runtime.ts +21 -3
- package/src/components/layout/shell/fullscreen.ts +10 -0
- package/src/components/layout/shell/index.test.tsx +395 -2
- package/src/components/layout/shell/index.tsx +128 -2
- package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
- package/src/components/layout/shell/pane/layers.tsx +26 -14
- package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
- package/src/components/layout/shell/shortcuts.ts +4 -0
- package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
- package/src/components/layout/shell/window-mode/index.ts +11 -3
- package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
- package/src/components/layout/status-bar.test.tsx +105 -0
- package/src/components/layout/status-bar.tsx +32 -6
- package/src/components/layout/transient-layout.tsx +57 -0
- package/src/components/layout/window-edit/mode.test.ts +76 -5
- package/src/components/layout/window-edit/mode.ts +47 -2
- package/src/components/layout/window-edit/presentation.ts +2 -1
- package/src/components/price-performance.tsx +9 -1
- package/src/core/app-services.ts +7 -4
- package/src/data/config/store/index.ts +1 -2
- package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
- package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
- package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
- package/src/plugins/builtin/news/wire/read-state.ts +9 -32
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
- package/src/plugins/builtin/shared/read-state.ts +43 -0
- package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
- package/src/plugins/builtin/substack/article-detail.tsx +39 -15
- package/src/plugins/builtin/substack/article-stack.tsx +6 -2
- package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
- package/src/plugins/builtin/substack/pane.tsx +8 -3
- package/src/plugins/builtin/substack/read-state.ts +55 -0
- package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
- package/src/plugins/pane-manager/docking.ts +9 -2
- package/src/plugins/pane-manager/queries.ts +8 -2
- package/src/plugins/prediction-markets/controller/index.ts +2 -0
- package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
- package/src/plugins/prediction-markets/pane.test.tsx +32 -0
- package/src/plugins/prediction-markets/pane.tsx +21 -1
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
- package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/index.ts +13 -0
- package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
- package/src/renderers/electrobun/bun/window/focus.ts +5 -0
- package/src/renderers/electrobun/view/app-services.ts +6 -4
- package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
- package/src/renderers/electrobun/view/host/text.test.ts +22 -0
- package/src/renderers/electrobun/view/host/text.tsx +24 -20
- package/src/renderers/electrobun/view/ui-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +13 -5
- package/src/state/app/input-capture.ts +12 -0
- package/src/types/plugin.ts +28 -0
- package/src/utils/cli-output.ts +6 -0
- package/src/utils/search-focus-navigation.ts +24 -0
- package/src/version.ts +1 -1
|
@@ -6,6 +6,7 @@ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../
|
|
|
6
6
|
import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane/footer";
|
|
7
7
|
import {
|
|
8
8
|
AppContext,
|
|
9
|
+
type AppAction,
|
|
9
10
|
createInitialState,
|
|
10
11
|
PaneInstanceProvider,
|
|
11
12
|
} from "../../../state/app/context";
|
|
@@ -13,7 +14,7 @@ import { Box } from "../../../ui";
|
|
|
13
14
|
import { cloneLayout, createDefaultConfig } from "../../../types/config";
|
|
14
15
|
import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
|
|
15
16
|
import type { DataProvider } from "../../../types/data-provider";
|
|
16
|
-
import type { TickerFinancials } from "../../../types/financials";
|
|
17
|
+
import type { PricePoint, TickerFinancials } from "../../../types/financials";
|
|
17
18
|
import type { PluginRegistry } from "../../registry";
|
|
18
19
|
import { PluginRenderProvider, type PluginRuntimeAccess } from "../../runtime";
|
|
19
20
|
import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../../registry";
|
|
@@ -30,6 +31,12 @@ let root: ReturnType<typeof createRoot> | undefined;
|
|
|
30
31
|
let sharedCoordinator: MarketDataCoordinator | null = null;
|
|
31
32
|
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
32
33
|
|
|
34
|
+
interface ComparisonHarnessOptions {
|
|
35
|
+
dispatch?: (action: AppAction) => void;
|
|
36
|
+
focused?: boolean;
|
|
37
|
+
focusedPaneId?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
function makeTicker(symbol: string, currency: string): TickerRecord {
|
|
34
41
|
return {
|
|
35
42
|
metadata: {
|
|
@@ -90,6 +97,13 @@ function makeDatedFinancials(symbol: string, currency: string, closes: Array<[st
|
|
|
90
97
|
};
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
function makeDatedPriceHistory(closes: Array<[string, number]>): PricePoint[] {
|
|
101
|
+
return closes.map(([date, close]) => ({
|
|
102
|
+
date: new Date(`${date}T00:00:00Z`),
|
|
103
|
+
close,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
function createProvider(historyBySymbol: Record<string, number[]>, currencyBySymbol: Record<string, string>): DataProvider {
|
|
94
108
|
return {
|
|
95
109
|
id: "test-provider",
|
|
@@ -139,6 +153,30 @@ function createDatedProvider(financialsBySymbol: Record<string, TickerFinancials
|
|
|
139
153
|
return provider;
|
|
140
154
|
}
|
|
141
155
|
|
|
156
|
+
function createRangeAwareDatedProvider({
|
|
157
|
+
fullHistoryBySymbol,
|
|
158
|
+
shortHistoryBySymbol,
|
|
159
|
+
}: {
|
|
160
|
+
fullHistoryBySymbol: Record<string, Array<[string, number]>>;
|
|
161
|
+
shortHistoryBySymbol: Record<string, Array<[string, number]>>;
|
|
162
|
+
}): DataProvider {
|
|
163
|
+
const financialsBySymbol = Object.fromEntries(
|
|
164
|
+
Object.entries(shortHistoryBySymbol).map(([symbol, history]) => [
|
|
165
|
+
symbol,
|
|
166
|
+
makeDatedFinancials(symbol, "USD", history),
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
const provider = createDatedProvider(financialsBySymbol);
|
|
170
|
+
const resolveHistory = (symbol: string, range: string) => makeDatedPriceHistory(
|
|
171
|
+
range === "5Y"
|
|
172
|
+
? fullHistoryBySymbol[symbol] ?? shortHistoryBySymbol[symbol] ?? []
|
|
173
|
+
: shortHistoryBySymbol[symbol] ?? [],
|
|
174
|
+
);
|
|
175
|
+
provider.getPriceHistory = async (symbol, _exchange, range) => resolveHistory(symbol, range);
|
|
176
|
+
provider.getPriceHistoryForResolution = async (symbol, _exchange, range) => resolveHistory(symbol, range);
|
|
177
|
+
return provider;
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
function createRegistrySpy(spy: { selected: string[]; focused: string[] }): PluginRegistry {
|
|
143
181
|
return {
|
|
144
182
|
selectTicker: (symbol: string) => { spy.selected.push(symbol); },
|
|
@@ -171,7 +209,13 @@ function createComparisonHarness(
|
|
|
171
209
|
tickers: TickerRecord[],
|
|
172
210
|
financials: Array<[string, TickerFinancials]>,
|
|
173
211
|
runtime: PluginRuntimeAccess,
|
|
212
|
+
options: ComparisonHarnessOptions = {},
|
|
174
213
|
) {
|
|
214
|
+
const {
|
|
215
|
+
dispatch = () => {},
|
|
216
|
+
focused = true,
|
|
217
|
+
focusedPaneId = focused ? TEST_PANE_ID : null,
|
|
218
|
+
} = options;
|
|
175
219
|
const config = createDefaultConfig("/tmp/gloomberb-compare");
|
|
176
220
|
const layout = {
|
|
177
221
|
dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
|
|
@@ -189,7 +233,7 @@ function createComparisonHarness(
|
|
|
189
233
|
layouts: [{ name: "Default", layout: cloneLayout(layout) }],
|
|
190
234
|
};
|
|
191
235
|
const state = createInitialState(nextConfig);
|
|
192
|
-
state.focusedPaneId =
|
|
236
|
+
state.focusedPaneId = focusedPaneId;
|
|
193
237
|
state.tickers = new Map(tickers.map((ticker) => [ticker.metadata.ticker, ticker]));
|
|
194
238
|
state.financials = new Map(financials);
|
|
195
239
|
|
|
@@ -202,7 +246,7 @@ function createComparisonHarness(
|
|
|
202
246
|
}) => ReactElement;
|
|
203
247
|
|
|
204
248
|
return (
|
|
205
|
-
<AppContext value={{ state, dispatch
|
|
249
|
+
<AppContext value={{ state, dispatch }}>
|
|
206
250
|
<PaneInstanceProvider paneId={TEST_PANE_ID}>
|
|
207
251
|
<PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
|
|
208
252
|
<PaneFooterProvider>
|
|
@@ -211,11 +255,11 @@ function createComparisonHarness(
|
|
|
211
255
|
<ComparisonPane
|
|
212
256
|
paneId={TEST_PANE_ID}
|
|
213
257
|
paneType="comparison-chart"
|
|
214
|
-
focused
|
|
258
|
+
focused={focused}
|
|
215
259
|
width={120}
|
|
216
260
|
height={19}
|
|
217
261
|
/>
|
|
218
|
-
<PaneFooterBar footer={footer} focused width={120} />
|
|
262
|
+
<PaneFooterBar footer={footer} focused={focused} width={120} />
|
|
219
263
|
</Box>
|
|
220
264
|
)}
|
|
221
265
|
</PaneFooterProvider>
|
|
@@ -241,13 +285,14 @@ async function mountComparisonHarness(
|
|
|
241
285
|
tickers: TickerRecord[],
|
|
242
286
|
financials: Array<[string, TickerFinancials]>,
|
|
243
287
|
spy: { selected: string[]; focused: string[]; settings?: string[] } = { selected: [], focused: [] },
|
|
288
|
+
options: ComparisonHarnessOptions = {},
|
|
244
289
|
) {
|
|
245
290
|
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
246
291
|
testSetup = await createTestRenderer({ width: 120, height: 20 });
|
|
247
292
|
root = createRoot(testSetup.renderer);
|
|
248
293
|
await act(async () => {
|
|
249
294
|
root!.render(
|
|
250
|
-
createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy)),
|
|
295
|
+
createComparisonHarness(settings, tickers, financials, createRuntimeSpy(spy), options),
|
|
251
296
|
);
|
|
252
297
|
await Promise.resolve();
|
|
253
298
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -399,6 +444,85 @@ describe("comparisonChartPlugin", () => {
|
|
|
399
444
|
expect(frame).toMatch(/MSFT\s+0\.00%/);
|
|
400
445
|
});
|
|
401
446
|
|
|
447
|
+
test("focuses the pane when an unfocused comparison plot is clicked", async () => {
|
|
448
|
+
const dispatched: AppAction[] = [];
|
|
449
|
+
const provider = createProvider({
|
|
450
|
+
AAPL: [100, 102, 104],
|
|
451
|
+
MSFT: [200, 202, 204],
|
|
452
|
+
}, {
|
|
453
|
+
AAPL: "USD",
|
|
454
|
+
MSFT: "USD",
|
|
455
|
+
});
|
|
456
|
+
setSharedMarketDataForTests(provider);
|
|
457
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
458
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
459
|
+
setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
|
|
460
|
+
|
|
461
|
+
await mountComparisonHarness({
|
|
462
|
+
axisMode: "percent",
|
|
463
|
+
symbols: ["AAPL", "MSFT"],
|
|
464
|
+
symbolsText: "AAPL, MSFT",
|
|
465
|
+
}, [
|
|
466
|
+
makeTicker("AAPL", "USD"),
|
|
467
|
+
makeTicker("MSFT", "USD"),
|
|
468
|
+
], [
|
|
469
|
+
["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
|
|
470
|
+
["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
|
|
471
|
+
], { selected: [], focused: [] }, {
|
|
472
|
+
dispatch: (action) => { dispatched.push(action); },
|
|
473
|
+
focused: false,
|
|
474
|
+
focusedPaneId: "ticker-detail:main",
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await flushFrames();
|
|
478
|
+
await act(async () => {
|
|
479
|
+
await testSetup!.mockMouse.click(20, 8);
|
|
480
|
+
await testSetup!.renderOnce();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(dispatched).toContainEqual({ type: "FOCUS_PANE", paneId: TEST_PANE_ID });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("keeps fixed return horizons independent from the selected chart range", async () => {
|
|
487
|
+
const shortHistory = {
|
|
488
|
+
AAPL: [["2025-01-02", 180], ["2026-01-02", 200]],
|
|
489
|
+
MSFT: [["2025-01-02", 300], ["2026-01-02", 330]],
|
|
490
|
+
} satisfies Record<string, Array<[string, number]>>;
|
|
491
|
+
const fullHistory = {
|
|
492
|
+
AAPL: [["2021-01-02", 100], ["2023-01-02", 150], ["2025-01-02", 180], ["2026-01-02", 200]],
|
|
493
|
+
MSFT: [["2021-01-02", 220], ["2023-01-02", 270], ["2025-01-02", 300], ["2026-01-02", 330]],
|
|
494
|
+
} satisfies Record<string, Array<[string, number]>>;
|
|
495
|
+
const provider = createRangeAwareDatedProvider({
|
|
496
|
+
fullHistoryBySymbol: fullHistory,
|
|
497
|
+
shortHistoryBySymbol: shortHistory,
|
|
498
|
+
});
|
|
499
|
+
setSharedMarketDataForTests(provider);
|
|
500
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
501
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
502
|
+
setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
|
|
503
|
+
|
|
504
|
+
await mountComparisonHarness({
|
|
505
|
+
axisMode: "percent",
|
|
506
|
+
rangePreset: "1Y",
|
|
507
|
+
chartResolution: "1d",
|
|
508
|
+
symbols: ["AAPL", "MSFT"],
|
|
509
|
+
symbolsText: "AAPL, MSFT",
|
|
510
|
+
}, [
|
|
511
|
+
makeTicker("AAPL", "USD"),
|
|
512
|
+
makeTicker("MSFT", "USD"),
|
|
513
|
+
], [
|
|
514
|
+
["AAPL", makeDatedFinancials("AAPL", "USD", shortHistory.AAPL)],
|
|
515
|
+
["MSFT", makeDatedFinancials("MSFT", "USD", shortHistory.MSFT)],
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
await flushFrames(8);
|
|
519
|
+
|
|
520
|
+
const frame = testSetup!.captureCharFrame();
|
|
521
|
+
expect(frame).toContain("5Y");
|
|
522
|
+
expect(frame).toMatch(/> AAPL\s+\+11\.11%[^\n]*\+100\.00%/);
|
|
523
|
+
expect(frame).toMatch(/MSFT\s+\+10\.00%[^\n]*\+50\.00%/);
|
|
524
|
+
});
|
|
525
|
+
|
|
402
526
|
test("sorts comparison summary rows by one-year return", async () => {
|
|
403
527
|
const rows = {
|
|
404
528
|
SLOW: makeDatedFinancials("SLOW", "USD", [["2025-01-02", 100], ["2026-01-02", 105]]),
|
|
@@ -1,55 +1,32 @@
|
|
|
1
1
|
import { useCallback, useMemo } from "react";
|
|
2
2
|
import { usePluginState } from "../../../runtime";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_READ_IDS,
|
|
5
|
+
markReadId,
|
|
6
|
+
normalizeReadIds,
|
|
7
|
+
sameStringArray,
|
|
8
|
+
} from "../../shared/read-state";
|
|
3
9
|
|
|
4
10
|
export interface NewsReadState {
|
|
5
11
|
articleIds: string[];
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
const NEWS_READ_STATE_SCHEMA_VERSION = 1;
|
|
9
|
-
export const MAX_READ_ARTICLE_IDS =
|
|
15
|
+
export const MAX_READ_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
|
|
10
16
|
|
|
11
17
|
const READ_STATE_KEY = "read-articles";
|
|
12
18
|
const EMPTY_READ_STATE: NewsReadState = { articleIds: [] };
|
|
13
19
|
|
|
14
|
-
function normalizeArticleId(articleId: string): string {
|
|
15
|
-
return articleId.trim();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function sameStringArray(left: string[], right: string[]): boolean {
|
|
19
|
-
if (left.length !== right.length) return false;
|
|
20
|
-
return left.every((value, index) => value === right[index]);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
20
|
export function normalizeNewsReadState(state: NewsReadState): NewsReadState {
|
|
24
|
-
|
|
25
|
-
const articleIds: string[] = [];
|
|
26
|
-
|
|
27
|
-
for (const rawId of state.articleIds) {
|
|
28
|
-
if (typeof rawId !== "string") continue;
|
|
29
|
-
const articleId = normalizeArticleId(rawId);
|
|
30
|
-
if (!articleId || seen.has(articleId)) continue;
|
|
31
|
-
seen.add(articleId);
|
|
32
|
-
articleIds.push(articleId);
|
|
33
|
-
if (articleIds.length >= MAX_READ_ARTICLE_IDS) break;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { articleIds };
|
|
21
|
+
return { articleIds: normalizeReadIds(state.articleIds, MAX_READ_ARTICLE_IDS) };
|
|
37
22
|
}
|
|
38
23
|
|
|
39
24
|
export function markNewsArticleRead(
|
|
40
25
|
state: NewsReadState,
|
|
41
26
|
articleId: string,
|
|
42
27
|
): NewsReadState {
|
|
43
|
-
const normalizedId = normalizeArticleId(articleId);
|
|
44
28
|
const current = normalizeNewsReadState(state).articleIds;
|
|
45
|
-
|
|
46
|
-
return sameStringArray(current, state.articleIds) ? state : { articleIds: current };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const articleIds = [
|
|
50
|
-
normalizedId,
|
|
51
|
-
...current.filter((currentId) => currentId !== normalizedId),
|
|
52
|
-
].slice(0, MAX_READ_ARTICLE_IDS);
|
|
29
|
+
const articleIds = markReadId(current, articleId, MAX_READ_ARTICLE_IDS);
|
|
53
30
|
|
|
54
31
|
return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
|
|
55
32
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { saveConfig } from "../../../../data/config/store";
|
|
2
|
+
import { countCollectionTickers } from "../../../../cli/helpers";
|
|
2
3
|
import { resolveTickerForCli } from "../../../../cli/ticker-resolution";
|
|
3
4
|
import { cliStyles, renderStat } from "../../../../utils/cli-output";
|
|
4
5
|
import { formatMarketCostWithCurrency, formatMarketQuantity } from "../../../../market-data/market/format";
|
|
@@ -17,6 +18,28 @@ import { parseFiniteNumber, requireManualPortfolio } from "./shared";
|
|
|
17
18
|
async function listCollections(ctx: CliCommandContext) {
|
|
18
19
|
const { config, store, persistence } = await ctx.initConfigData();
|
|
19
20
|
const tickers = await store.loadAllTickers();
|
|
21
|
+
if (ctx.cliOptions.format !== "text") {
|
|
22
|
+
ctx.printResult({
|
|
23
|
+
data: {
|
|
24
|
+
portfolios: config.portfolios.map((portfolio) => ({
|
|
25
|
+
id: portfolio.id,
|
|
26
|
+
name: portfolio.name,
|
|
27
|
+
currency: portfolio.currency,
|
|
28
|
+
brokerId: portfolio.brokerId ?? "",
|
|
29
|
+
brokerInstanceId: portfolio.brokerInstanceId ?? "",
|
|
30
|
+
brokerAccountId: portfolio.brokerAccountId ?? "",
|
|
31
|
+
tickerCount: countCollectionTickers(tickers, "portfolios", portfolio.id),
|
|
32
|
+
})),
|
|
33
|
+
watchlists: config.watchlists.map((watchlist) => ({
|
|
34
|
+
id: watchlist.id,
|
|
35
|
+
name: watchlist.name,
|
|
36
|
+
tickerCount: countCollectionTickers(tickers, "watchlists", watchlist.id),
|
|
37
|
+
})),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
persistence.close();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
20
43
|
console.log(renderCollectionOverview(config, tickers));
|
|
21
44
|
persistence.close();
|
|
22
45
|
}
|
|
@@ -129,6 +129,23 @@ async function removeTickerFromWatchlist(watchlistName: string, symbol: string,
|
|
|
129
129
|
async function listWatchlists(ctx: CliCommandContext) {
|
|
130
130
|
const { config, store, persistence } = await ctx.initConfigData();
|
|
131
131
|
const tickers = await store.loadAllTickers();
|
|
132
|
+
const rows = config.watchlists.map((watchlist) => ({
|
|
133
|
+
id: watchlist.id,
|
|
134
|
+
name: watchlist.name,
|
|
135
|
+
tickerCount: countCollectionTickers(tickers, "watchlists", watchlist.id),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
if (ctx.cliOptions.format !== "text") {
|
|
139
|
+
ctx.printResult({ data: rows }, {
|
|
140
|
+
columns: [
|
|
141
|
+
{ key: "name", header: "Watchlist" },
|
|
142
|
+
{ key: "id", header: "ID" },
|
|
143
|
+
{ key: "tickerCount", header: "Tickers", align: "right" },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
persistence.close();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
132
149
|
|
|
133
150
|
console.log(renderSection("Watchlists"));
|
|
134
151
|
if (config.watchlists.length === 0) {
|
|
@@ -143,10 +160,10 @@ async function listWatchlists(ctx: CliCommandContext) {
|
|
|
143
160
|
{ header: "ID" },
|
|
144
161
|
{ header: "Tickers", align: "right" },
|
|
145
162
|
],
|
|
146
|
-
|
|
163
|
+
rows.map((watchlist) => [
|
|
147
164
|
watchlist.name,
|
|
148
165
|
watchlist.id,
|
|
149
|
-
String(
|
|
166
|
+
String(watchlist.tickerCount),
|
|
150
167
|
]),
|
|
151
168
|
));
|
|
152
169
|
persistence.close();
|
|
@@ -363,7 +363,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
363
363
|
activeValue={activeCollectionId}
|
|
364
364
|
onSelect={handleCollectionSelect}
|
|
365
365
|
compact
|
|
366
|
-
focused={focused}
|
|
366
|
+
focused={focused && !quickAddFocused}
|
|
367
367
|
/>
|
|
368
368
|
</Box>
|
|
369
369
|
</Box>
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { Box, Input, Text, type InputRenderable } from "../../../../ui";
|
|
3
3
|
import { useShortcut } from "../../../../react/input";
|
|
4
4
|
import { useAppDispatch, useAppSelector } from "../../../../state/app/context";
|
|
5
|
+
import { useAppInputCapture } from "../../../../state/app/input-capture";
|
|
5
6
|
import { getSharedRegistry } from "../../../registry";
|
|
6
7
|
import { usePluginAppActions } from "../../../runtime";
|
|
7
8
|
import { colors, priceColor } from "../../../../theme/colors";
|
|
@@ -102,6 +103,7 @@ export function QuickAddTickerInput({
|
|
|
102
103
|
const [inputValue, setInputValue] = useState("");
|
|
103
104
|
const [validation, setValidation] = useState<QuickAddValidation>(IDLE_VALIDATION);
|
|
104
105
|
const [submitting, setSubmitting] = useState(false);
|
|
106
|
+
useAppInputCapture(inputFocused && focused);
|
|
105
107
|
|
|
106
108
|
const focusInput = useCallback(() => {
|
|
107
109
|
if (!focused) return;
|
|
@@ -122,13 +124,6 @@ export function QuickAddTickerInput({
|
|
|
122
124
|
onFocusChange?.(inputFocused && focused);
|
|
123
125
|
}, [focused, inputFocused, onFocusChange]);
|
|
124
126
|
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
if (inputFocused && focused) {
|
|
127
|
-
dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
|
|
128
|
-
return () => dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
|
|
129
|
-
}
|
|
130
|
-
}, [dispatch, focused, inputFocused]);
|
|
131
|
-
|
|
132
127
|
useEffect(() => {
|
|
133
128
|
if (!focused && inputFocused) {
|
|
134
129
|
blurInput();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const DEFAULT_MAX_READ_IDS = 2_000;
|
|
2
|
+
|
|
3
|
+
function normalizeReadId(readId: unknown): string {
|
|
4
|
+
return typeof readId === "string" ? readId.trim() : "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
|
8
|
+
if (left.length !== right.length) return false;
|
|
9
|
+
return left.every((value, index) => value === right[index]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeReadIds(
|
|
13
|
+
readIds: readonly unknown[] | undefined,
|
|
14
|
+
maxIds = DEFAULT_MAX_READ_IDS,
|
|
15
|
+
): string[] {
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
const normalizedIds: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const rawId of readIds ?? []) {
|
|
20
|
+
const readId = normalizeReadId(rawId);
|
|
21
|
+
if (!readId || seen.has(readId)) continue;
|
|
22
|
+
seen.add(readId);
|
|
23
|
+
normalizedIds.push(readId);
|
|
24
|
+
if (normalizedIds.length >= maxIds) break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return normalizedIds;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markReadId(
|
|
31
|
+
readIds: readonly unknown[] | undefined,
|
|
32
|
+
readId: string,
|
|
33
|
+
maxIds = DEFAULT_MAX_READ_IDS,
|
|
34
|
+
): string[] {
|
|
35
|
+
const normalizedId = normalizeReadId(readId);
|
|
36
|
+
const current = normalizeReadIds(readIds, maxIds);
|
|
37
|
+
if (!normalizedId) return current;
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
normalizedId,
|
|
41
|
+
...current.filter((currentId) => currentId !== normalizedId),
|
|
42
|
+
].slice(0, maxIds);
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveArticleImageSize } from "./article-detail";
|
|
3
|
+
|
|
4
|
+
describe("Substack article image sizing", () => {
|
|
5
|
+
test("uses extra detail pane room without shrinking compact panes", () => {
|
|
6
|
+
expect(resolveArticleImageSize(40)).toEqual({ width: 40, height: 12 });
|
|
7
|
+
expect(resolveArticleImageSize(86)).toEqual({ width: 86, height: 15 });
|
|
8
|
+
expect(resolveArticleImageSize(220)).toEqual({ width: 152, height: 26 });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, type RefObject } from "react";
|
|
1
|
+
import { memo, useCallback, useMemo, type RefObject } from "react";
|
|
2
2
|
import { Box, ScrollBox, Text, TextAttributes, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
|
|
3
3
|
import { Spinner } from "../../../components";
|
|
4
4
|
import { RemoteImage } from "../../../components/ui";
|
|
@@ -38,6 +38,25 @@ function wrappedTextProps(width: number) {
|
|
|
38
38
|
} as const;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const ARTICLE_IMAGE_MAX_WIDTH = 152;
|
|
42
|
+
const ARTICLE_IMAGE_MAX_HEIGHT = 26;
|
|
43
|
+
const ARTICLE_IMAGE_MIN_HEIGHT = 6;
|
|
44
|
+
const ARTICLE_IMAGE_LEGACY_HEIGHT_CAP = 14;
|
|
45
|
+
|
|
46
|
+
export function resolveArticleImageSize(lineWidth: number) {
|
|
47
|
+
const safeLineWidth = Math.max(1, Math.floor(lineWidth));
|
|
48
|
+
const width = Math.min(safeLineWidth, ARTICLE_IMAGE_MAX_WIDTH);
|
|
49
|
+
const legacyHeight = Math.min(ARTICLE_IMAGE_LEGACY_HEIGHT_CAP, Math.floor(width * 0.32));
|
|
50
|
+
const expandedHeight = Math.floor(width * 0.18);
|
|
51
|
+
return {
|
|
52
|
+
width,
|
|
53
|
+
height: Math.max(ARTICLE_IMAGE_MIN_HEIGHT, Math.min(
|
|
54
|
+
ARTICLE_IMAGE_MAX_HEIGHT,
|
|
55
|
+
Math.max(legacyHeight, expandedHeight),
|
|
56
|
+
)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
function normalizedTwitterUsername(username: string | null | undefined): string | null {
|
|
42
61
|
const normalized = username?.trim().replace(/^@/, "") ?? "";
|
|
43
62
|
return /^[A-Za-z0-9_]{1,15}$/.test(normalized) ? normalized : null;
|
|
@@ -251,7 +270,7 @@ function ArticleBlockView({
|
|
|
251
270
|
}
|
|
252
271
|
}
|
|
253
272
|
|
|
254
|
-
function ArticleRichContent({
|
|
273
|
+
const ArticleRichContent = memo(function ArticleRichContent({
|
|
255
274
|
blocks,
|
|
256
275
|
fallbackText,
|
|
257
276
|
fallbackImageUrls,
|
|
@@ -269,14 +288,20 @@ function ArticleRichContent({
|
|
|
269
288
|
imageHeight: number;
|
|
270
289
|
}) {
|
|
271
290
|
const rendererHost = useRendererHost();
|
|
272
|
-
const resolvedBlocks =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
291
|
+
const resolvedBlocks = useMemo(() => (
|
|
292
|
+
blocks.length > 0
|
|
293
|
+
? blocks
|
|
294
|
+
: [
|
|
295
|
+
...(fallbackText ? [{ type: "paragraph" as const, text: fallbackText }] : []),
|
|
296
|
+
...fallbackImageUrls.map((url): SubstackContentBlock => ({ type: "image", url, alt: articleTitle })),
|
|
297
|
+
]
|
|
298
|
+
), [articleTitle, blocks, fallbackImageUrls, fallbackText]);
|
|
299
|
+
const tickerText = useMemo(
|
|
300
|
+
() => resolvedBlocks.map(articleBlockText).filter(Boolean).join("\n"),
|
|
301
|
+
[resolvedBlocks],
|
|
302
|
+
);
|
|
303
|
+
const tickerTexts = useMemo(() => [tickerText], [tickerText]);
|
|
304
|
+
const { catalog, openTicker } = useInlineTickers(tickerTexts, { liveQuotes: false });
|
|
280
305
|
const openLink = useCallback((url: string) => {
|
|
281
306
|
void rendererHost.openExternal(url);
|
|
282
307
|
}, [rendererHost]);
|
|
@@ -307,9 +332,9 @@ function ArticleRichContent({
|
|
|
307
332
|
))}
|
|
308
333
|
</Box>
|
|
309
334
|
);
|
|
310
|
-
}
|
|
335
|
+
});
|
|
311
336
|
|
|
312
|
-
export function ArticleDetail({
|
|
337
|
+
export const ArticleDetail = memo(function ArticleDetail({
|
|
313
338
|
article,
|
|
314
339
|
detail,
|
|
315
340
|
width,
|
|
@@ -331,8 +356,7 @@ export function ArticleDetail({
|
|
|
331
356
|
const blocks = resolved?.contentBlocks ?? [];
|
|
332
357
|
const fallbackImageUrls = resolved?.imageUrls.length ? resolved.imageUrls : article.imageUrls;
|
|
333
358
|
const lineWidth = Math.max(1, width - 2);
|
|
334
|
-
const imageWidth =
|
|
335
|
-
const imageHeight = Math.max(6, Math.min(14, Math.floor(imageWidth * 0.32)));
|
|
359
|
+
const { width: imageWidth, height: imageHeight } = resolveArticleImageSize(lineWidth);
|
|
336
360
|
|
|
337
361
|
return (
|
|
338
362
|
<ScrollBox ref={scrollRef} scrollY focusable={false} flexGrow={1} paddingX={1}>
|
|
@@ -356,4 +380,4 @@ export function ArticleDetail({
|
|
|
356
380
|
</Box>
|
|
357
381
|
</ScrollBox>
|
|
358
382
|
);
|
|
359
|
-
}
|
|
383
|
+
});
|
|
@@ -24,6 +24,7 @@ export function SubstackArticleStack({
|
|
|
24
24
|
detailOpen,
|
|
25
25
|
selectedArticle,
|
|
26
26
|
selectedArticleId,
|
|
27
|
+
readArticleIds,
|
|
27
28
|
detailContent,
|
|
28
29
|
activePublication,
|
|
29
30
|
activeFeedState,
|
|
@@ -45,6 +46,7 @@ export function SubstackArticleStack({
|
|
|
45
46
|
detailOpen: boolean;
|
|
46
47
|
selectedArticle: SubstackArticleSummary | null;
|
|
47
48
|
selectedArticleId: string | null;
|
|
49
|
+
readArticleIds: ReadonlySet<string>;
|
|
48
50
|
detailContent: ReactNode;
|
|
49
51
|
activePublication: SubstackPublication | null;
|
|
50
52
|
activeFeedState: ActiveFeedState;
|
|
@@ -78,7 +80,9 @@ export function SubstackArticleStack({
|
|
|
78
80
|
return {
|
|
79
81
|
text: article.title,
|
|
80
82
|
color: selectedColor ?? colors.text,
|
|
81
|
-
attributes:
|
|
83
|
+
attributes: readArticleIds.has(article.id)
|
|
84
|
+
? TextAttributes.NONE
|
|
85
|
+
: TextAttributes.BOLD,
|
|
82
86
|
};
|
|
83
87
|
case "read":
|
|
84
88
|
return {
|
|
@@ -86,7 +90,7 @@ export function SubstackArticleStack({
|
|
|
86
90
|
color: selectedColor ?? colors.textDim,
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
|
-
}, []);
|
|
93
|
+
}, [readArticleIds]);
|
|
90
94
|
|
|
91
95
|
const bodyAfter = activePublication && sortedRows.length > 0 && (activeFeedState.loading || activeFeedState.loadingMore) ? (
|
|
92
96
|
<Box height={1} paddingX={1}>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
1
2
|
import { Box } from "../../../ui";
|
|
2
3
|
import { Tabs } from "../../../components";
|
|
3
4
|
import { tabIdForPublication } from "./table";
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
} from "./types";
|
|
8
9
|
import { tabLabel } from "./pane-state";
|
|
9
10
|
|
|
10
|
-
export function SubstackFeedTabs({
|
|
11
|
+
export const SubstackFeedTabs = memo(function SubstackFeedTabs({
|
|
11
12
|
subscriptions,
|
|
12
13
|
activeTab,
|
|
13
14
|
focused,
|
|
@@ -20,16 +21,18 @@ export function SubstackFeedTabs({
|
|
|
20
21
|
detailOpen: boolean;
|
|
21
22
|
onSelect: (tabId: string) => void;
|
|
22
23
|
}) {
|
|
24
|
+
const tabs = useMemo(() => [
|
|
25
|
+
{ label: "Feed", value: SUBSTACK_FEED_TAB_ID },
|
|
26
|
+
...subscriptions.map((publication) => ({
|
|
27
|
+
label: tabLabel(publication.name),
|
|
28
|
+
value: tabIdForPublication(publication),
|
|
29
|
+
})),
|
|
30
|
+
], [subscriptions]);
|
|
31
|
+
|
|
23
32
|
return (
|
|
24
33
|
<Box height={1}>
|
|
25
34
|
<Tabs
|
|
26
|
-
tabs={
|
|
27
|
-
{ label: "Feed", value: SUBSTACK_FEED_TAB_ID },
|
|
28
|
-
...subscriptions.map((publication) => ({
|
|
29
|
-
label: tabLabel(publication.name),
|
|
30
|
-
value: tabIdForPublication(publication),
|
|
31
|
-
})),
|
|
32
|
-
]}
|
|
35
|
+
tabs={tabs}
|
|
33
36
|
activeValue={activeTab}
|
|
34
37
|
onSelect={onSelect}
|
|
35
38
|
compact
|
|
@@ -38,4 +41,4 @@ export function SubstackFeedTabs({
|
|
|
38
41
|
/>
|
|
39
42
|
</Box>
|
|
40
43
|
);
|
|
41
|
-
}
|
|
44
|
+
});
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
type LoadState,
|
|
59
59
|
type PublicationFeedState,
|
|
60
60
|
} from "./pane-state";
|
|
61
|
+
import { useSubstackReadState } from "./read-state";
|
|
61
62
|
|
|
62
63
|
const PUBLICATION_LOAD_MORE_THRESHOLD_ROWS = 8;
|
|
63
64
|
|
|
@@ -74,6 +75,7 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
|
|
|
74
75
|
columnId: "published",
|
|
75
76
|
direction: "desc",
|
|
76
77
|
});
|
|
78
|
+
const { readArticleIds, markArticleRead } = useSubstackReadState();
|
|
77
79
|
const homeFetchGenRef = useRef(0);
|
|
78
80
|
const publicationFetchGenRef = useRef<Record<string, number>>({});
|
|
79
81
|
const detailFetchGenRef = useRef(0);
|
|
@@ -206,12 +208,12 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
|
|
|
206
208
|
loadPublication(activePublication, false);
|
|
207
209
|
}, [activePublication, activePublicationTabId, loadPublication, publicationFeeds]);
|
|
208
210
|
|
|
209
|
-
const activeFeedState = activeFeedStateFromSources({
|
|
211
|
+
const activeFeedState = useMemo(() => activeFeedStateFromSources({
|
|
210
212
|
activePublication,
|
|
211
213
|
activePublicationTabId,
|
|
212
214
|
publicationFeeds,
|
|
213
215
|
home,
|
|
214
|
-
});
|
|
216
|
+
}), [activePublication, activePublicationTabId, home, publicationFeeds]);
|
|
215
217
|
|
|
216
218
|
const loadMoreActivePublicationRows = useCallback(() => {
|
|
217
219
|
if (!activePublication || !activePublicationTabId || detailOpen) return;
|
|
@@ -335,8 +337,9 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
|
|
|
335
337
|
|
|
336
338
|
const openSelectedArticle = useCallback(() => {
|
|
337
339
|
if (!selectedArticle?.url) return;
|
|
340
|
+
markArticleRead(selectedArticle.id);
|
|
338
341
|
void rendererHost.openExternal(selectedArticle.url);
|
|
339
|
-
}, [rendererHost, selectedArticle]);
|
|
342
|
+
}, [markArticleRead, rendererHost, selectedArticle]);
|
|
340
343
|
|
|
341
344
|
const handleLogin = useCallback((nextAuth: SubstackAuthState) => {
|
|
342
345
|
setAuth(nextAuth);
|
|
@@ -477,7 +480,9 @@ export function SubstackPane({ focused, width, height }: PaneProps) {
|
|
|
477
480
|
selectedArticle={selectedArticle}
|
|
478
481
|
detailContent={detailContent}
|
|
479
482
|
selectedArticleId={selectedArticleId}
|
|
483
|
+
readArticleIds={readArticleIds}
|
|
480
484
|
onActivate={(article) => {
|
|
485
|
+
markArticleRead(article.id);
|
|
481
486
|
setSelectedArticleId(article.id, { immediate: true });
|
|
482
487
|
setDetailOpen(true);
|
|
483
488
|
}}
|