gloomberb 0.11.2 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/api-client/data.ts +5 -0
- package/src/api-client/index.ts +5 -0
- package/src/api-client/types.ts +24 -0
- package/src/capabilities/chart-series.ts +3 -2
- package/src/cli/commands/market.ts +2 -2
- package/src/cli/pane-functions/capabilities.ts +3 -3
- package/src/cli/registry.ts +0 -22
- package/src/cli/result.ts +7 -10
- package/src/components/data-table/export.test.ts +21 -0
- package/src/components/data-table/export.ts +21 -0
- package/src/components/ui/data-table/index.tsx +19 -2
- package/src/data/config/chart-settings.ts +4 -4
- package/src/layout-marketplace/gallery-terminal.tsx +126 -96
- package/src/layout-marketplace/gallery.test.tsx +65 -1
- package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
- package/src/plugins/builtin/chart-composer/settings.ts +2 -13
- package/src/plugins/builtin/composite-plugins.ts +2 -0
- package/src/plugins/builtin/earnings/index.tsx +1 -0
- package/src/plugins/builtin/econ/index.tsx +1 -0
- package/src/plugins/builtin/futures/index.tsx +1 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
- package/src/plugins/builtin/market-halts/index.tsx +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +1 -0
- package/src/plugins/builtin/options/analytics.test.ts +83 -0
- package/src/plugins/builtin/options/analytics.ts +110 -0
- package/src/plugins/builtin/options/index.tsx +17 -2
- package/src/plugins/builtin/options/table.ts +121 -51
- package/src/plugins/builtin/options/types.ts +22 -15
- package/src/plugins/builtin/options/view.test.tsx +24 -0
- package/src/plugins/builtin/options/view.tsx +91 -19
- package/src/plugins/builtin/plugin-module.test.ts +2 -4
- package/src/plugins/builtin/plugin-module.ts +0 -7
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/scanner/index.ts +2 -0
- package/src/plugins/builtin/sectors/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -0
- package/src/plugins/builtin/world-venue-map/index.ts +27 -0
- package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
- package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
- package/src/plugins/builtin/world-venue-map/model.ts +147 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
- package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
- package/src/plugins/catalog-backend.ts +6 -44
- package/src/plugins/catalog-browser.ts +2 -0
- package/src/plugins/registry/pane-settings.test.ts +53 -0
- package/src/plugins/registry/pane-settings.ts +36 -3
- package/src/renderers/browser/ui-host.tsx +11 -0
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
- package/src/renderers/electrobun/bun/index.ts +1 -0
- package/src/renderers/electrobun/shared/protocol.ts +4 -0
- package/src/renderers/electrobun/view/ui-host.tsx +3 -0
- package/src/renderers/opentui/host.tsx +4 -0
- package/src/state/pane-table-export-registry.ts +31 -0
- package/src/time-series/range.ts +12 -0
- package/src/time-series/resolution.ts +3 -15
- package/src/time-series/spec.ts +5 -5
- package/src/types/plugin.ts +2 -20
- package/src/ui/host.tsx +7 -0
- package/src/utils/csv.test.ts +13 -0
- package/src/utils/csv.ts +37 -0
- package/src/utils/save-text-file.test.ts +24 -0
- package/src/utils/save-text-file.ts +36 -0
package/README.md
CHANGED
|
@@ -280,6 +280,7 @@ The toolbar controls preset or exact date ranges, intervals from one minute thro
|
|
|
280
280
|
| `TBO` | TheBuildout infrastructure intelligence |
|
|
281
281
|
| `CG` | Congress trading disclosures |
|
|
282
282
|
| `WEI` | Global equity indices |
|
|
283
|
+
| `MAP` | Live world venue map with local market status and clocks |
|
|
283
284
|
| `FUT` | Front-month futures across index, rates, energy, metals, grains, and FX |
|
|
284
285
|
| `ECON` | Economic events and releases |
|
|
285
286
|
| `GC` | Yield curve |
|
package/package.json
CHANGED
package/src/api-client/data.ts
CHANGED
|
@@ -55,6 +55,7 @@ import type {
|
|
|
55
55
|
CloudPricePointPayload,
|
|
56
56
|
CloudQuotePayload,
|
|
57
57
|
CloudTweetSearchResponse,
|
|
58
|
+
CloudWorldVenueMapPayload,
|
|
58
59
|
CloudYieldPointPayload,
|
|
59
60
|
} from "./types";
|
|
60
61
|
|
|
@@ -94,6 +95,10 @@ export class CloudDataApi {
|
|
|
94
95
|
return this.postMarketBatch("/market/quotes/batch", targets, mode);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
async getCloudWorldVenues(): Promise<CloudMarketResponse<CloudWorldVenueMapPayload>> {
|
|
99
|
+
return this.request<CloudMarketResponse<CloudWorldVenueMapPayload>>("/market/venues");
|
|
100
|
+
}
|
|
101
|
+
|
|
97
102
|
async getCloudMarketScreener(
|
|
98
103
|
category: CloudMarketScreenerCategory,
|
|
99
104
|
count = 25,
|
package/src/api-client/index.ts
CHANGED
|
@@ -61,6 +61,7 @@ import type {
|
|
|
61
61
|
CloudMarketBatchPayload,
|
|
62
62
|
CloudMarketScreenerCategory,
|
|
63
63
|
CloudMarketScreenerPayload,
|
|
64
|
+
CloudWorldVenueMapPayload,
|
|
64
65
|
CloudVerificationResponse,
|
|
65
66
|
DeviceAuthStartResponse,
|
|
66
67
|
DeviceAuthTokenResponse,
|
|
@@ -545,6 +546,10 @@ class GloomApiClient {
|
|
|
545
546
|
return this.data.getCloudQuotesBatch(targets, mode);
|
|
546
547
|
}
|
|
547
548
|
|
|
549
|
+
async getCloudWorldVenues(): Promise<CloudMarketResponse<CloudWorldVenueMapPayload>> {
|
|
550
|
+
return this.data.getCloudWorldVenues();
|
|
551
|
+
}
|
|
552
|
+
|
|
548
553
|
async getCloudMarketScreener(
|
|
549
554
|
category: CloudMarketScreenerCategory,
|
|
550
555
|
count = 25,
|
package/src/api-client/types.ts
CHANGED
|
@@ -253,6 +253,30 @@ export interface CloudQuotePayload extends Quote {
|
|
|
253
253
|
dataSource: "live" | "delayed";
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
export interface CloudWorldVenuePayload {
|
|
257
|
+
mic: string;
|
|
258
|
+
name: string;
|
|
259
|
+
title: string;
|
|
260
|
+
country: string;
|
|
261
|
+
countryCode: string;
|
|
262
|
+
city: string;
|
|
263
|
+
timezone: string;
|
|
264
|
+
latitude: number;
|
|
265
|
+
longitude: number;
|
|
266
|
+
isOpen: boolean;
|
|
267
|
+
timeAfterOpenSeconds?: number;
|
|
268
|
+
timeToOpenSeconds?: number;
|
|
269
|
+
timeToCloseSeconds?: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface CloudWorldVenueMapPayload {
|
|
273
|
+
providerId: "gloomberb-cloud";
|
|
274
|
+
checkedAt: number;
|
|
275
|
+
refreshAt: number;
|
|
276
|
+
stale?: boolean;
|
|
277
|
+
venues: CloudWorldVenuePayload[];
|
|
278
|
+
}
|
|
279
|
+
|
|
256
280
|
export interface CloudOptionsChainPayload extends OptionsChain {
|
|
257
281
|
providerId: "gloomberb-cloud";
|
|
258
282
|
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SeriesTransform,
|
|
10
10
|
TimeSeriesPoint,
|
|
11
11
|
} from "../time-series/types";
|
|
12
|
+
import { CHART_RESOLUTIONS, TIME_RANGES } from "../time-series/range";
|
|
12
13
|
import type {
|
|
13
14
|
CapabilityInvoker,
|
|
14
15
|
CapabilitySchema,
|
|
@@ -24,8 +25,8 @@ export const MAX_CHART_SERIES_POINTS = 20_000;
|
|
|
24
25
|
const CAPABILITY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
25
26
|
const SERIES_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~%:/-]{0,239}$/;
|
|
26
27
|
const chartSeriesLog = debugLog.createLogger("chart-series");
|
|
27
|
-
const ranges = new Set(
|
|
28
|
-
const resolutions = new Set(
|
|
28
|
+
const ranges = new Set<string>(TIME_RANGES);
|
|
29
|
+
const resolutions = new Set<string>(CHART_RESOLUTIONS);
|
|
29
30
|
const periods = new Set(["auto", "daily", "weekly", "monthly", "quarterly", "annual", "ttm"]);
|
|
30
31
|
const styles = new Set(["line", "area", "step", "columns", "points", "candles", "ohlc", "hlc"]);
|
|
31
32
|
const transforms = new Set(["raw", "percent", "index100", "yoy", "qoq", "log"]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CliCommandDef } from "../../types/plugin";
|
|
2
|
-
import type
|
|
2
|
+
import { TIME_RANGES, type TimeRange } from "../../time-series/range";
|
|
3
3
|
import type { EarningsEvent, QuoteBatchResult, SecFilingItem } from "../../types/data-provider";
|
|
4
4
|
import type { NewsArticle, NewsFeed, NewsQuery } from "../../news/types";
|
|
5
5
|
import type {
|
|
@@ -15,7 +15,7 @@ import { formatCompact } from "../../utils/format";
|
|
|
15
15
|
import { withCliServices, withMarketData } from "../context";
|
|
16
16
|
import { isoDate, parsePositiveInt, requireArg, takeOption } from "./command-utils";
|
|
17
17
|
|
|
18
|
-
const VALID_RANGES = new Set<TimeRange>(
|
|
18
|
+
const VALID_RANGES = new Set<TimeRange>(TIME_RANGES);
|
|
19
19
|
const VALID_NEWS_FEEDS = new Set<NewsFeed>(["latest", "top", "breaking", "ticker", "sector", "topic"]);
|
|
20
20
|
|
|
21
21
|
type QuoteCliRecord = Omit<QuoteBatchResult, "error"> & { error: string | null };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CHART_RESOLUTIONS } from "../../time-series/range";
|
|
1
2
|
import type { PaneDef, PaneTemplateDef } from "../../types/plugin";
|
|
2
3
|
|
|
3
4
|
export type PaneFunctionReadiness = "ready" | "partial" | "unsupported";
|
|
@@ -128,8 +129,7 @@ const CAPABILITIES: Record<string, PaneFunctionCapability> = {
|
|
|
128
129
|
description: "Market-price sampling resolution.",
|
|
129
130
|
type: "enum",
|
|
130
131
|
aliases: ["resolution"],
|
|
131
|
-
values:
|
|
132
|
-
.map((value) => ({ value })),
|
|
132
|
+
values: CHART_RESOLUTIONS.map((value) => ({ value })),
|
|
133
133
|
},
|
|
134
134
|
],
|
|
135
135
|
},
|
|
@@ -251,7 +251,7 @@ const CAPABILITIES: Record<string, PaneFunctionCapability> = {
|
|
|
251
251
|
description: "Price sampling resolution.",
|
|
252
252
|
type: "enum",
|
|
253
253
|
aliases: ["resolution"],
|
|
254
|
-
values:
|
|
254
|
+
values: CHART_RESOLUTIONS.filter((value) => value !== "auto").map((value) => ({ value })),
|
|
255
255
|
defaultValue: "1d",
|
|
256
256
|
},
|
|
257
257
|
],
|
package/src/cli/registry.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
CliCommandDef,
|
|
5
5
|
CliDispatchResult,
|
|
6
6
|
GloomPlugin,
|
|
7
|
-
PluginCliCommandDescriptor,
|
|
8
7
|
} from "../types/plugin";
|
|
9
8
|
import type { LoadedExternalPlugin } from "../plugins/loader";
|
|
10
9
|
import { getPluginCatalog } from "../plugins/catalog";
|
|
@@ -35,7 +34,6 @@ export interface CliCommandRegistry {
|
|
|
35
34
|
config: AppConfig | null;
|
|
36
35
|
plugins: GloomPlugin[];
|
|
37
36
|
externalPlugins: LoadedExternalPlugin[];
|
|
38
|
-
descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }>;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
interface BuildCliCommandRegistryOptions {
|
|
@@ -71,19 +69,6 @@ function getCommandUsageLabel(command: CliCommandDef): string {
|
|
|
71
69
|
return command.help?.usage?.[0] ?? command.name;
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
function descriptorToCommand(descriptor: PluginCliCommandDescriptor): CliCommandDef | null {
|
|
75
|
-
if (!descriptor.execute) return null;
|
|
76
|
-
return {
|
|
77
|
-
name: descriptor.name,
|
|
78
|
-
aliases: descriptor.aliases,
|
|
79
|
-
description: descriptor.summary,
|
|
80
|
-
help: descriptor.examples?.length
|
|
81
|
-
? { usage: descriptor.examples }
|
|
82
|
-
: undefined,
|
|
83
|
-
execute: descriptor.execute,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
72
|
function renderHelpSections(registry: CliCommandRegistry): string[] {
|
|
88
73
|
const lines: string[] = [];
|
|
89
74
|
|
|
@@ -172,7 +157,6 @@ export function buildCliCommandRegistry({
|
|
|
172
157
|
}
|
|
173
158
|
|
|
174
159
|
const loadablePlugins: GloomPlugin[] = [];
|
|
175
|
-
const descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }> = [];
|
|
176
160
|
for (const entry of catalog) {
|
|
177
161
|
if (entry.error) {
|
|
178
162
|
registryLog.warn(`Skipping external plugin "${entry.plugin.id}" for CLI registration.`, {
|
|
@@ -185,11 +169,6 @@ export function buildCliCommandRegistry({
|
|
|
185
169
|
for (const command of entry.plugin.cliCommands ?? []) {
|
|
186
170
|
registerCommand(command, entry.plugin.id, "plugin");
|
|
187
171
|
}
|
|
188
|
-
for (const descriptor of entry.plugin.cli?.commands ?? []) {
|
|
189
|
-
descriptors.push({ pluginId: entry.plugin.id, descriptor });
|
|
190
|
-
const command = descriptorToCommand(descriptor);
|
|
191
|
-
if (command) registerCommand(command, entry.plugin.id, "plugin");
|
|
192
|
-
}
|
|
193
172
|
}
|
|
194
173
|
|
|
195
174
|
const commands = allCommands.filter((entry) => (
|
|
@@ -210,7 +189,6 @@ export function buildCliCommandRegistry({
|
|
|
210
189
|
config,
|
|
211
190
|
plugins: loadablePlugins.filter((plugin) => !disabledPlugins.has(plugin.id)),
|
|
212
191
|
externalPlugins,
|
|
213
|
-
descriptors,
|
|
214
192
|
};
|
|
215
193
|
}
|
|
216
194
|
|
package/src/cli/result.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CliGlobalOptions } from "./options";
|
|
2
2
|
import { renderTable, type CliTableColumn } from "../utils/cli-output";
|
|
3
|
+
import { serializeCsv } from "../utils/csv";
|
|
3
4
|
|
|
4
5
|
export interface CliResult<T = unknown> {
|
|
5
6
|
data: T;
|
|
@@ -57,11 +58,6 @@ function inferColumns(rows: Record<string, unknown>[]): CliResultColumn<Record<s
|
|
|
57
58
|
return [...keys].map((key) => ({ key, header: key }));
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function csvEscape(value: string): string {
|
|
61
|
-
if (!/[",\n\r]/.test(value)) return value;
|
|
62
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
function serializeColumns<Row>(columns?: CliResultColumn<Row>[]) {
|
|
66
62
|
return columns?.map((column) => ({
|
|
67
63
|
key: column.key,
|
|
@@ -78,11 +74,12 @@ function renderCsv<Row extends Record<string, unknown>>(
|
|
|
78
74
|
const resolvedColumns = columns && columns.length > 0
|
|
79
75
|
? columns
|
|
80
76
|
: inferColumns(rows as Record<string, unknown>[]) as CliResultColumn<Row>[];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.map((
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
return serializeCsv([
|
|
78
|
+
resolvedColumns.map((column) => column.header),
|
|
79
|
+
...rows.map((row) => resolvedColumns.map((column) => (
|
|
80
|
+
column.value ? column.value(row) : row[column.key]
|
|
81
|
+
))),
|
|
82
|
+
]);
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
function renderTextTable<Row extends Record<string, unknown>>(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { EXCEL_CSV_BOM } from "../../utils/csv";
|
|
3
|
+
import { createDataTableCsv } from "./export";
|
|
4
|
+
|
|
5
|
+
test("exports displayed table cells and skips section headers", () => {
|
|
6
|
+
const items = [
|
|
7
|
+
{ type: "section", name: "Americas", value: "" },
|
|
8
|
+
{ type: "row", name: "S&P 500", value: "+1.25%" },
|
|
9
|
+
];
|
|
10
|
+
const csv = createDataTableCsv({
|
|
11
|
+
columns: [
|
|
12
|
+
{ id: "name", label: "Name", width: 20, align: "left" },
|
|
13
|
+
{ id: "value", label: "Change", width: 10, align: "right" },
|
|
14
|
+
],
|
|
15
|
+
items,
|
|
16
|
+
renderSectionHeader: (item) => item.type === "section" ? { text: item.name } : null,
|
|
17
|
+
renderCell: (item, column) => ({ text: item[column.id as "name" | "value"] }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(csv).toBe(`${EXCEL_CSV_BOM}Name,Change\nS&P 500,'+1.25%`);
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DataTableColumn, DataTableProps } from "../ui";
|
|
2
|
+
import { serializeCsv } from "../../utils/csv";
|
|
3
|
+
|
|
4
|
+
export function createDataTableCsv<
|
|
5
|
+
T,
|
|
6
|
+
C extends DataTableColumn = DataTableColumn,
|
|
7
|
+
>({
|
|
8
|
+
columns,
|
|
9
|
+
items,
|
|
10
|
+
renderCell,
|
|
11
|
+
renderSectionHeader,
|
|
12
|
+
}: Pick<DataTableProps<T, C>, "columns" | "items" | "renderCell" | "renderSectionHeader">): string {
|
|
13
|
+
const rows = items.flatMap((item, index) => {
|
|
14
|
+
if (renderSectionHeader?.(item, index)) return [];
|
|
15
|
+
return [columns.map((column) => renderCell(item, column, index, { selected: false }).text)];
|
|
16
|
+
});
|
|
17
|
+
return serializeCsv([
|
|
18
|
+
columns.map((column) => column.label),
|
|
19
|
+
...rows,
|
|
20
|
+
], { excelCompatible: true });
|
|
21
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type ComponentType } from "react";
|
|
2
|
-
import { useUiHost } from "../../../ui";
|
|
1
|
+
import { type ComponentType, useEffect, useRef } from "react";
|
|
2
|
+
import { useRendererHost, useUiHost } from "../../../ui";
|
|
3
3
|
import { OpenTuiDataTable } from "./opentui";
|
|
4
4
|
import type {
|
|
5
5
|
DataTableColumn,
|
|
@@ -7,6 +7,9 @@ import type {
|
|
|
7
7
|
} from "./types";
|
|
8
8
|
import { useRemoteUiNode } from "../../../remote/semantic-tree";
|
|
9
9
|
import { remoteNumberValue, resolveRemoteItemIndex } from "../../../remote/semantic-helpers";
|
|
10
|
+
import { useOptionalPaneInstanceId } from "../../../state/app/context";
|
|
11
|
+
import { registerPaneTableExporter } from "../../../state/pane-table-export-registry";
|
|
12
|
+
import { createDataTableCsv } from "../../data-table/export";
|
|
10
13
|
|
|
11
14
|
export type {
|
|
12
15
|
DataTableCell,
|
|
@@ -19,6 +22,20 @@ export type {
|
|
|
19
22
|
export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
|
|
20
23
|
props: DataTableProps<T, C>,
|
|
21
24
|
) {
|
|
25
|
+
const paneId = useOptionalPaneInstanceId();
|
|
26
|
+
const renderer = useRendererHost();
|
|
27
|
+
const propsRef = useRef(props);
|
|
28
|
+
propsRef.current = props;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!paneId || !renderer.saveTextFile) return;
|
|
32
|
+
return registerPaneTableExporter(paneId, (filename) => renderer.saveTextFile!({
|
|
33
|
+
name: filename,
|
|
34
|
+
text: createDataTableCsv(propsRef.current),
|
|
35
|
+
mimeType: "text/csv;charset=utf-8",
|
|
36
|
+
}));
|
|
37
|
+
}, [paneId, renderer]);
|
|
38
|
+
|
|
22
39
|
useRemoteUiNode({
|
|
23
40
|
role: "table",
|
|
24
41
|
label: "Data table",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { CHART_RESOLUTIONS, TIME_RANGES, type ChartResolution, type TimeRange } from "../../time-series/range";
|
|
2
2
|
import type { LayoutConfig, PaneBinding } from "../../types/config";
|
|
3
3
|
import type {
|
|
4
4
|
ChartPanelSpec,
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
validateChartSpec,
|
|
17
17
|
} from "../../time-series/spec";
|
|
18
18
|
|
|
19
|
-
const CHART_RANGES = new Set<TimeRange>(
|
|
20
|
-
const
|
|
19
|
+
const CHART_RANGES = new Set<TimeRange>(TIME_RANGES);
|
|
20
|
+
const CHART_RESOLUTION_SET = new Set<ChartResolution>(CHART_RESOLUTIONS);
|
|
21
21
|
const PRICE_RENDER_MODES = new Set<SeriesStyle>(["area", "line", "candles", "ohlc", "hlc"]);
|
|
22
22
|
const LEGACY_INDICATOR_IDS = new Set<LegacyChartIndicatorId>([
|
|
23
23
|
"volume",
|
|
@@ -94,7 +94,7 @@ function range(value: unknown, fallback: TimeRange): TimeRange {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function resolution(value: unknown, fallback: ChartResolution): ChartResolution {
|
|
97
|
-
return
|
|
97
|
+
return CHART_RESOLUTION_SET.has(value as ChartResolution) ? value as ChartResolution : fallback;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function makeSpec(
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { Box,
|
|
3
|
-
import {
|
|
2
|
+
import { Box, Text, TextAttributes, type InputRenderable } from "../ui";
|
|
3
|
+
import { InputSearchBar, usePaneFooter } from "../components";
|
|
4
4
|
import { ListView, type ListViewItem } from "../components/ui/list-view";
|
|
5
5
|
import { useShortcut, useViewport } from "../react/input";
|
|
6
|
-
import { useAppInputCapture } from "../state/app/input-capture";
|
|
7
6
|
import { useThemeColors } from "../theme/theme-context";
|
|
8
7
|
import { truncateToDisplayWidth } from "../utils/format";
|
|
8
|
+
import { isPlainKey } from "../utils/keyboard";
|
|
9
9
|
import { t, tf } from "../i18n";
|
|
10
10
|
import type { LayoutGalleryController } from "./gallery";
|
|
11
11
|
import {
|
|
@@ -71,9 +71,23 @@ export function LayoutGalleryTerminal({
|
|
|
71
71
|
const detailsWidth = Math.min(DETAILS_WIDTH, Math.max(28, Math.floor(paneWidth * 0.42)));
|
|
72
72
|
const inputRef = useRef<InputRenderable | null>(null);
|
|
73
73
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
74
|
+
const [searchFocusToken, setSearchFocusToken] = useState(0);
|
|
74
75
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const {
|
|
77
|
+
activate: activateLayout,
|
|
78
|
+
canDelete,
|
|
79
|
+
community,
|
|
80
|
+
copyLink,
|
|
81
|
+
deleteLayout,
|
|
82
|
+
duplicateLayout,
|
|
83
|
+
install: installLayout,
|
|
84
|
+
newLayout,
|
|
85
|
+
owned,
|
|
86
|
+
publishCurrent,
|
|
87
|
+
publishing,
|
|
88
|
+
renameLayout,
|
|
89
|
+
select,
|
|
90
|
+
} = controller;
|
|
77
91
|
const discoverStatus = discoverStatusRow(controller);
|
|
78
92
|
|
|
79
93
|
const rows = useMemo<GalleryRow[]>(() => {
|
|
@@ -138,6 +152,12 @@ export function LayoutGalleryTerminal({
|
|
|
138
152
|
setSelectedIndex(selectableIndexes[nextPosition]!);
|
|
139
153
|
}, [selectableIndexes, selectedIndex]);
|
|
140
154
|
|
|
155
|
+
const focusSearch = useCallback(() => {
|
|
156
|
+
setSearchFocused(true);
|
|
157
|
+
setSearchFocusToken((current) => current + 1);
|
|
158
|
+
}, []);
|
|
159
|
+
const blurSearch = useCallback(() => setSearchFocused(false), []);
|
|
160
|
+
|
|
141
161
|
// The details panel is always on screen here, so Enter runs the action it shows
|
|
142
162
|
// instead of stepping through a separate detail state.
|
|
143
163
|
const activate = useCallback((row: GalleryRow | null) => {
|
|
@@ -147,70 +167,120 @@ export function LayoutGalleryTerminal({
|
|
|
147
167
|
return;
|
|
148
168
|
}
|
|
149
169
|
if (!row.entry) return;
|
|
150
|
-
if (row.entry.kind === "community")
|
|
151
|
-
else
|
|
152
|
-
}, [
|
|
170
|
+
if (row.entry.kind === "community") installLayout(row.entry);
|
|
171
|
+
else activateLayout(row.entry);
|
|
172
|
+
}, [activateLayout, installLayout]);
|
|
173
|
+
|
|
174
|
+
const selectedRowRef = useRef<GalleryRow | null>(selectedRow);
|
|
175
|
+
selectedRowRef.current = selectedRow;
|
|
176
|
+
const activateSelected = useCallback(() => activate(selectedRowRef.current), [activate]);
|
|
177
|
+
const renameSelected = useCallback(() => {
|
|
178
|
+
const entry = selectedRowRef.current?.entry;
|
|
179
|
+
if (entry?.kind === "owned") renameLayout(entry);
|
|
180
|
+
}, [renameLayout]);
|
|
181
|
+
const copySelected = useCallback(() => {
|
|
182
|
+
const entry = selectedRowRef.current?.entry;
|
|
183
|
+
if (entry?.kind === "community") copyLink(entry);
|
|
184
|
+
else if (entry?.kind === "owned") duplicateLayout(entry);
|
|
185
|
+
}, [copyLink, duplicateLayout]);
|
|
186
|
+
const deleteSelected = useCallback(() => {
|
|
187
|
+
const entry = selectedRowRef.current?.entry;
|
|
188
|
+
if (entry?.kind === "owned") deleteLayout(entry);
|
|
189
|
+
}, [deleteLayout]);
|
|
190
|
+
|
|
191
|
+
usePaneFooter("layout-marketplace", () => ({
|
|
192
|
+
info: publishing
|
|
193
|
+
? [{ id: "publishing", parts: [{ text: "publishing", tone: "muted" as const }] }]
|
|
194
|
+
: [],
|
|
195
|
+
hints: [
|
|
196
|
+
{ id: "search", key: "/", label: "search", onPress: focusSearch },
|
|
197
|
+
{ id: "new", key: "n", label: "ew", onPress: newLayout },
|
|
198
|
+
...(selectedEntry?.kind === "owned"
|
|
199
|
+
? [
|
|
200
|
+
{ id: "open", key: "o", label: "pen", onPress: activateSelected },
|
|
201
|
+
{ id: "rename", key: "r", label: "ename", onPress: renameSelected },
|
|
202
|
+
{ id: "copy", key: "c", label: "opy", onPress: copySelected },
|
|
203
|
+
{ id: "delete", key: "d", label: "elete", onPress: deleteSelected, disabled: !canDelete },
|
|
204
|
+
]
|
|
205
|
+
: selectedEntry?.kind === "community"
|
|
206
|
+
? [
|
|
207
|
+
{ id: "add", key: "a", label: "dd layout", onPress: activateSelected },
|
|
208
|
+
{ id: "copy-link", key: "c", label: "opy link", onPress: copySelected },
|
|
209
|
+
]
|
|
210
|
+
: selectedRow?.id === "discover:retry"
|
|
211
|
+
? [{ id: "retry", key: "r", label: "etry", onPress: activateSelected }]
|
|
212
|
+
: selectedRow?.action
|
|
213
|
+
? [{ id: "open", key: "o", label: "pen", onPress: activateSelected }]
|
|
214
|
+
: []),
|
|
215
|
+
{ id: "publish", key: "p", label: "ublish", onPress: publishCurrent, disabled: publishing },
|
|
216
|
+
],
|
|
217
|
+
}), [
|
|
218
|
+
activateSelected,
|
|
219
|
+
canDelete,
|
|
220
|
+
copySelected,
|
|
221
|
+
deleteSelected,
|
|
222
|
+
focusSearch,
|
|
223
|
+
newLayout,
|
|
224
|
+
publishCurrent,
|
|
225
|
+
publishing,
|
|
226
|
+
renameSelected,
|
|
227
|
+
selectedEntry?.kind,
|
|
228
|
+
selectedRow?.action,
|
|
229
|
+
selectedRow?.id,
|
|
230
|
+
]);
|
|
153
231
|
|
|
154
232
|
useShortcut((event) => {
|
|
155
233
|
if (dialogOpen) return;
|
|
156
|
-
const editable = event.targetEditable === true;
|
|
157
234
|
// Leaving the search field is the first Escape; the gallery closes on the next.
|
|
158
|
-
if (event
|
|
159
|
-
event.preventDefault();
|
|
160
|
-
event.stopPropagation();
|
|
161
|
-
setSearchFocused(false);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (event.name === "down" || (!editable && event.name === "j")) {
|
|
235
|
+
if (isPlainKey(event, "escape", "esc") && searchFocused) {
|
|
165
236
|
event.preventDefault();
|
|
166
237
|
event.stopPropagation();
|
|
167
|
-
|
|
238
|
+
blurSearch();
|
|
168
239
|
return;
|
|
169
240
|
}
|
|
170
|
-
if (event.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
move(-1);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (event.name === "enter" || event.name === "return") {
|
|
177
|
-
event.preventDefault();
|
|
178
|
-
event.stopPropagation();
|
|
179
|
-
activate(selectedRow);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (!editable && (event.name === "/" || event.sequence === "/")) {
|
|
241
|
+
if (event.targetEditable) return;
|
|
242
|
+
|
|
243
|
+
const run = (action: () => void) => {
|
|
183
244
|
event.preventDefault();
|
|
184
245
|
event.stopPropagation();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
action();
|
|
247
|
+
};
|
|
248
|
+
if (isPlainKey(event, "down", "j")) run(() => move(1));
|
|
249
|
+
else if (isPlainKey(event, "up", "k")) {
|
|
250
|
+
run(() => {
|
|
251
|
+
if (event.name === "up" && selectedIndex === selectableIndexes[0]) focusSearch();
|
|
252
|
+
else move(-1);
|
|
253
|
+
});
|
|
254
|
+
} else if (isPlainKey(event, "enter", "return")) run(activateSelected);
|
|
255
|
+
else if (isPlainKey(event, "/")) run(focusSearch);
|
|
256
|
+
else if (isPlainKey(event, "n")) run(newLayout);
|
|
257
|
+
else if (isPlainKey(event, "p")) run(publishCurrent);
|
|
258
|
+
else if (isPlainKey(event, "o") && (selectedEntry?.kind === "owned" || selectedRow?.action)) run(activateSelected);
|
|
259
|
+
else if (isPlainKey(event, "a") && selectedEntry?.kind === "community") run(activateSelected);
|
|
260
|
+
else if (isPlainKey(event, "r") && selectedRow?.id === "discover:retry") run(activateSelected);
|
|
261
|
+
else if (isPlainKey(event, "r") && selectedEntry?.kind === "owned") run(renameSelected);
|
|
262
|
+
else if (isPlainKey(event, "c") && selectedEntry) run(copySelected);
|
|
263
|
+
else if (isPlainKey(event, "d") && selectedEntry?.kind === "owned" && canDelete) run(deleteSelected);
|
|
188
264
|
}, { allowEditable: true, enabled: focused && !dialogOpen, phase: "before", scope: "layout-gallery" });
|
|
189
265
|
|
|
190
266
|
const bodyHeight = Math.max(4, paneHeight - 1);
|
|
191
267
|
|
|
192
268
|
return (
|
|
193
269
|
<Box flexGrow={1} flexDirection="column" backgroundColor={colors.bg}>
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
onChange={controller.setQuery}
|
|
209
|
-
onFocus={() => setSearchFocused(true)}
|
|
210
|
-
onBlur={() => setSearchFocused(false)}
|
|
211
|
-
onSubmit={() => setSearchFocused(false)}
|
|
212
|
-
/>
|
|
213
|
-
</Box>
|
|
270
|
+
<InputSearchBar
|
|
271
|
+
value={controller.query}
|
|
272
|
+
focused={focused && !dialogOpen}
|
|
273
|
+
active={searchFocused}
|
|
274
|
+
width={paneWidth}
|
|
275
|
+
focusToken={searchFocusToken}
|
|
276
|
+
inputRef={inputRef}
|
|
277
|
+
placeholder={t("Search layouts and panes")}
|
|
278
|
+
debounceMs={80}
|
|
279
|
+
onFocus={focusSearch}
|
|
280
|
+
onBlur={blurSearch}
|
|
281
|
+
onNavigateDown={blurSearch}
|
|
282
|
+
onQueryChange={controller.setQuery}
|
|
283
|
+
/>
|
|
214
284
|
|
|
215
285
|
<Box flexDirection="row" flexGrow={1}>
|
|
216
286
|
<Box flexGrow={1} flexDirection="column" paddingX={1}>
|
|
@@ -257,17 +327,12 @@ export function LayoutGalleryTerminal({
|
|
|
257
327
|
controller={controller}
|
|
258
328
|
entry={selectedEntry}
|
|
259
329
|
width={detailsWidth}
|
|
260
|
-
height={
|
|
330
|
+
height={bodyHeight}
|
|
261
331
|
/>
|
|
262
332
|
) : selectedRow?.action ? (
|
|
263
333
|
<Box flexDirection="column" gap={1}>
|
|
264
334
|
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{selectedRow.label}</Text>
|
|
265
335
|
{selectedRow.detail && <Text fg={colors.textMuted}>{selectedRow.detail}</Text>}
|
|
266
|
-
<Button
|
|
267
|
-
label={selectedRow.id === "discover:login" ? "Log in" : "Retry"}
|
|
268
|
-
variant="primary"
|
|
269
|
-
onPress={selectedRow.action}
|
|
270
|
-
/>
|
|
271
336
|
</Box>
|
|
272
337
|
) : (
|
|
273
338
|
<Text fg={colors.textDim}>{t("No layout selected.")}</Text>
|
|
@@ -292,8 +357,8 @@ function LayoutDetails({
|
|
|
292
357
|
const colors = useThemeColors();
|
|
293
358
|
const allPanes = summarizeLayoutPanes(entry.layout, controller.panes);
|
|
294
359
|
const missing = allPanes.filter((pane) => pane.missing);
|
|
295
|
-
// Leave room for the title block
|
|
296
|
-
const paneBudget = Math.max(3, height -
|
|
360
|
+
// Leave room for the title block and the optional warning.
|
|
361
|
+
const paneBudget = Math.max(3, height - 8);
|
|
297
362
|
const overflow = Math.max(0, allPanes.length - paneBudget);
|
|
298
363
|
const panes = overflow > 0 ? allPanes.slice(0, paneBudget - 1) : allPanes;
|
|
299
364
|
|
|
@@ -336,41 +401,6 @@ function LayoutDetails({
|
|
|
336
401
|
</Text>
|
|
337
402
|
</>
|
|
338
403
|
)}
|
|
339
|
-
<Box height={1} />
|
|
340
|
-
<Box flexDirection="row" gap={1}>
|
|
341
|
-
<Button
|
|
342
|
-
label={entry.kind === "community" ? "Add Layout" : "Open"}
|
|
343
|
-
variant="primary"
|
|
344
|
-
onPress={() => (entry.kind === "community"
|
|
345
|
-
? controller.install(entry)
|
|
346
|
-
: controller.activate(entry))}
|
|
347
|
-
/>
|
|
348
|
-
{entry.kind === "community" && (
|
|
349
|
-
<Button label="Copy Link" variant="secondary" onPress={() => controller.copyLink(entry)} />
|
|
350
|
-
)}
|
|
351
|
-
{entry.kind === "owned" && (
|
|
352
|
-
<>
|
|
353
|
-
<Button label="Rename" variant="secondary" onPress={() => controller.renameLayout(entry)} />
|
|
354
|
-
<Button label="Copy" variant="secondary" onPress={() => controller.duplicateLayout(entry)} />
|
|
355
|
-
<Button
|
|
356
|
-
label="Delete"
|
|
357
|
-
variant="secondary"
|
|
358
|
-
disabled={!controller.canDelete}
|
|
359
|
-
onPress={() => controller.deleteLayout(entry)}
|
|
360
|
-
/>
|
|
361
|
-
</>
|
|
362
|
-
)}
|
|
363
|
-
</Box>
|
|
364
|
-
<Box height={1} />
|
|
365
|
-
<Box flexDirection="row" gap={1}>
|
|
366
|
-
<Button label="New" variant="ghost" onPress={controller.newLayout} />
|
|
367
|
-
<Button
|
|
368
|
-
label={controller.publishing ? "Publishing" : "Publish"}
|
|
369
|
-
variant="ghost"
|
|
370
|
-
disabled={controller.publishing}
|
|
371
|
-
onPress={controller.publishCurrent}
|
|
372
|
-
/>
|
|
373
|
-
</Box>
|
|
374
404
|
</Box>
|
|
375
405
|
);
|
|
376
406
|
}
|