gloomberb 0.9.1 → 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/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
- 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 +56 -6
- 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/detached-pane-shell.tsx +5 -2
- package/src/components/layout/header.tsx +13 -5
- 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-controls.tsx +19 -10
- 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 +183 -0
- package/src/core/app-services.ts +7 -4
- package/src/data/config/store/index.ts +1 -2
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- 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 +218 -15
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- 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/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- 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/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +22 -2
- 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/shared/protocol.ts +1 -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/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- 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
|
@@ -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
|
}}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { usePluginState } from "../../runtime";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_READ_IDS,
|
|
5
|
+
markReadId,
|
|
6
|
+
normalizeReadIds,
|
|
7
|
+
sameStringArray,
|
|
8
|
+
} from "../shared/read-state";
|
|
9
|
+
|
|
10
|
+
export interface SubstackReadState {
|
|
11
|
+
articleIds: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SUBSTACK_READ_STATE_SCHEMA_VERSION = 1;
|
|
15
|
+
const READ_STATE_KEY = "read-articles";
|
|
16
|
+
const EMPTY_READ_STATE: SubstackReadState = { articleIds: [] };
|
|
17
|
+
|
|
18
|
+
export const MAX_READ_SUBSTACK_ARTICLE_IDS = DEFAULT_MAX_READ_IDS;
|
|
19
|
+
|
|
20
|
+
export function normalizeSubstackReadState(state: SubstackReadState): SubstackReadState {
|
|
21
|
+
return {
|
|
22
|
+
articleIds: normalizeReadIds(state.articleIds, MAX_READ_SUBSTACK_ARTICLE_IDS),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function markSubstackArticleRead(
|
|
27
|
+
state: SubstackReadState,
|
|
28
|
+
articleId: string,
|
|
29
|
+
): SubstackReadState {
|
|
30
|
+
const current = normalizeSubstackReadState(state).articleIds;
|
|
31
|
+
const articleIds = markReadId(current, articleId, MAX_READ_SUBSTACK_ARTICLE_IDS);
|
|
32
|
+
|
|
33
|
+
return sameStringArray(articleIds, state.articleIds) ? state : { articleIds };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useSubstackReadState() {
|
|
37
|
+
const [readState, setReadState] = usePluginState<SubstackReadState>(
|
|
38
|
+
READ_STATE_KEY,
|
|
39
|
+
EMPTY_READ_STATE,
|
|
40
|
+
{ schemaVersion: SUBSTACK_READ_STATE_SCHEMA_VERSION },
|
|
41
|
+
);
|
|
42
|
+
const normalizedReadState = useMemo(
|
|
43
|
+
() => normalizeSubstackReadState(readState),
|
|
44
|
+
[readState],
|
|
45
|
+
);
|
|
46
|
+
const readArticleIds = useMemo(
|
|
47
|
+
() => new Set(normalizedReadState.articleIds),
|
|
48
|
+
[normalizedReadState],
|
|
49
|
+
);
|
|
50
|
+
const markArticleRead = useCallback((articleId: string) => {
|
|
51
|
+
setReadState((current) => markSubstackArticleRead(current, articleId));
|
|
52
|
+
}, [setReadState]);
|
|
53
|
+
|
|
54
|
+
return { readArticleIds, markArticleRead };
|
|
55
|
+
}
|
|
@@ -224,6 +224,32 @@ describe("ThirteenFPane", () => {
|
|
|
224
224
|
).toBeUndefined();
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
test("moves from the first fund row to search with Up", async () => {
|
|
228
|
+
setHttpFetchTransport(async (url) => {
|
|
229
|
+
const path = new URL(String(url)).pathname;
|
|
230
|
+
if (path.endsWith("/topfunds")) {
|
|
231
|
+
return json([
|
|
232
|
+
{ cik: "1", name: "Alpha Capital", period_of_report: "2026-03-31", pnl: null },
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
return json([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
testSetup = await testRender(<Harness />, { width: 100, height: 20 });
|
|
240
|
+
});
|
|
241
|
+
await renderFrames();
|
|
242
|
+
|
|
243
|
+
await emitKeypress({ name: "up", sequence: "\u001B[A" });
|
|
244
|
+
await act(async () => {
|
|
245
|
+
await testSetup!.mockInput.typeText("BRK");
|
|
246
|
+
await testSetup!.renderOnce();
|
|
247
|
+
});
|
|
248
|
+
await renderFrames(2);
|
|
249
|
+
|
|
250
|
+
expect(testSetup!.captureCharFrame()).toContain("BRK");
|
|
251
|
+
});
|
|
252
|
+
|
|
227
253
|
test("rapid keyboard navigation activates the current rendered row", async () => {
|
|
228
254
|
setHttpFetchTransport(async (url) => {
|
|
229
255
|
const path = new URL(String(url)).pathname;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Tabs,
|
|
16
16
|
usePaneFooter,
|
|
17
17
|
type DataTableKeyEvent,
|
|
18
|
+
type DataTableRootKeyContext,
|
|
18
19
|
} from "../../../components";
|
|
19
20
|
import { useShortcut } from "../../../react/input";
|
|
20
21
|
import { usePaneSettingValue } from "../../../state/app/context";
|
|
@@ -23,6 +24,7 @@ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
|
|
|
23
24
|
import type { PaneProps } from "../../../types/plugin";
|
|
24
25
|
import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
|
|
25
26
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
27
|
+
import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation";
|
|
26
28
|
import { truncateWithEllipsis } from "../../../utils/text-wrap";
|
|
27
29
|
import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
|
|
28
30
|
import { loadBrowserRows, loadFilingPositions, loadFundDetail } from "./data";
|
|
@@ -303,6 +305,7 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
|
|
|
303
305
|
normalizeValue={trimSearchValue}
|
|
304
306
|
onFocus={focusSearch}
|
|
305
307
|
onBlur={blurSearch}
|
|
308
|
+
onNavigateDown={blurSearch}
|
|
306
309
|
onQueryChange={updateQuery}
|
|
307
310
|
/>
|
|
308
311
|
</Box>
|
|
@@ -312,7 +315,15 @@ export function ThirteenFPane({ focused, width, height }: PaneProps) {
|
|
|
312
315
|
? "Loading 13F funds..."
|
|
313
316
|
: error ?? warning ?? "No 13F funds found.";
|
|
314
317
|
|
|
315
|
-
const handleRootKeyDown = useCallback((
|
|
318
|
+
const handleRootKeyDown = useCallback((
|
|
319
|
+
event: DataTableKeyEvent,
|
|
320
|
+
context: DataTableRootKeyContext,
|
|
321
|
+
) => {
|
|
322
|
+
if (context.selectedIndex <= 0 && isPlainArrowUp(event)) {
|
|
323
|
+
stopSearchFocusNavigation(event);
|
|
324
|
+
focusSearch();
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
316
327
|
if (event.name === "r") {
|
|
317
328
|
event.preventDefault?.();
|
|
318
329
|
event.stopPropagation?.();
|
|
@@ -90,20 +90,6 @@ export function buildOverviewStats({
|
|
|
90
90
|
valueColor: priceColor(fundamentals.revenueGrowth),
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
-
if (fundamentals?.return1Y != null) {
|
|
94
|
-
stats.push({
|
|
95
|
-
label: "1Y Return",
|
|
96
|
-
value: formatPercent(fundamentals.return1Y),
|
|
97
|
-
valueColor: priceColor(fundamentals.return1Y),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
if (fundamentals?.return3Y != null) {
|
|
101
|
-
stats.push({
|
|
102
|
-
label: "3Y Return",
|
|
103
|
-
value: formatPercent(fundamentals.return3Y),
|
|
104
|
-
valueColor: priceColor(fundamentals.return3Y),
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
93
|
if (fundamentals?.enterpriseValue) {
|
|
108
94
|
stats.push({ label: "EV", value: formatCompact(fundamentals.enterpriseValue) });
|
|
109
95
|
}
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
|
|
16
16
|
import { EmptyState } from "../../../components";
|
|
17
17
|
import { ResolvedStockChart } from "../../../components/chart/stock-chart";
|
|
18
|
+
import { PriceReturnStrip } from "../../../components/price-performance";
|
|
19
|
+
import {
|
|
20
|
+
appendQuoteToPriceReturnHistory,
|
|
21
|
+
buildPriceReturnFields,
|
|
22
|
+
} from "../../../market-data/performance";
|
|
18
23
|
import type { TickerFinancials } from "../../../types/financials";
|
|
19
24
|
import type { TickerRecord } from "../../../types/ticker";
|
|
20
25
|
import {
|
|
@@ -96,6 +101,19 @@ export function OverviewTab({
|
|
|
96
101
|
baseCurrency,
|
|
97
102
|
toBase,
|
|
98
103
|
});
|
|
104
|
+
const performanceFields = buildPriceReturnFields(
|
|
105
|
+
appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], quote),
|
|
106
|
+
).map((field) => {
|
|
107
|
+
if (field.value != null) return field;
|
|
108
|
+
if (field.id === "1Y" && fundamentals?.return1Y != null) {
|
|
109
|
+
return { ...field, value: fundamentals.return1Y };
|
|
110
|
+
}
|
|
111
|
+
if (field.id === "3Y" && fundamentals?.return3Y != null) {
|
|
112
|
+
return { ...field, value: fundamentals.return3Y };
|
|
113
|
+
}
|
|
114
|
+
return field;
|
|
115
|
+
});
|
|
116
|
+
const hasPerformance = performanceFields.some((field) => field.value != null);
|
|
99
117
|
const positionRows = buildPositionRows({
|
|
100
118
|
ticker,
|
|
101
119
|
quote,
|
|
@@ -210,6 +228,13 @@ export function OverviewTab({
|
|
|
210
228
|
/>
|
|
211
229
|
)}
|
|
212
230
|
|
|
231
|
+
{hasPerformance && (
|
|
232
|
+
<Box flexDirection="column">
|
|
233
|
+
<SectionHeader title="Price Return" />
|
|
234
|
+
<PriceReturnStrip fields={performanceFields} width={contentWidth} />
|
|
235
|
+
</Box>
|
|
236
|
+
)}
|
|
237
|
+
|
|
213
238
|
{stats.length > 0 && (
|
|
214
239
|
<Box flexDirection="column">
|
|
215
240
|
<SectionHeader title="Fundamentals" />
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
findDockLeaf,
|
|
7
7
|
getNodeAtPath,
|
|
8
8
|
replaceNodeAtPath,
|
|
9
|
+
type DockGeometryOptions,
|
|
9
10
|
type DockTarget,
|
|
10
11
|
type LayoutBounds,
|
|
11
12
|
} from "./dock-tree";
|
|
@@ -116,11 +117,17 @@ export function applyDrop(layout: LayoutConfig, draggedId: string, dropTarget: D
|
|
|
116
117
|
return insertRelativeToLeaf(layout, draggedId, dropTarget.targetId, direction);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
export function simulateDrop(
|
|
120
|
+
export function simulateDrop(
|
|
121
|
+
layout: LayoutConfig,
|
|
122
|
+
draggedId: string,
|
|
123
|
+
dropTarget: DropTarget,
|
|
124
|
+
bounds: LayoutBounds,
|
|
125
|
+
options?: DockGeometryOptions,
|
|
126
|
+
): LayoutSimulation {
|
|
120
127
|
const nextLayout = applyDrop(layout, draggedId, dropTarget);
|
|
121
128
|
return {
|
|
122
129
|
layout: nextLayout,
|
|
123
|
-
previewRect: getLeafRect(nextLayout, draggedId, bounds),
|
|
130
|
+
previewRect: getLeafRect(nextLayout, draggedId, bounds, options),
|
|
124
131
|
};
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
countColumnsFromGeometry,
|
|
11
11
|
findDockLeaf,
|
|
12
12
|
getDockLeafLayouts,
|
|
13
|
+
type DockGeometryOptions,
|
|
13
14
|
type LayoutBounds,
|
|
14
15
|
} from "./dock-tree";
|
|
15
16
|
|
|
@@ -20,8 +21,13 @@ export interface ResolvedPane {
|
|
|
20
21
|
path?: Array<0 | 1>;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
export function getLeafRect(
|
|
24
|
-
|
|
24
|
+
export function getLeafRect(
|
|
25
|
+
layout: LayoutConfig,
|
|
26
|
+
instanceId: string,
|
|
27
|
+
bounds: LayoutBounds,
|
|
28
|
+
options?: DockGeometryOptions,
|
|
29
|
+
): LayoutBounds | null {
|
|
30
|
+
return getDockLeafLayouts(layout, bounds, options).find((entry) => entry.instanceId === instanceId)?.rect ?? null;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export function resolveDocked(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { type InputRenderable, type ScrollBoxRenderable } from "../../../ui";
|
|
3
3
|
import { useViewport } from "../../../react/input";
|
|
4
|
+
import { useAppInputCapture } from "../../../state/app/input-capture";
|
|
4
5
|
import { usePaneInstance } from "../../../state/app/context";
|
|
5
6
|
import {
|
|
6
7
|
useDebouncedPluginPaneState,
|
|
@@ -101,6 +102,7 @@ export function usePredictionMarketsController({
|
|
|
101
102
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
102
103
|
const [initialParamsApplied, setInitialParamsApplied] = useState(false);
|
|
103
104
|
const appViewport = useViewport();
|
|
105
|
+
useAppInputCapture(focused && searchFocused);
|
|
104
106
|
|
|
105
107
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
106
108
|
const headerScrollRef = useRef<ScrollBoxRenderable>(null);
|