gloomberb 0.7.0 → 0.7.2
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/package.json +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type DataTableColumn,
|
|
8
8
|
type DataTableKeyEvent,
|
|
9
9
|
} from "../../../components";
|
|
10
|
-
import type { AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
|
|
10
|
+
import type { AnalystRatingRecord, AnalystResearchData, CorporateActionsData, TickerFinancials } from "../../../types/financials";
|
|
11
11
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
12
12
|
import { usePaneInstance, usePaneTicker } from "../../../state/app-context";
|
|
13
13
|
import { blendHex, colors, priceColor } from "../../../theme/colors";
|
|
@@ -110,6 +110,32 @@ function ratingActionColor(action: string | undefined): string {
|
|
|
110
110
|
return colors.textDim;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function formatPriceTarget(value: number | undefined, currency: string): string {
|
|
114
|
+
if (value == null) return "-";
|
|
115
|
+
return formatCurrency(value, currency)
|
|
116
|
+
.replace(/\.00\b/, "")
|
|
117
|
+
.replace(/(\.\d)0\b/, "$1");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatRatingTarget(row: AnalystResearchData["ratings"][number], currency: string): string {
|
|
121
|
+
const current = row.currentPriceTarget;
|
|
122
|
+
const prior = row.priorPriceTarget;
|
|
123
|
+
if (current == null && prior == null) return "-";
|
|
124
|
+
if (current == null) return ` ${formatPriceTarget(prior, currency)}`;
|
|
125
|
+
if (prior == null) return ` ${formatPriceTarget(current, currency)}`;
|
|
126
|
+
return ` ${formatPriceTarget(prior, currency)} → ${formatPriceTarget(current, currency)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ratingTargetDelta(row: AnalystResearchData["ratings"][number]): number | null {
|
|
130
|
+
if (row.currentPriceTarget == null || row.priorPriceTarget == null) return null;
|
|
131
|
+
return row.currentPriceTarget - row.priorPriceTarget;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ratingTargetBackground(delta: number | null): string | undefined {
|
|
135
|
+
if (delta == null || delta === 0) return undefined;
|
|
136
|
+
return blendHex(colors.bg, delta > 0 ? colors.positive : colors.negative, 0.42);
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
function AnalystSummary({ data, width }: { data: AnalystResearchData | null; width: number }) {
|
|
114
140
|
const target = data?.priceTarget;
|
|
115
141
|
const upside = targetUpside(target);
|
|
@@ -170,26 +196,135 @@ function AnalystSummary({ data, width }: { data: AnalystResearchData | null; wid
|
|
|
170
196
|
);
|
|
171
197
|
}
|
|
172
198
|
|
|
173
|
-
type RatingColumnId = "date" | "firm" | "action" | "current" | "prior";
|
|
199
|
+
export type RatingColumnId = "date" | "firm" | "action" | "current" | "target" | "prior";
|
|
174
200
|
type RatingColumn = DataTableColumn & { id: RatingColumnId };
|
|
201
|
+
export type SortDirection = "asc" | "desc";
|
|
202
|
+
|
|
203
|
+
export interface RatingSortPreference {
|
|
204
|
+
columnId: RatingColumnId;
|
|
205
|
+
direction: SortDirection;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const DEFAULT_RATING_SORT: RatingSortPreference = {
|
|
209
|
+
columnId: "date",
|
|
210
|
+
direction: "desc",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const DEFAULT_RATING_SORT_DIRECTIONS: Record<RatingColumnId, SortDirection> = {
|
|
214
|
+
date: "desc",
|
|
215
|
+
firm: "asc",
|
|
216
|
+
action: "asc",
|
|
217
|
+
current: "asc",
|
|
218
|
+
target: "desc",
|
|
219
|
+
prior: "asc",
|
|
220
|
+
};
|
|
175
221
|
|
|
176
222
|
const RATING_COLUMNS: RatingColumn[] = [
|
|
177
223
|
{ id: "date", label: "DATE", width: 10, align: "left" },
|
|
178
|
-
{ id: "firm", label: "FIRM", width:
|
|
179
|
-
{ id: "action", label: "ACTION", width:
|
|
180
|
-
{ id: "current", label: "RATING", width:
|
|
181
|
-
{ id: "
|
|
224
|
+
{ id: "firm", label: "FIRM", width: 20, align: "left" },
|
|
225
|
+
{ id: "action", label: "ACTION", width: 10, align: "left" },
|
|
226
|
+
{ id: "current", label: "RATING", width: 13, align: "left" },
|
|
227
|
+
{ id: "target", label: "TARGET", width: 13, align: "left" },
|
|
228
|
+
{ id: "prior", label: "PRIOR", width: 13, align: "left" },
|
|
182
229
|
];
|
|
183
230
|
|
|
231
|
+
function normalizedText(value: string | undefined): string | null {
|
|
232
|
+
const trimmed = value?.trim();
|
|
233
|
+
return trimmed ? trimmed.toLocaleLowerCase() : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ratingTargetSortValue(row: AnalystRatingRecord): number | null {
|
|
237
|
+
return row.currentPriceTarget ?? row.priorPriceTarget ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ratingSortValue(row: AnalystRatingRecord, columnId: RatingColumnId): string | number | null {
|
|
241
|
+
switch (columnId) {
|
|
242
|
+
case "date":
|
|
243
|
+
return normalizedText(row.date);
|
|
244
|
+
case "firm":
|
|
245
|
+
return normalizedText(row.firm);
|
|
246
|
+
case "action":
|
|
247
|
+
return normalizedText(row.action);
|
|
248
|
+
case "current":
|
|
249
|
+
return normalizedText(row.current);
|
|
250
|
+
case "target":
|
|
251
|
+
return ratingTargetSortValue(row);
|
|
252
|
+
case "prior":
|
|
253
|
+
return normalizedText(row.prior);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function compareSortValues(
|
|
258
|
+
left: string | number | null,
|
|
259
|
+
right: string | number | null,
|
|
260
|
+
direction: SortDirection,
|
|
261
|
+
): number {
|
|
262
|
+
if (left == null && right == null) return 0;
|
|
263
|
+
if (left == null) return 1;
|
|
264
|
+
if (right == null) return -1;
|
|
265
|
+
|
|
266
|
+
const comparison = typeof left === "string" && typeof right === "string"
|
|
267
|
+
? left.localeCompare(right)
|
|
268
|
+
: Number(left) - Number(right);
|
|
269
|
+
return direction === "asc" ? comparison : -comparison;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function sortRatingRows<T extends AnalystRatingRecord>(
|
|
273
|
+
rows: readonly T[],
|
|
274
|
+
preference: RatingSortPreference,
|
|
275
|
+
): T[] {
|
|
276
|
+
return rows
|
|
277
|
+
.map((row, index) => ({ row, index }))
|
|
278
|
+
.sort((left, right) => {
|
|
279
|
+
const primary = compareSortValues(
|
|
280
|
+
ratingSortValue(left.row, preference.columnId),
|
|
281
|
+
ratingSortValue(right.row, preference.columnId),
|
|
282
|
+
preference.direction,
|
|
283
|
+
);
|
|
284
|
+
if (primary !== 0) return primary;
|
|
285
|
+
|
|
286
|
+
const dateTieBreak = preference.columnId === "date"
|
|
287
|
+
? 0
|
|
288
|
+
: compareSortValues(
|
|
289
|
+
ratingSortValue(left.row, "date"),
|
|
290
|
+
ratingSortValue(right.row, "date"),
|
|
291
|
+
"desc",
|
|
292
|
+
);
|
|
293
|
+
if (dateTieBreak !== 0) return dateTieBreak;
|
|
294
|
+
|
|
295
|
+
return left.index - right.index;
|
|
296
|
+
})
|
|
297
|
+
.map((entry) => entry.row);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function nextRatingSortPreference(
|
|
301
|
+
current: RatingSortPreference,
|
|
302
|
+
columnId: string,
|
|
303
|
+
): RatingSortPreference {
|
|
304
|
+
const typedColumnId = columnId as RatingColumnId;
|
|
305
|
+
if (current.columnId !== typedColumnId) {
|
|
306
|
+
return {
|
|
307
|
+
columnId: typedColumnId,
|
|
308
|
+
direction: DEFAULT_RATING_SORT_DIRECTIONS[typedColumnId] ?? "asc",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
columnId: typedColumnId,
|
|
313
|
+
direction: current.direction === "asc" ? "desc" : "asc",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
184
317
|
function AnalystResearchView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
|
|
185
318
|
const dataProvider = useAssetData();
|
|
186
319
|
const { symbol, exchange } = useSymbolBinding();
|
|
320
|
+
const [sortPreference, setSortPreference] = useState<RatingSortPreference>(DEFAULT_RATING_SORT);
|
|
187
321
|
const loader = useCallback((nextSymbol: string, nextExchange: string, forceRefresh: boolean) => {
|
|
188
322
|
if (!dataProvider?.getAnalystResearch) throw new Error("Analyst data unavailable");
|
|
189
323
|
return dataProvider.getAnalystResearch(nextSymbol, nextExchange, forceRefresh ? { cacheMode: "refresh" } : undefined);
|
|
190
324
|
}, [dataProvider]);
|
|
191
325
|
const { data, loading, error, reload } = useTickerRequest<AnalystResearchData>(loader, symbol, exchange);
|
|
192
|
-
const rows = data?.ratings ?? [];
|
|
326
|
+
const rows = useMemo(() => sortRatingRows(data?.ratings ?? [], sortPreference), [data?.ratings, sortPreference]);
|
|
327
|
+
const ratingCurrency = data?.priceTarget?.currency ?? data?.currency ?? "USD";
|
|
193
328
|
|
|
194
329
|
const renderCell = useCallback((
|
|
195
330
|
row: AnalystResearchData["ratings"][number],
|
|
@@ -207,10 +342,20 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
|
|
|
207
342
|
return { text: row.action ?? "-", color: selectedColor ?? ratingActionColor(row.action) };
|
|
208
343
|
case "current":
|
|
209
344
|
return { text: row.current ?? "-", color: selectedColor ?? colors.text };
|
|
345
|
+
case "target": {
|
|
346
|
+
const delta = ratingTargetDelta(row);
|
|
347
|
+
const hasTarget = row.currentPriceTarget != null || row.priorPriceTarget != null;
|
|
348
|
+
return {
|
|
349
|
+
text: formatRatingTarget(row, ratingCurrency),
|
|
350
|
+
color: selectedColor ?? (hasTarget ? colors.textBright : colors.textDim),
|
|
351
|
+
backgroundColor: rowState.selected ? undefined : ratingTargetBackground(delta),
|
|
352
|
+
attributes: hasTarget ? TextAttributes.BOLD : undefined,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
210
355
|
case "prior":
|
|
211
356
|
return { text: row.prior ?? "-", color: selectedColor ?? colors.textDim };
|
|
212
357
|
}
|
|
213
|
-
}, []);
|
|
358
|
+
}, [ratingCurrency]);
|
|
214
359
|
|
|
215
360
|
const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
216
361
|
if (event.name !== "r") return false;
|
|
@@ -219,6 +364,9 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
|
|
|
219
364
|
reload();
|
|
220
365
|
return true;
|
|
221
366
|
}, [reload]);
|
|
367
|
+
const handleHeaderClick = useCallback((columnId: string) => {
|
|
368
|
+
setSortPreference((current) => nextRatingSortPreference(current, columnId));
|
|
369
|
+
}, []);
|
|
222
370
|
|
|
223
371
|
usePaneFooter("analyst-research", () => ({
|
|
224
372
|
info: [
|
|
@@ -237,15 +385,14 @@ function AnalystResearchView({ focused, width, height }: { focused: boolean; wid
|
|
|
237
385
|
onRootKeyDown={handleKeyDown}
|
|
238
386
|
columns={RATING_COLUMNS}
|
|
239
387
|
items={rows}
|
|
240
|
-
sortColumnId={
|
|
241
|
-
sortDirection=
|
|
242
|
-
onHeaderClick={
|
|
388
|
+
sortColumnId={sortPreference.columnId}
|
|
389
|
+
sortDirection={sortPreference.direction}
|
|
390
|
+
onHeaderClick={handleHeaderClick}
|
|
243
391
|
getItemKey={(row, index) => `${row.date}:${row.firm}:${index}`}
|
|
244
392
|
isSelected={() => false}
|
|
245
393
|
onSelect={() => {}}
|
|
246
394
|
renderCell={renderCell}
|
|
247
395
|
emptyStateTitle={loading ? "Loading analyst data..." : error ?? "No analyst data"}
|
|
248
|
-
showHorizontalScrollbar={false}
|
|
249
396
|
/>
|
|
250
397
|
);
|
|
251
398
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box } from "../../../ui";
|
|
2
2
|
import { useShortcut } from "../../../react/input";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import type { DetailTabDef, PaneProps } from "../../../types/plugin";
|
|
5
5
|
import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types";
|
|
6
6
|
import {
|
|
@@ -33,6 +33,29 @@ function sameStringSet(left: Set<string>, right: Set<string>): boolean {
|
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function registryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
|
|
37
|
+
if (!registry) return "";
|
|
38
|
+
return [...registry.detailTabs.values()]
|
|
39
|
+
.map((tab) => `${tab.id}:${tab.name}:${tab.order}:${registry.getDetailTabPluginId?.(tab.id) ?? ""}`)
|
|
40
|
+
.join("\0");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function useRegistryDetailTabsSnapshot(registry: ReturnType<typeof getSharedRegistry>): string {
|
|
44
|
+
const subscribe = useCallback((onStoreChange: () => void) => {
|
|
45
|
+
const events = registry?.events;
|
|
46
|
+
if (!events) return () => {};
|
|
47
|
+
const unregisterRegistered = events.on("plugin:registered", onStoreChange);
|
|
48
|
+
const unregisterUnregistered = events.on("plugin:unregistered", onStoreChange);
|
|
49
|
+
return () => {
|
|
50
|
+
unregisterRegistered();
|
|
51
|
+
unregisterUnregistered();
|
|
52
|
+
};
|
|
53
|
+
}, [registry]);
|
|
54
|
+
|
|
55
|
+
const getSnapshot = useCallback(() => registryDetailTabsSnapshot(registry), [registry]);
|
|
56
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
37
60
|
const dispatch = useAppDispatch();
|
|
38
61
|
const config = useAppSelector((state) => state.config);
|
|
@@ -54,6 +77,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
54
77
|
|
|
55
78
|
const disabledPlugins = config.disabledPlugins;
|
|
56
79
|
const registry = getSharedRegistry();
|
|
80
|
+
const detailTabsSnapshot = useRegistryDetailTabsSnapshot(registry);
|
|
57
81
|
const pluginTabs = useMemo<DetailTabDef[]>(() => (
|
|
58
82
|
registry
|
|
59
83
|
? [...registry.detailTabs.values()].filter((tab) => {
|
|
@@ -61,7 +85,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
61
85
|
return !ownerId || !disabledPlugins.includes(ownerId);
|
|
62
86
|
})
|
|
63
87
|
: []
|
|
64
|
-
), [disabledPlugins, registry]);
|
|
88
|
+
), [disabledPlugins, registry, detailTabsSnapshot]);
|
|
65
89
|
const allTabs = buildVisibleDetailTabs(pluginTabs, ticker, financials, {
|
|
66
90
|
config,
|
|
67
91
|
hasOptionsChain,
|
|
@@ -84,26 +108,17 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
84
108
|
return next;
|
|
85
109
|
}, [mountedTabIds, resolvedTabId, visibleTabIds]);
|
|
86
110
|
|
|
87
|
-
const allTabsRef = useRef(allTabs);
|
|
88
|
-
allTabsRef.current = allTabs;
|
|
89
|
-
|
|
90
111
|
const stateRef = useRef({
|
|
91
112
|
focused,
|
|
92
113
|
chartInteractive,
|
|
93
114
|
pluginCaptured,
|
|
94
115
|
activeTabId: resolvedTabId,
|
|
95
|
-
tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
|
|
96
|
-
allTabCount: allTabs.length,
|
|
97
|
-
hideTabs: paneSettings.hideTabs,
|
|
98
116
|
});
|
|
99
117
|
stateRef.current = {
|
|
100
118
|
focused,
|
|
101
119
|
chartInteractive,
|
|
102
120
|
pluginCaptured,
|
|
103
121
|
activeTabId: resolvedTabId,
|
|
104
|
-
tabIdx: Math.max(0, allTabs.findIndex((tab) => tab.id === resolvedTabId)),
|
|
105
|
-
allTabCount: allTabs.length,
|
|
106
|
-
hideTabs: paneSettings.hideTabs,
|
|
107
122
|
};
|
|
108
123
|
|
|
109
124
|
const setChartInteractiveEager = useCallback((value: boolean) => {
|
|
@@ -162,17 +177,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
162
177
|
if (currentState.chartInteractive) return;
|
|
163
178
|
}
|
|
164
179
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const tabs = allTabsRef.current;
|
|
168
|
-
if (event.name === "h" || event.name === "left") {
|
|
169
|
-
const nextIndex = Math.max(currentState.tabIdx - 1, 0);
|
|
170
|
-
setActiveTabId(tabs[nextIndex]!.id);
|
|
171
|
-
} else if (event.name === "l" || event.name === "right") {
|
|
172
|
-
const nextIndex = Math.min(currentState.tabIdx + 1, currentState.allTabCount - 1);
|
|
173
|
-
setActiveTabId(tabs[nextIndex]!.id);
|
|
174
|
-
}
|
|
175
|
-
}, [setActiveTabId, setChartInteractiveEager]);
|
|
180
|
+
}, [setChartInteractiveEager]);
|
|
176
181
|
|
|
177
182
|
useShortcut(handleKeyboard);
|
|
178
183
|
|
|
@@ -196,6 +201,7 @@ export function TickerDetailPane({ focused, width, height }: PaneProps) {
|
|
|
196
201
|
tabs={allTabs.map((tab) => ({ label: tab.name, value: tab.id }))}
|
|
197
202
|
activeValue={resolvedTabId}
|
|
198
203
|
onSelect={setActiveTabId}
|
|
204
|
+
focused={focused && !pluginCaptured && !(resolvedTabId === "chart" && chartInteractive)}
|
|
199
205
|
/>
|
|
200
206
|
)}
|
|
201
207
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type BrokerInstanceConfig,
|
|
20
20
|
} from "../../types/config";
|
|
21
21
|
import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
|
|
22
|
+
import { EventBus } from "../event-bus";
|
|
22
23
|
import type { DataProvider } from "../../types/data-provider";
|
|
23
24
|
import type { TickerFinancials } from "../../types/financials";
|
|
24
25
|
import type { DetailTabDef } from "../../types/plugin";
|
|
@@ -543,6 +544,43 @@ describe("TickerDetailPane", () => {
|
|
|
543
544
|
expect(frame).not.toContain("SEC");
|
|
544
545
|
});
|
|
545
546
|
|
|
547
|
+
test("refreshes plugin tabs when registration completes after the pane mounted", async () => {
|
|
548
|
+
const detailTabs = new Map<string, DetailTabDef>();
|
|
549
|
+
const events = new EventBus();
|
|
550
|
+
setSharedRegistryForTests({
|
|
551
|
+
detailTabs,
|
|
552
|
+
events,
|
|
553
|
+
getDetailTabPluginId: () => "company-research",
|
|
554
|
+
} as unknown as PluginRegistry);
|
|
555
|
+
setOptionsProvider(createProvider(false));
|
|
556
|
+
|
|
557
|
+
testSetup = await testRender(
|
|
558
|
+
<DetailHarness
|
|
559
|
+
config={createDetailConfig("AAPL")}
|
|
560
|
+
ticker={makeTicker("AAPL")}
|
|
561
|
+
financials={null}
|
|
562
|
+
/>,
|
|
563
|
+
{ width: 90, height: 24 },
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
await flushFrame();
|
|
567
|
+
expect(testSetup.captureCharFrame()).not.toContain("Analyst");
|
|
568
|
+
|
|
569
|
+
await act(async () => {
|
|
570
|
+
detailTabs.set("analyst-research", {
|
|
571
|
+
id: "analyst-research",
|
|
572
|
+
name: "Analyst",
|
|
573
|
+
order: 32,
|
|
574
|
+
component: () => <text>Analyst body</text>,
|
|
575
|
+
isVisible: ({ ticker }) => !!ticker,
|
|
576
|
+
});
|
|
577
|
+
events.emit("plugin:registered", { pluginId: "company-research" });
|
|
578
|
+
});
|
|
579
|
+
await flushFrame();
|
|
580
|
+
|
|
581
|
+
expect(testSetup.captureCharFrame()).toContain("Analyst");
|
|
582
|
+
});
|
|
583
|
+
|
|
546
584
|
test("passes visible tab content height to plugin tabs", async () => {
|
|
547
585
|
let receivedHeight: number | null = null;
|
|
548
586
|
const probeTab: DetailTabDef["component"] = ({ height }) => {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
usePluginPaneActions,
|
|
21
21
|
usePluginPaneState,
|
|
22
22
|
usePluginState,
|
|
23
|
+
usePluginTickerActions,
|
|
23
24
|
useSetPluginConfigStates,
|
|
24
25
|
} from "./plugin-runtime";
|
|
25
26
|
|
|
@@ -244,6 +245,39 @@ describe("plugin runtime hooks", () => {
|
|
|
244
245
|
]);
|
|
245
246
|
});
|
|
246
247
|
|
|
248
|
+
test("scopes ticker navigation to the rendering pane", async () => {
|
|
249
|
+
const calls: string[] = [];
|
|
250
|
+
let actions: ReturnType<typeof usePluginTickerActions> | null = null;
|
|
251
|
+
|
|
252
|
+
const runtime = createTestPluginRuntime({
|
|
253
|
+
navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }) {
|
|
254
|
+
calls.push(`${symbol}:${options?.sourcePaneId ?? ""}`);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
function HookProbe() {
|
|
259
|
+
actions = usePluginTickerActions();
|
|
260
|
+
return <text>ticker-actions</text>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
testSetup = await testRender(
|
|
264
|
+
<PaneInstanceProvider paneId="comparison-chart:main">
|
|
265
|
+
<PluginRenderProvider pluginId="comparison-chart" runtime={runtime}>
|
|
266
|
+
<HookProbe />
|
|
267
|
+
</PluginRenderProvider>
|
|
268
|
+
</PaneInstanceProvider>,
|
|
269
|
+
{ width: 40, height: 5 },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
await act(async () => {
|
|
273
|
+
await testSetup!.renderOnce();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
actions?.navigateTicker("MSFT");
|
|
277
|
+
|
|
278
|
+
expect(calls).toEqual(["MSFT:comparison-chart:main"]);
|
|
279
|
+
});
|
|
280
|
+
|
|
247
281
|
test("exposes renderer pane actions through the plugin hook", async () => {
|
|
248
282
|
const calls: string[] = [];
|
|
249
283
|
let actions: ReturnType<typeof usePluginPaneActions> | null = null;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useAppDispatch,
|
|
14
14
|
useAppSelector,
|
|
15
15
|
useAppStateRef,
|
|
16
|
+
useOptionalPaneInstanceId,
|
|
16
17
|
usePaneInstanceId,
|
|
17
18
|
type PaneRuntimeState,
|
|
18
19
|
} from "../state/app-context";
|
|
@@ -30,7 +31,7 @@ export interface PluginRuntimeAccess {
|
|
|
30
31
|
syncBrokerInstance(instanceId: string): Promise<void>;
|
|
31
32
|
removeBrokerInstance(instanceId: string): Promise<void>;
|
|
32
33
|
pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
|
|
33
|
-
navigateTicker(symbol: string): void;
|
|
34
|
+
navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
|
|
34
35
|
selectTicker(symbol: string, paneId?: string): void;
|
|
35
36
|
switchTab(tabId: string, paneId?: string): void;
|
|
36
37
|
switchPanel(panel: "left" | "right"): void;
|
|
@@ -85,9 +86,13 @@ function usePluginRenderContext(): PluginRenderContextValue {
|
|
|
85
86
|
|
|
86
87
|
export function usePluginTickerActions() {
|
|
87
88
|
const { runtime } = usePluginRenderContext();
|
|
89
|
+
const sourcePaneId = useOptionalPaneInstanceId();
|
|
90
|
+
const navigateTicker = useCallback((symbol: string) => {
|
|
91
|
+
runtime.navigateTicker(symbol, { sourcePaneId });
|
|
92
|
+
}, [runtime, sourcePaneId]);
|
|
88
93
|
return {
|
|
89
94
|
pinTicker: runtime.pinTicker,
|
|
90
|
-
navigateTicker
|
|
95
|
+
navigateTicker,
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -11,7 +11,6 @@ import { getAdjacentPredictionCategoryId } from "./categories";
|
|
|
11
11
|
import { usePredictionMarketsDataState } from "./controller-data";
|
|
12
12
|
import { resolvePredictionKeyboardCommand } from "./keyboard";
|
|
13
13
|
import {
|
|
14
|
-
getAdjacentPredictionDetailTab,
|
|
15
14
|
getAdjacentPredictionVenueScope,
|
|
16
15
|
parsePredictionSearchShortcut,
|
|
17
16
|
parsePredictionVenueScope,
|
|
@@ -520,20 +519,6 @@ export function usePredictionMarketsController({
|
|
|
520
519
|
return;
|
|
521
520
|
}
|
|
522
521
|
|
|
523
|
-
if (command === "previous-category") {
|
|
524
|
-
event.stopPropagation?.();
|
|
525
|
-
event.preventDefault?.();
|
|
526
|
-
setDetailTab(getAdjacentPredictionDetailTab(detailTab, "previous"));
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (command === "next-category") {
|
|
531
|
-
event.stopPropagation?.();
|
|
532
|
-
event.preventDefault?.();
|
|
533
|
-
setDetailTab(getAdjacentPredictionDetailTab(detailTab, "next"));
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
522
|
return;
|
|
538
523
|
}
|
|
539
524
|
|
package/src/plugins/registry.ts
CHANGED
|
@@ -141,7 +141,7 @@ export class PluginRegistry implements PluginRuntimeAccess {
|
|
|
141
141
|
hidePaneFn: ((paneId: string) => void) = () => {};
|
|
142
142
|
focusPaneFn: ((paneId: string) => void) = () => {};
|
|
143
143
|
pinTickerFn: ((symbol: string, options?: { floating?: boolean; paneType?: string }) => void) = () => {};
|
|
144
|
-
navigateTickerFn: ((symbol: string) => void) = () => {};
|
|
144
|
+
navigateTickerFn: ((symbol: string, options?: { sourcePaneId?: string | null }) => void) = () => {};
|
|
145
145
|
getMarketData = () => this.marketData;
|
|
146
146
|
getCapability = (capabilityId: string) => this.capabilities.get(capabilityId)?.capability ?? null;
|
|
147
147
|
getBrokerAdapter = (brokerType: string) => this.brokersMap.get(brokerType) ?? null;
|
|
@@ -154,8 +154,8 @@ export class PluginRegistry implements PluginRuntimeAccess {
|
|
|
154
154
|
pinTicker = (symbol: string, options?: { floating?: boolean; paneType?: string }) => {
|
|
155
155
|
this.pinTickerFn(symbol, options);
|
|
156
156
|
};
|
|
157
|
-
navigateTicker = (symbol: string) => {
|
|
158
|
-
this.navigateTickerFn(symbol);
|
|
157
|
+
navigateTicker = (symbol: string, options?: { sourcePaneId?: string | null }) => {
|
|
158
|
+
this.navigateTickerFn(symbol, options);
|
|
159
159
|
};
|
|
160
160
|
selectTicker = (symbol: string, paneId?: string) => {
|
|
161
161
|
this.selectTickerFn(symbol, paneId);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
|
|
3
|
+
import {
|
|
4
|
+
resolveTickerNavigationDetailPane,
|
|
5
|
+
shouldFocusTickerNavigationTarget,
|
|
6
|
+
} from "./ticker-navigation";
|
|
7
|
+
|
|
8
|
+
function createLayout(instances: PaneInstanceConfig[]): LayoutConfig {
|
|
9
|
+
return {
|
|
10
|
+
dockRoot: { kind: "pane", instanceId: instances[0]?.instanceId ?? "" },
|
|
11
|
+
instances,
|
|
12
|
+
floating: instances.slice(1).map((instance, index) => ({
|
|
13
|
+
instanceId: instance.instanceId,
|
|
14
|
+
x: 2 + index,
|
|
15
|
+
y: 2 + index,
|
|
16
|
+
width: 40,
|
|
17
|
+
height: 12,
|
|
18
|
+
})),
|
|
19
|
+
detached: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveTickerNavigationDetailPane", () => {
|
|
24
|
+
test("prefers the detail pane following the pane that opened ticker navigation", () => {
|
|
25
|
+
const layout = createLayout([
|
|
26
|
+
createPaneInstance("portfolio-list", {
|
|
27
|
+
instanceId: "portfolio-list:main",
|
|
28
|
+
binding: { kind: "none" },
|
|
29
|
+
}),
|
|
30
|
+
createPaneInstance("comparison-chart", {
|
|
31
|
+
instanceId: "comparison-chart:main",
|
|
32
|
+
binding: { kind: "none" },
|
|
33
|
+
}),
|
|
34
|
+
createPaneInstance("ticker-detail", {
|
|
35
|
+
instanceId: "ticker-detail:portfolio",
|
|
36
|
+
binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
|
|
37
|
+
}),
|
|
38
|
+
createPaneInstance("ticker-detail", {
|
|
39
|
+
instanceId: "ticker-detail:compare",
|
|
40
|
+
binding: { kind: "follow", sourceInstanceId: "comparison-chart:main" },
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
resolveTickerNavigationDetailPane(layout, "comparison-chart:main")?.instanceId,
|
|
46
|
+
).toBe("ticker-detail:compare");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("shouldFocusTickerNavigationTarget", () => {
|
|
51
|
+
test("does not focus stale async ticker navigation after the user moves to another pane", () => {
|
|
52
|
+
expect(shouldFocusTickerNavigationTarget({
|
|
53
|
+
sourcePaneId: "comparison-chart:main",
|
|
54
|
+
currentFocusedPaneId: "portfolio-list:main",
|
|
55
|
+
targetPaneId: "ticker-detail:compare",
|
|
56
|
+
})).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("keeps focusing when the source pane still owns the latest interaction", () => {
|
|
60
|
+
expect(shouldFocusTickerNavigationTarget({
|
|
61
|
+
sourcePaneId: "comparison-chart:main",
|
|
62
|
+
currentFocusedPaneId: "comparison-chart:main",
|
|
63
|
+
targetPaneId: "ticker-detail:compare",
|
|
64
|
+
})).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { findPaneInstance, type LayoutConfig, type PaneInstanceConfig } from "../types/config";
|
|
2
|
+
import { isPaneInLayout } from "./pane-manager";
|
|
3
|
+
|
|
4
|
+
export function resolveTickerNavigationDetailPane(
|
|
5
|
+
layout: LayoutConfig,
|
|
6
|
+
sourcePaneId: string | null,
|
|
7
|
+
): PaneInstanceConfig | null {
|
|
8
|
+
const sourceInstance = sourcePaneId ? findPaneInstance(layout, sourcePaneId) : null;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
sourceInstance?.paneId === "ticker-detail" && isPaneInLayout(layout, sourceInstance.instanceId)
|
|
12
|
+
? sourceInstance
|
|
13
|
+
: null
|
|
14
|
+
)
|
|
15
|
+
?? layout.instances.find((instance) =>
|
|
16
|
+
instance.paneId === "ticker-detail"
|
|
17
|
+
&& instance.binding?.kind === "follow"
|
|
18
|
+
&& instance.binding.sourceInstanceId === sourcePaneId
|
|
19
|
+
&& isPaneInLayout(layout, instance.instanceId)
|
|
20
|
+
)
|
|
21
|
+
?? layout.instances.find((instance) =>
|
|
22
|
+
instance.paneId === "ticker-detail"
|
|
23
|
+
&& instance.binding?.kind === "follow"
|
|
24
|
+
&& isPaneInLayout(layout, instance.instanceId)
|
|
25
|
+
)
|
|
26
|
+
?? layout.instances.find((instance) =>
|
|
27
|
+
instance.paneId === "ticker-detail"
|
|
28
|
+
&& isPaneInLayout(layout, instance.instanceId)
|
|
29
|
+
)
|
|
30
|
+
?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shouldFocusTickerNavigationTarget({
|
|
34
|
+
sourcePaneId,
|
|
35
|
+
currentFocusedPaneId,
|
|
36
|
+
targetPaneId,
|
|
37
|
+
}: {
|
|
38
|
+
sourcePaneId: string | null;
|
|
39
|
+
currentFocusedPaneId: string | null;
|
|
40
|
+
targetPaneId: string | null;
|
|
41
|
+
}): boolean {
|
|
42
|
+
if (!sourcePaneId) return true;
|
|
43
|
+
return currentFocusedPaneId === sourcePaneId || currentFocusedPaneId === targetPaneId;
|
|
44
|
+
}
|