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.
Files changed (85) hide show
  1. package/README.md +9 -7
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. 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 [activeTabId, setActiveTabId] = usePaneStateValue<string>("activeTabId", "overview");
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
- return new Response(response.body, {
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: response.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
  }