gloomberb 0.8.2 → 0.9.0
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 +9 -7
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/src/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SubstackArticleSummary,
|
|
3
|
+
SubstackColumn,
|
|
4
|
+
SubstackPublication,
|
|
5
|
+
SubstackSortColumnId,
|
|
6
|
+
SubstackSortDirection,
|
|
7
|
+
} from "./types";
|
|
8
|
+
import { publicationKey } from "./normalize";
|
|
9
|
+
import { timestamp } from "./utils";
|
|
10
|
+
|
|
11
|
+
export function isSubstackSortColumnId(value: string): value is SubstackSortColumnId {
|
|
12
|
+
return value === "published" || value === "publication" || value === "title" || value === "read";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function nextSubstackSort(
|
|
16
|
+
current: { columnId: SubstackSortColumnId; direction: SubstackSortDirection },
|
|
17
|
+
columnId: SubstackSortColumnId,
|
|
18
|
+
): { columnId: SubstackSortColumnId; direction: SubstackSortDirection } {
|
|
19
|
+
if (current.columnId === columnId) {
|
|
20
|
+
return { columnId, direction: current.direction === "desc" ? "asc" : "desc" };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
columnId,
|
|
24
|
+
direction: columnId === "title" || columnId === "publication" ? "asc" : "desc",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sortedSubstackArticles(
|
|
29
|
+
articles: SubstackArticleSummary[],
|
|
30
|
+
sort: { columnId: SubstackSortColumnId; direction: SubstackSortDirection },
|
|
31
|
+
): SubstackArticleSummary[] {
|
|
32
|
+
const direction = sort.direction === "asc" ? 1 : -1;
|
|
33
|
+
return [...articles].sort((a, b) => {
|
|
34
|
+
let result = 0;
|
|
35
|
+
switch (sort.columnId) {
|
|
36
|
+
case "published":
|
|
37
|
+
result = timestamp(a.publishedAt) - timestamp(b.publishedAt);
|
|
38
|
+
break;
|
|
39
|
+
case "publication":
|
|
40
|
+
result = (a.publicationName ?? "").localeCompare(b.publicationName ?? "");
|
|
41
|
+
break;
|
|
42
|
+
case "title":
|
|
43
|
+
result = a.title.localeCompare(b.title);
|
|
44
|
+
break;
|
|
45
|
+
case "read":
|
|
46
|
+
result = a.readMinutes - b.readMinutes;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return result === 0 ? timestamp(b.publishedAt) - timestamp(a.publishedAt) : result * direction;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatPublishedAt(value: string | null): string {
|
|
54
|
+
if (!value) return "-";
|
|
55
|
+
const date = new Date(value);
|
|
56
|
+
if (Number.isNaN(date.getTime())) return "-";
|
|
57
|
+
const now = new Date();
|
|
58
|
+
const sameYear = date.getFullYear() === now.getFullYear();
|
|
59
|
+
const datePart = date.toLocaleDateString("en-US", {
|
|
60
|
+
month: "short",
|
|
61
|
+
day: "numeric",
|
|
62
|
+
...(sameYear ? {} : { year: "2-digit" as const }),
|
|
63
|
+
});
|
|
64
|
+
const timePart = date.toLocaleTimeString("en-US", {
|
|
65
|
+
hour: "2-digit",
|
|
66
|
+
minute: "2-digit",
|
|
67
|
+
hour12: false,
|
|
68
|
+
});
|
|
69
|
+
return `${datePart} ${timePart}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatReadTime(minutes: number): string {
|
|
73
|
+
return `${Math.max(1, Math.ceil(minutes))}m`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatWordCount(words: number): string {
|
|
77
|
+
if (words >= 1000) return `${(words / 1000).toFixed(words >= 10_000 ? 0 : 1)}k words`;
|
|
78
|
+
return `${Math.max(0, Math.round(words))} words`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildSubstackColumns(width: number, includePublication: boolean): SubstackColumn[] {
|
|
82
|
+
const publishedWidth = 13;
|
|
83
|
+
const readWidth = 5;
|
|
84
|
+
const publicationWidth = includePublication ? Math.max(12, Math.min(21, Math.floor(width * 0.18))) : 0;
|
|
85
|
+
const fixed = publishedWidth + readWidth + publicationWidth;
|
|
86
|
+
const titleWidth = Math.max(18, width - fixed - 4);
|
|
87
|
+
const columns: SubstackColumn[] = [
|
|
88
|
+
{ id: "published", label: "Published", width: publishedWidth, align: "left" },
|
|
89
|
+
];
|
|
90
|
+
if (includePublication) {
|
|
91
|
+
columns.push({ id: "publication", label: "Publication", width: publicationWidth, align: "left" });
|
|
92
|
+
}
|
|
93
|
+
columns.push(
|
|
94
|
+
{ id: "title", label: "Title", width: titleWidth, align: "left", flexGrow: 1 },
|
|
95
|
+
{ id: "read", label: "Read", width: readWidth, align: "right" },
|
|
96
|
+
);
|
|
97
|
+
return columns;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function tabIdForPublication(publication: SubstackPublication): string {
|
|
101
|
+
return `pub:${publicationKey(publication)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function publicationFromTabId(
|
|
105
|
+
tabId: string,
|
|
106
|
+
publications: SubstackPublication[],
|
|
107
|
+
): SubstackPublication | null {
|
|
108
|
+
if (!tabId.startsWith("pub:")) return null;
|
|
109
|
+
const key = tabId.slice(4);
|
|
110
|
+
return publications.find((publication) => publicationKey(publication) === key) ?? null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { DataTableColumn } from "../../../components";
|
|
2
|
+
|
|
3
|
+
export const SUBSTACK_PANE_ID = "substack";
|
|
4
|
+
export const SUBSTACK_PLUGIN_ID = "substack";
|
|
5
|
+
export const SUBSTACK_FEED_TAB_ID = "feed";
|
|
6
|
+
|
|
7
|
+
export interface SubstackPublication {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
subdomain: string | null;
|
|
11
|
+
baseUrl: string | null;
|
|
12
|
+
description: string | null;
|
|
13
|
+
logoUrl: string | null;
|
|
14
|
+
latestPublishedAt: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExtractedArticleContent {
|
|
18
|
+
text: string;
|
|
19
|
+
blocks: SubstackContentBlock[];
|
|
20
|
+
imageUrls: string[];
|
|
21
|
+
linkUrls: string[];
|
|
22
|
+
wordCount: number;
|
|
23
|
+
readMinutes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SubstackContentBlock =
|
|
27
|
+
| { type: "heading"; text: string; level: number }
|
|
28
|
+
| { type: "paragraph"; text: string }
|
|
29
|
+
| { type: "quote"; text: string }
|
|
30
|
+
| { type: "listItem"; text: string }
|
|
31
|
+
| { type: "image"; url: string; alt: string | null }
|
|
32
|
+
| {
|
|
33
|
+
type: "embed";
|
|
34
|
+
kind: "tweet";
|
|
35
|
+
text: string;
|
|
36
|
+
url: string | null;
|
|
37
|
+
username: string | null;
|
|
38
|
+
authorName: string | null;
|
|
39
|
+
dateLabel: string | null;
|
|
40
|
+
imageUrls: string[];
|
|
41
|
+
}
|
|
42
|
+
| { type: "embed"; kind: "link" | "media"; text: string; url: string | null }
|
|
43
|
+
| { type: "divider" };
|
|
44
|
+
|
|
45
|
+
export interface SubstackArticleSummary {
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
publicationId: string | null;
|
|
49
|
+
publicationName: string | null;
|
|
50
|
+
publicationSubdomain: string | null;
|
|
51
|
+
publicationBaseUrl: string | null;
|
|
52
|
+
url: string | null;
|
|
53
|
+
slug: string | null;
|
|
54
|
+
publishedAt: string | null;
|
|
55
|
+
subtitle: string | null;
|
|
56
|
+
previewText: string | null;
|
|
57
|
+
bodyHtml: string | null;
|
|
58
|
+
imageUrls: string[];
|
|
59
|
+
wordCount: number;
|
|
60
|
+
readMinutes: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SubstackArticleDetail extends SubstackArticleSummary {
|
|
64
|
+
contentText: string;
|
|
65
|
+
contentBlocks: SubstackContentBlock[];
|
|
66
|
+
linkUrls: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SubstackSortColumnId = "published" | "publication" | "title" | "read";
|
|
70
|
+
export type SubstackSortDirection = "asc" | "desc";
|
|
71
|
+
export type SubstackColumn = DataTableColumn & { id: SubstackSortColumnId };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { decodeHtmlEntities } from "../../../utils/html-entities";
|
|
2
|
+
import { normalizedHttpUrl } from "../../../utils/url";
|
|
3
|
+
|
|
4
|
+
export type JsonRecord = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function asRecord(value: unknown): JsonRecord | null {
|
|
11
|
+
return isRecord(value) ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stringValue(value: unknown): string | null {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const trimmed = decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
|
|
17
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rawStringValue(value: unknown): string | null {
|
|
21
|
+
if (typeof value !== "string") return null;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function numberValue(value: unknown): number | null {
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
28
|
+
if (typeof value === "string" && value.trim()) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function firstString(record: JsonRecord | null, keys: string[]): string | null {
|
|
36
|
+
if (!record) return null;
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
const value = stringValue(record[key]);
|
|
39
|
+
if (value) return value;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function firstRawString(record: JsonRecord | null, keys: string[]): string | null {
|
|
45
|
+
if (!record) return null;
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const value = rawStringValue(record[key]);
|
|
48
|
+
if (value) return value;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function firstNumber(record: JsonRecord | null, keys: string[]): number | null {
|
|
54
|
+
if (!record) return null;
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const value = numberValue(record[key]);
|
|
57
|
+
if (value != null) return value;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function firstValue(record: JsonRecord | null, keys: string[]): unknown {
|
|
63
|
+
if (!record) return null;
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
if (record[key] != null) return record[key];
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function firstRecord(...values: unknown[]): JsonRecord | null {
|
|
71
|
+
for (const value of values) {
|
|
72
|
+
const record = asRecord(value);
|
|
73
|
+
if (record) return record;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseDateIso(value: unknown): string | null {
|
|
79
|
+
if (value == null) return null;
|
|
80
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
81
|
+
const ms = value > 10_000_000_000 ? value : value * 1000;
|
|
82
|
+
const date = new Date(ms);
|
|
83
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
84
|
+
}
|
|
85
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
86
|
+
const date = new Date(value);
|
|
87
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeUrl(value: unknown, baseUrl?: string | null): string | null {
|
|
91
|
+
const raw = rawStringValue(value);
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
const direct = normalizedHttpUrl(raw);
|
|
94
|
+
if (direct) return direct;
|
|
95
|
+
if (!baseUrl) return null;
|
|
96
|
+
try {
|
|
97
|
+
const resolved = new URL(raw, baseUrl);
|
|
98
|
+
return normalizedHttpUrl(resolved.toString());
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
const result: string[] = [];
|
|
107
|
+
for (const value of values) {
|
|
108
|
+
if (!value || seen.has(value)) continue;
|
|
109
|
+
seen.add(value);
|
|
110
|
+
result.push(value);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function timestamp(value: string | null | undefined): number {
|
|
116
|
+
if (!value) return 0;
|
|
117
|
+
const date = new Date(value);
|
|
118
|
+
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function maxIso(a: string | null, b: string | null): string | null {
|
|
122
|
+
if (!a) return b;
|
|
123
|
+
if (!b) return a;
|
|
124
|
+
return timestamp(a) >= timestamp(b) ? a : b;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function extractAttribute(tag: string, name: string): string | null {
|
|
128
|
+
const pattern = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
|
|
129
|
+
const match = tag.match(pattern);
|
|
130
|
+
return rawStringValue(match?.[1] ?? match?.[2] ?? match?.[3] ?? null);
|
|
131
|
+
}
|
|
@@ -41,6 +41,7 @@ let detailHarnessState: ReturnType<typeof createInitialState> | null = null;
|
|
|
41
41
|
let sharedCoordinator: MarketDataCoordinator | null = null;
|
|
42
42
|
|
|
43
43
|
const runtime = createTestPluginRuntime();
|
|
44
|
+
const TICKER_TAB_SETTLE_MS = 160;
|
|
44
45
|
|
|
45
46
|
const DetailPane = tickerDetailPlugin.panes![0]!.component as (props: {
|
|
46
47
|
paneId: string;
|
|
@@ -160,7 +161,7 @@ function setOptionsProvider(provider: DataProvider | undefined): void {
|
|
|
160
161
|
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
function makeRegistry(): PluginRegistry {
|
|
164
|
+
function makeRegistry(options: { stubFundamentalGraphs?: boolean } = {}): PluginRegistry {
|
|
164
165
|
const stubTab = (_props: { width: number; height: number; focused: boolean; onCapture: (capturing: boolean) => void }) => (
|
|
165
166
|
<text>stub</text>
|
|
166
167
|
);
|
|
@@ -168,6 +169,10 @@ function makeRegistry(): PluginRegistry {
|
|
|
168
169
|
tickerDetailPlugin.setup?.({
|
|
169
170
|
registerTickerResearchTab: (tab: TickerResearchTabDef) => tickerResearchTabs.set(tab.id, tab),
|
|
170
171
|
} as any);
|
|
172
|
+
if (options.stubFundamentalGraphs) {
|
|
173
|
+
const tab = tickerResearchTabs.get("fundamental-graphs");
|
|
174
|
+
if (tab) tickerResearchTabs.set(tab.id, { ...tab, component: stubTab });
|
|
175
|
+
}
|
|
171
176
|
for (const tab of [
|
|
172
177
|
["ibkr-trade", {
|
|
173
178
|
id: "ibkr-trade",
|
|
@@ -280,6 +285,13 @@ async function flushFrame() {
|
|
|
280
285
|
});
|
|
281
286
|
}
|
|
282
287
|
|
|
288
|
+
async function settleTickerTabCommit() {
|
|
289
|
+
await act(async () => {
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, TICKER_TAB_SETTLE_MS));
|
|
291
|
+
await testSetup!.renderOnce();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
283
295
|
async function clickFrameText(text: string) {
|
|
284
296
|
const lines = testSetup!.captureCharFrame().split("\n");
|
|
285
297
|
const row = lines.findIndex((line) => line.includes(text));
|
|
@@ -482,7 +494,7 @@ describe("TickerResearchPane", () => {
|
|
|
482
494
|
});
|
|
483
495
|
|
|
484
496
|
test("lets ticker detail tab arrows leave the embedded financials tab", async () => {
|
|
485
|
-
setSharedRegistryForTests(makeRegistry());
|
|
497
|
+
setSharedRegistryForTests(makeRegistry({ stubFundamentalGraphs: true }));
|
|
486
498
|
setOptionsProvider(createProvider(false));
|
|
487
499
|
|
|
488
500
|
testSetup = await testRender(
|
|
@@ -506,6 +518,7 @@ describe("TickerResearchPane", () => {
|
|
|
506
518
|
});
|
|
507
519
|
await flushFrame();
|
|
508
520
|
|
|
521
|
+
await settleTickerTabCommit();
|
|
509
522
|
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("fundamental-graphs");
|
|
510
523
|
|
|
511
524
|
await act(async () => {
|
|
@@ -514,6 +527,7 @@ describe("TickerResearchPane", () => {
|
|
|
514
527
|
});
|
|
515
528
|
await flushFrame();
|
|
516
529
|
|
|
530
|
+
await settleTickerTabCommit();
|
|
517
531
|
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("financials");
|
|
518
532
|
});
|
|
519
533
|
|
|
@@ -146,6 +146,7 @@ export const tickerDetailPlugin: GloomPlugin = {
|
|
|
146
146
|
label: "Ticker Research",
|
|
147
147
|
description: "Open another research pane for the selected ticker or current collection",
|
|
148
148
|
keywords: ["new", "ticker", "research", "detail", "pane", "inspector"],
|
|
149
|
+
shortcut: { prefix: "T", argPlaceholder: "ticker", argKind: "ticker" },
|
|
149
150
|
canCreate: (context) => context.activeTicker !== null || context.activeCollectionId !== null,
|
|
150
151
|
createInstance: (context) => (
|
|
151
152
|
context.activeTicker
|
|
@@ -14,6 +14,7 @@ import { useQuoteStreaming } from "../../../state/hooks/quote-streaming";
|
|
|
14
14
|
import { getCollectionName, getCollectionTickerCount } from "../../../state/selectors";
|
|
15
15
|
import { getSharedRegistry } from "../../registry";
|
|
16
16
|
import { EmptyState, PaneFooterScope, Tabs } from "../../../components";
|
|
17
|
+
import { useThrottledCommitValue } from "../../../react/use-throttled-commit-value";
|
|
17
18
|
import { resolveOptionsTarget } from "../../../utils/options";
|
|
18
19
|
import {
|
|
19
20
|
buildVisibleTickerResearchTabs,
|
|
@@ -21,6 +22,8 @@ import {
|
|
|
21
22
|
resolveLockedTabId,
|
|
22
23
|
} from "./settings";
|
|
23
24
|
|
|
25
|
+
const TICKER_RESEARCH_TAB_COMMIT_DELAY_MS = 120;
|
|
26
|
+
|
|
24
27
|
function sameStringSet(left: Set<string>, right: Set<string>): boolean {
|
|
25
28
|
if (left.size !== right.size) return false;
|
|
26
29
|
for (const value of left) {
|
|
@@ -79,7 +82,16 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
|
|
|
79
82
|
useQuoteStreaming(streamingTargets);
|
|
80
83
|
|
|
81
84
|
const { collectionId } = usePaneCollection();
|
|
82
|
-
const [
|
|
85
|
+
const [committedActiveTabId, setCommittedActiveTabId] = usePaneStateValue<string>("activeTabId", "overview");
|
|
86
|
+
const {
|
|
87
|
+
value: activeTabId,
|
|
88
|
+
setValue: setActiveTabId,
|
|
89
|
+
} = useThrottledCommitValue(
|
|
90
|
+
committedActiveTabId,
|
|
91
|
+
setCommittedActiveTabId,
|
|
92
|
+
TICKER_RESEARCH_TAB_COMMIT_DELAY_MS,
|
|
93
|
+
{ commitPendingOnUnmount: true },
|
|
94
|
+
);
|
|
83
95
|
const [pluginCaptured, setPluginCaptured] = useState(false);
|
|
84
96
|
const [mountedTabIds, setMountedTabIds] = useState<Set<string>>(() => new Set());
|
|
85
97
|
const paneSettings = getTickerResearchPaneSettings(paneInstance?.settings);
|
|
@@ -2,6 +2,7 @@ import type { GloomPlugin } from "../types/plugin";
|
|
|
2
2
|
import { portfolioListPlugin } from "./builtin/portfolio-list";
|
|
3
3
|
import { newsPlugin } from "./builtin/news";
|
|
4
4
|
import { notesPlugin } from "./builtin/notes";
|
|
5
|
+
import { substackPlugin } from "./builtin/substack";
|
|
5
6
|
import { aiPlugin } from "./builtin/ai";
|
|
6
7
|
import { gloomberbCloudPlugin } from "./builtin/cloud";
|
|
7
8
|
import { changelogPlugin } from "./builtin/changelog";
|
|
@@ -26,6 +27,7 @@ export const uiBuiltinPlugins: GloomPlugin[] = [
|
|
|
26
27
|
ibkrPlugin,
|
|
27
28
|
layoutManagerPlugin,
|
|
28
29
|
newsPlugin,
|
|
30
|
+
substackPlugin,
|
|
29
31
|
notesPlugin,
|
|
30
32
|
aiPlugin,
|
|
31
33
|
changelogPlugin,
|
|
@@ -158,3 +158,14 @@ export function removePane(layout: LayoutConfig, instanceId: string): LayoutConf
|
|
|
158
158
|
[instanceId],
|
|
159
159
|
));
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
export function removeFloatingPanes(layout: LayoutConfig): LayoutConfig {
|
|
163
|
+
if (layout.floating.length === 0) return layout;
|
|
164
|
+
return finalizeLayout(removePaneInstances(
|
|
165
|
+
{
|
|
166
|
+
...layout,
|
|
167
|
+
floating: [],
|
|
168
|
+
},
|
|
169
|
+
layout.floating.map((entry) => entry.instanceId),
|
|
170
|
+
));
|
|
171
|
+
}
|
|
@@ -49,7 +49,7 @@ export {
|
|
|
49
49
|
swapPanes,
|
|
50
50
|
} from "./pane-manager/docking";
|
|
51
51
|
|
|
52
|
-
export { removePane } from "./pane-manager/layout-state";
|
|
52
|
+
export { removeFloatingPanes, removePane } from "./pane-manager/layout-state";
|
|
53
53
|
export { gridlockAllPanes } from "./pane-manager/gridlock";
|
|
54
54
|
export type {
|
|
55
55
|
DropTarget,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useThrottledCommitValue<T>(
|
|
4
|
+
committedValue: T,
|
|
5
|
+
commitValue: (value: T) => void,
|
|
6
|
+
delayMs: number,
|
|
7
|
+
options?: { commitPendingOnUnmount?: boolean },
|
|
8
|
+
) {
|
|
9
|
+
const [value, setValueState] = useState<T>(committedValue);
|
|
10
|
+
const valueRef = useRef<T>(committedValue);
|
|
11
|
+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
12
|
+
const hasPendingCommitRef = useRef(false);
|
|
13
|
+
const pendingValueRef = useRef<T>(committedValue);
|
|
14
|
+
const appliedValueRef = useRef<T>(committedValue);
|
|
15
|
+
const commitValueRef = useRef(commitValue);
|
|
16
|
+
const optionsRef = useRef(options);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
commitValueRef.current = commitValue;
|
|
20
|
+
}, [commitValue]);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
optionsRef.current = options;
|
|
24
|
+
}, [options]);
|
|
25
|
+
|
|
26
|
+
const clearPendingCommit = useCallback(() => {
|
|
27
|
+
if (commitTimerRef.current) {
|
|
28
|
+
clearTimeout(commitTimerRef.current);
|
|
29
|
+
commitTimerRef.current = null;
|
|
30
|
+
}
|
|
31
|
+
hasPendingCommitRef.current = false;
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const flushValue = useCallback((...args: [] | [T]) => {
|
|
35
|
+
const targetValue = args.length === 0 ? pendingValueRef.current : args[0];
|
|
36
|
+
clearPendingCommit();
|
|
37
|
+
pendingValueRef.current = targetValue;
|
|
38
|
+
valueRef.current = targetValue;
|
|
39
|
+
setValueState((current) => (Object.is(current, targetValue) ? current : targetValue));
|
|
40
|
+
|
|
41
|
+
if (Object.is(appliedValueRef.current, targetValue)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
appliedValueRef.current = targetValue;
|
|
46
|
+
commitValueRef.current(targetValue);
|
|
47
|
+
}, [clearPendingCommit]);
|
|
48
|
+
|
|
49
|
+
const replaceValue = useCallback((nextValue: T) => {
|
|
50
|
+
clearPendingCommit();
|
|
51
|
+
appliedValueRef.current = nextValue;
|
|
52
|
+
pendingValueRef.current = nextValue;
|
|
53
|
+
valueRef.current = nextValue;
|
|
54
|
+
setValueState((current) => (Object.is(current, nextValue) ? current : nextValue));
|
|
55
|
+
}, [clearPendingCommit]);
|
|
56
|
+
|
|
57
|
+
const setValue = useCallback((nextValue: T, options?: { immediate?: boolean }) => {
|
|
58
|
+
valueRef.current = nextValue;
|
|
59
|
+
pendingValueRef.current = nextValue;
|
|
60
|
+
setValueState((current) => (Object.is(current, nextValue) ? current : nextValue));
|
|
61
|
+
|
|
62
|
+
if (options?.immediate) {
|
|
63
|
+
flushValue(nextValue);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
clearPendingCommit();
|
|
68
|
+
|
|
69
|
+
if (Object.is(appliedValueRef.current, nextValue)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
hasPendingCommitRef.current = true;
|
|
74
|
+
commitTimerRef.current = setTimeout(() => {
|
|
75
|
+
flushValue();
|
|
76
|
+
}, delayMs);
|
|
77
|
+
}, [clearPendingCommit, delayMs, flushValue]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (hasPendingCommitRef.current && Object.is(committedValue, pendingValueRef.current)) {
|
|
81
|
+
clearPendingCommit();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!hasPendingCommitRef.current) {
|
|
85
|
+
appliedValueRef.current = committedValue;
|
|
86
|
+
pendingValueRef.current = committedValue;
|
|
87
|
+
valueRef.current = committedValue;
|
|
88
|
+
setValueState((current) => (Object.is(current, committedValue) ? current : committedValue));
|
|
89
|
+
}
|
|
90
|
+
}, [clearPendingCommit, committedValue]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => () => {
|
|
93
|
+
const pending = hasPendingCommitRef.current;
|
|
94
|
+
const pendingValue = pendingValueRef.current;
|
|
95
|
+
if (commitTimerRef.current) {
|
|
96
|
+
clearTimeout(commitTimerRef.current);
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
optionsRef.current?.commitPendingOnUnmount
|
|
100
|
+
&& pending
|
|
101
|
+
&& !Object.is(appliedValueRef.current, pendingValue)
|
|
102
|
+
) {
|
|
103
|
+
appliedValueRef.current = pendingValue;
|
|
104
|
+
commitValueRef.current(pendingValue);
|
|
105
|
+
}
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
value,
|
|
110
|
+
valueRef,
|
|
111
|
+
setValue,
|
|
112
|
+
flushValue,
|
|
113
|
+
replaceValue,
|
|
114
|
+
cancelPendingValue: clearPendingCommit,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleHttpFetch } from "./http-fetch";
|
|
3
|
+
|
|
4
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
server?.stop(true);
|
|
8
|
+
server = null;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("handleHttpFetch", () => {
|
|
12
|
+
test("preserves manual redirects and Set-Cookie headers", async () => {
|
|
13
|
+
server = Bun.serve({
|
|
14
|
+
port: 0,
|
|
15
|
+
fetch(request) {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === "/login") {
|
|
18
|
+
return new Response(null, {
|
|
19
|
+
status: 302,
|
|
20
|
+
headers: {
|
|
21
|
+
location: "/reader",
|
|
22
|
+
"set-cookie": "substack.sid=sid123; Path=/; HttpOnly",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (url.pathname === "/reader") {
|
|
27
|
+
return new Response("reader");
|
|
28
|
+
}
|
|
29
|
+
return new Response("not found", { status: 404 });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const response = await handleHttpFetch({
|
|
34
|
+
url: new URL("/login", server.url).toString(),
|
|
35
|
+
init: { redirect: "manual" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(response.status).toBe(302);
|
|
39
|
+
expect(response.headers.location).toBe("/reader");
|
|
40
|
+
expect(response.setCookie).toEqual(["substack.sid=sid123; Path=/; HttpOnly"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -26,6 +26,10 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
|
|
|
26
26
|
typeof init.method === "string" && init.method.trim().length > 0
|
|
27
27
|
? init.method.trim().toUpperCase()
|
|
28
28
|
: "GET";
|
|
29
|
+
const redirect =
|
|
30
|
+
init.redirect === "manual" || init.redirect === "error" || init.redirect === "follow"
|
|
31
|
+
? init.redirect
|
|
32
|
+
: undefined;
|
|
29
33
|
const body =
|
|
30
34
|
typeof init.body === "string" && method !== "GET" && method !== "HEAD"
|
|
31
35
|
? init.body
|
|
@@ -35,6 +39,7 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
|
|
|
35
39
|
method,
|
|
36
40
|
headers: normalizeHttpFetchHeaders(init.headers),
|
|
37
41
|
body,
|
|
42
|
+
redirect,
|
|
38
43
|
});
|
|
39
44
|
const responseHeaders: Record<string, string> = {};
|
|
40
45
|
response.headers.forEach((value, key) => {
|
|
@@ -61,12 +61,18 @@ async function electrobunHttpFetch(url: string, init?: RequestInit): Promise<Res
|
|
|
61
61
|
const requestPromise = requestBackendHttpFetch(url, init);
|
|
62
62
|
|
|
63
63
|
const response = await withAbort(requestPromise, init?.signal);
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const headers = createResponseHeaders(response.headers, response.setCookie);
|
|
65
|
+
const fetchResponse = new Response(response.body, {
|
|
66
66
|
status: response.status,
|
|
67
67
|
statusText: response.statusText,
|
|
68
|
-
headers
|
|
68
|
+
headers,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
Object.defineProperty(fetchResponse, "headers", {
|
|
72
|
+
value: headers,
|
|
73
|
+
configurable: true,
|
|
69
74
|
});
|
|
75
|
+
return fetchResponse;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
async function requestBackendHttpFetch(url: string, init?: RequestInit): Promise<ElectrobunHttpFetchResponse> {
|
|
@@ -76,6 +82,7 @@ async function requestBackendHttpFetch(url: string, init?: RequestInit): Promise
|
|
|
76
82
|
method: init?.method,
|
|
77
83
|
headers: normalizeHeaders(init?.headers),
|
|
78
84
|
body: await serializeBody(init?.body),
|
|
85
|
+
redirect: init?.redirect,
|
|
79
86
|
},
|
|
80
87
|
});
|
|
81
88
|
}
|