@gloom-sh/terminal 0.19.0 → 0.19.1
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 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/cli/commands/market.ts +35 -12
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +86 -20
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +6 -0
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/listing-arg.ts +57 -10
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +1 -1
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +6 -7
- package/src/components/command-bar/selection-runtime.ts +52 -3
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +22 -6
- package/src/components/layout/pane/footer/model.ts +83 -15
- package/src/components/layout/pane/status-footer.ts +35 -2
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +18 -3
- package/src/components/ui/icon.tsx +4 -1
- package/src/data/plugin-cache.ts +5 -3
- package/src/data/saved-copies.ts +8 -0
- package/src/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- package/src/plugins/builtin/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -1
- package/src/plugins/builtin/credit-documents/headless.ts +3 -3
- package/src/plugins/builtin/doe/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- package/src/plugins/builtin/earnings-calls/data.ts +18 -6
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +2 -2
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- package/src/plugins/builtin/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +2 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/screener.ts +8 -4
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +4 -2
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- package/src/plugins/builtin/thirteenf/api.ts +12 -1
- package/src/plugins/builtin/thirteenf/data.ts +7 -7
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/pane.tsx +5 -2
- package/src/plugins/builtin/yield-curve/client.ts +61 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/create.ts +43 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +8 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- package/src/tickers/company-name-listing.ts +53 -13
- package/src/tickers/company-name.ts +6 -1
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/index.ts +6 -2
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- package/src/types/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +27 -0
- package/src/types/headless.ts +6 -0
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/fundamentals.ts +36 -5
- package/src/version.ts +2 -2
|
@@ -123,6 +123,8 @@ export interface ReportFreshness {
|
|
|
123
123
|
ageOnly?: true;
|
|
124
124
|
/** What the newest observation is, said before its time: `newest story Tue 29 Sep 2026 20:17 UTC`. */
|
|
125
125
|
asOfLabel?: string;
|
|
126
|
+
/** The line says when the report was retrieved after the newest observation's date (`retrieved Wed 28 Oct 2026 19:07 UTC`). */
|
|
127
|
+
retrievedToo?: true;
|
|
126
128
|
/** What the figures are, said after the status: `funding = current estimate, pays 08:00 UTC`. */
|
|
127
129
|
note?: string;
|
|
128
130
|
/** Shorter forms of `note`, in the order a capture's line too long for it tries them. */
|
|
@@ -610,6 +612,7 @@ function resolveFreshness({ declared, signals, observations, now }: FreshnessInp
|
|
|
610
612
|
...(oldestAgeMinutes != null ? { oldestAgeMinutes } : {}),
|
|
611
613
|
...(status === "unreported" && declared.ageOnly ? { ageOnly: true as const } : {}),
|
|
612
614
|
...(asOfLabel ? { asOfLabel } : {}),
|
|
615
|
+
...(asOf && declared.retrievedToo ? { retrievedToo: true as const } : {}),
|
|
613
616
|
...(note ? { note } : {}),
|
|
614
617
|
...(noteVariants.length > 0 ? { noteVariants } : {}),
|
|
615
618
|
...(schedule ? { schedule } : {}),
|
|
@@ -1061,8 +1064,11 @@ function statusLineParts(freshness: ReportFreshness, detail: StatusLineDetail =
|
|
|
1061
1064
|
const undated = detail.date === "none";
|
|
1062
1065
|
return [
|
|
1063
1066
|
...(freshness.sources?.length ? sourcesAsOfParts(freshness, freshness.sources, undated)
|
|
1064
|
-
:
|
|
1067
|
+
// Undated, a saved copy is not "retrieved" now: its own part says when it was saved.
|
|
1068
|
+
: (freshness.unreached || freshness.savedAt) && !freshness.asOf ? []
|
|
1065
1069
|
: [asOfText(freshness, detail.asOf === "short", local, undated)]),
|
|
1070
|
+
// A list dated by its newest filing says when it was read as well.
|
|
1071
|
+
...(freshness.retrievedToo && freshness.asOf && !freshness.savedAt ? [`retrieved ${timeText(freshness.retrievedAt, local)}`] : []),
|
|
1066
1072
|
...(freshness.schedule && detail.schedule !== "none"
|
|
1067
1073
|
? [typeof detail.schedule === "number" ? freshness.scheduleVariants?.[detail.schedule] ?? freshness.schedule : freshness.schedule] : []),
|
|
1068
1074
|
...(freshness.oldestAgeMinutes != null ? [`oldest ${ageText(freshness.oldestAgeMinutes)}`] : []),
|
|
@@ -1426,6 +1426,13 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
|
|
|
1426
1426
|
data: null,
|
|
1427
1427
|
bloomberg: [],
|
|
1428
1428
|
},
|
|
1429
|
+
ROADMAP: {
|
|
1430
|
+
summary: "What is being built, what is planned and what shipped, with each item's votes. Pro members vote on what is building or planned, once per item, and can take the vote back.",
|
|
1431
|
+
usage: ["ROADMAP"],
|
|
1432
|
+
keys: [OPEN, key("v", "ote")],
|
|
1433
|
+
data: null,
|
|
1434
|
+
bloomberg: [],
|
|
1435
|
+
},
|
|
1429
1436
|
HELP: {
|
|
1430
1437
|
summary: "Help and the function reference. HELP with a function opens its card, F1 opens the focused pane's, and HELP HELP reaches support.",
|
|
1431
1438
|
usage: ["HELP", "HELP OMON", "HELP HELP"],
|
|
@@ -36,8 +36,8 @@ import {
|
|
|
36
36
|
ListingArgError, listingFateLoader, listingReadNotes, loadForListing, loadListingQuoteAnswer, quotedListingChoiceNotes, refuseMovedListing,
|
|
37
37
|
} from "../listing-arg";
|
|
38
38
|
import { notATickerStop } from "../company-name-hint";
|
|
39
|
-
import { CLI_ERROR_CODE, failWithError, isCliFailure,
|
|
40
|
-
import { DATA_SERVICE_UNREACHABLE_MESSAGE } from "../../utils/network-failure";
|
|
39
|
+
import { CLI_ERROR_CODE, failUnreachable, failWithError, isCliFailure, unreachableError } from "../errors";
|
|
40
|
+
import { DATA_SERVICE_UNREACHABLE_MESSAGE, isNetworkFailure } from "../../utils/network-failure";
|
|
41
41
|
import { isTickerLinkPeer } from "../../layout/ticker-navigation";
|
|
42
42
|
import { knownGapMessage, commandPointer, commandPointerLeads, topicPointer } from "../known-gaps";
|
|
43
43
|
import { selectReportTables, type CliReportTables } from "../report-tables";
|
|
@@ -264,7 +264,7 @@ export function reportFailure(
|
|
|
264
264
|
const reasons = [...data.errors ?? [], ...typeof data.reason === "string" ? [data.reason] : []];
|
|
265
265
|
const unreached = reasons.filter((reason) => reason.includes(DATA_SERVICE_UNREACHABLE_MESSAGE));
|
|
266
266
|
if (unreached.length > 0 && (data.empty || data.unavailableSymbols.length > 0)) {
|
|
267
|
-
return
|
|
267
|
+
return unreachableError(`${asked}: ${unreached.join("; ")}.`.replace(/\.\.$/, "."));
|
|
268
268
|
}
|
|
269
269
|
if (!data.empty && !(noListing && noValues) && !(failsWithoutData && foundNothing(data))) return null;
|
|
270
270
|
const unavailable = data.unavailableSymbols.length > 0 ? `no data for ${data.unavailableSymbols.join(", ")}` : "no data";
|
|
@@ -618,6 +618,9 @@ async function runPaneCliCommand(ctx: CliCommandContext, run: () => Promise<void
|
|
|
618
618
|
// A listing it cannot use says what to type instead; a name that opens nothing, what is close to it.
|
|
619
619
|
if (error instanceof ListingArgError || error instanceof UnknownPaneFunctionError) ctx.fail(error.message, error.details || undefined);
|
|
620
620
|
const message = error instanceof Error ? error.message : String(error);
|
|
621
|
+
// Nothing answered: said as every command says it, with what to check.
|
|
622
|
+
if (message.includes(DATA_SERVICE_UNREACHABLE_MESSAGE)) failUnreachable(message);
|
|
623
|
+
if (isNetworkFailure(error)) failUnreachable();
|
|
621
624
|
// A failure the command already worded keeps its advice line.
|
|
622
625
|
const stated = error instanceof Error ? (error as Error & { details?: unknown }).details : undefined;
|
|
623
626
|
const details = typeof stated === "string" ? stated : undefined;
|
package/src/cli/quote-answer.ts
CHANGED
|
@@ -160,7 +160,7 @@ async function answerCompanyName(
|
|
|
160
160
|
if (error instanceof ListingArgError) return null;
|
|
161
161
|
throw error;
|
|
162
162
|
});
|
|
163
|
-
const found = resolved ? { ...resolved, readAs: named.readAs, ...named.meant ? { meant: named.meant } : {} } : null;
|
|
163
|
+
const found = resolved ? { ...resolved, readAs: named.readAs, ...named.meant ? { meant: named.meant } : {}, ...named.readFromName ? { readFromName: named.readFromName } : {} } : null;
|
|
164
164
|
const [quoted] = found ? await market.dataProvider.getQuotesBatch([found.request], { forceRefresh: refresh, revalidate: true, datesChangeBasis }) : [];
|
|
165
165
|
if (found && quoted?.quote) return answerQuote(quoted, found, market, request, refresh, null, { datesChangeBasis });
|
|
166
166
|
if (!unsettled) return null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AppResourceStorePort } from "../core/app-service-ports";
|
|
2
|
+
import type { JsonValue } from "../data/sqlite/json";
|
|
3
|
+
import type { PersistedResourceValue } from "../types/persistence";
|
|
4
|
+
import type { PluginPersistence } from "../types/plugin";
|
|
5
|
+
import { logFailure } from "../utils/log-failure";
|
|
6
|
+
|
|
7
|
+
type SetResourceOptions = Parameters<PluginPersistence["setResource"]>[3];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A plugin's persistence in a command: what the command fetches through a
|
|
11
|
+
* plugin's cache is saved in the profile, under the namespace the app keeps
|
|
12
|
+
* it in (`plugin:<state id>`), so a later run whose refresh fails answers
|
|
13
|
+
* from that saved copy as the app does. Only a read that may take an expired
|
|
14
|
+
* copy (`allowExpired`, what a cache falls back to when its refresh fails)
|
|
15
|
+
* reaches the saved copies; any other read sees only what this run fetched,
|
|
16
|
+
* so a command still asks for current data rather than answering from the
|
|
17
|
+
* app's cache. Plugin state is not read or kept. With no resource store,
|
|
18
|
+
* nothing outlives the run.
|
|
19
|
+
*
|
|
20
|
+
* The profile's store closes when the command ends, while a plugin may keep
|
|
21
|
+
* the persistence it was set up with: `release()` leaves the store alone from
|
|
22
|
+
* then on, and a store that fails (closed, full) costs the saved copy only,
|
|
23
|
+
* never the command.
|
|
24
|
+
*/
|
|
25
|
+
export function createCommandPluginPersistence(
|
|
26
|
+
resources: AppResourceStorePort | null | undefined,
|
|
27
|
+
namespace: string,
|
|
28
|
+
): PluginPersistence & { release(): void } {
|
|
29
|
+
const fetched = new Map<string, PersistedResourceValue<unknown>>();
|
|
30
|
+
const keyOf = (kind: string, key: string) => `${kind}:${key}`;
|
|
31
|
+
let store = resources ?? null;
|
|
32
|
+
const saved = <T>(operation: string, run: (store: AppResourceStorePort) => T, fallback: T): T => {
|
|
33
|
+
if (!store) return fallback;
|
|
34
|
+
try {
|
|
35
|
+
return run(store);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return logFailure("cli.saved-copies", `${operation} a saved copy failed`, fallback)(error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
release: () => { store = null; },
|
|
42
|
+
getState: () => null,
|
|
43
|
+
setState: () => {},
|
|
44
|
+
deleteState: () => {},
|
|
45
|
+
getResource: <T = unknown>(kind: string, key: string, options?: { sourceKey?: string; schemaVersion?: number; allowExpired?: boolean }) => {
|
|
46
|
+
const own = fetched.get(keyOf(kind, key)) as PersistedResourceValue<T> | undefined;
|
|
47
|
+
if (own || !options?.allowExpired) return own ?? null;
|
|
48
|
+
const record = saved("Reading", (resources) => resources.get<T>(
|
|
49
|
+
{ namespace, kind, entityKey: key, sourceKey: options.sourceKey },
|
|
50
|
+
{ schemaVersion: options.schemaVersion, allowExpired: true },
|
|
51
|
+
), null);
|
|
52
|
+
return record && {
|
|
53
|
+
value: record.value,
|
|
54
|
+
fetchedAt: record.fetchedAt,
|
|
55
|
+
staleAt: record.staleAt,
|
|
56
|
+
expiresAt: record.expiresAt,
|
|
57
|
+
sourceKey: record.sourceKey,
|
|
58
|
+
schemaVersion: record.schemaVersion,
|
|
59
|
+
provenance: record.provenance,
|
|
60
|
+
stale: record.stale,
|
|
61
|
+
expired: record.expired,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
setResource: <T = unknown>(kind: string, key: string, value: T, options: SetResourceOptions) => {
|
|
65
|
+
const fetchedAt = Date.now();
|
|
66
|
+
const entry: PersistedResourceValue<T> = {
|
|
67
|
+
value,
|
|
68
|
+
fetchedAt,
|
|
69
|
+
staleAt: fetchedAt + options.cachePolicy.staleMs,
|
|
70
|
+
expiresAt: fetchedAt + options.cachePolicy.expireMs,
|
|
71
|
+
sourceKey: options.sourceKey ?? "",
|
|
72
|
+
schemaVersion: options.schemaVersion ?? 1,
|
|
73
|
+
provenance: options.provenance ?? null,
|
|
74
|
+
};
|
|
75
|
+
fetched.set(keyOf(kind, key), entry);
|
|
76
|
+
saved("Writing", (resources) => resources.set<T>({ namespace, kind, entityKey: key, sourceKey: options.sourceKey }, value, {
|
|
77
|
+
cachePolicy: options.cachePolicy,
|
|
78
|
+
schemaVersion: options.schemaVersion,
|
|
79
|
+
provenance: options.provenance as JsonValue | null | undefined,
|
|
80
|
+
}), undefined);
|
|
81
|
+
return entry;
|
|
82
|
+
},
|
|
83
|
+
deleteResource: (kind: string, key: string, options?: { sourceKey?: string }) => {
|
|
84
|
+
fetched.delete(keyOf(kind, key));
|
|
85
|
+
saved("Deleting", (resources) => resources.delete({ namespace, kind, entityKey: key, sourceKey: options?.sourceKey }), undefined);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -73,6 +73,11 @@ export interface ResultItem {
|
|
|
73
73
|
action: () => void | Promise<void>;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** A row plain Enter can run, so the untouched selection may rest on it. */
|
|
77
|
+
export function isDefaultSelectable(item: ResultItem): boolean {
|
|
78
|
+
return item.disabled !== true && item.defaultSelectable !== false;
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
type ListScreenKind = "root" | "mode" | "picker";
|
|
77
82
|
|
|
78
83
|
export interface ListScreenState {
|
|
@@ -7,7 +7,7 @@ import type { TickerRecord } from "../../../../types/ticker";
|
|
|
7
7
|
import { listingChoiceQuery, symbolSearchQuery, type TickerSearchCandidate } from "../../../../tickers/search";
|
|
8
8
|
import type { AssistRowHandlers } from "../../assist/model";
|
|
9
9
|
import { matchPrefix, type Command } from "../../commands/registry";
|
|
10
|
-
import type
|
|
10
|
+
import { isDefaultSelectable, type ResultItem } from "../../list/model";
|
|
11
11
|
import type { CommandBarCategoryPriorities } from "../../view-model";
|
|
12
12
|
import type { CommandBarRoute } from "../../workflow/types";
|
|
13
13
|
import { normalizeCommandTickerSearchText, readRootTickerQuery } from "../ticker-search/results";
|
|
@@ -20,11 +20,6 @@ import { buildRootShortcutFeedback } from "./shortcut-feedback";
|
|
|
20
20
|
import type { ShortcutIntent } from "./shortcuts";
|
|
21
21
|
import { clampIndex } from "../../../../utils/math";
|
|
22
22
|
|
|
23
|
-
/** A row plain Enter can run, so the untouched selection may rest on it. */
|
|
24
|
-
function isDefaultSelectable(item: ResultItem): boolean {
|
|
25
|
-
return item.disabled !== true && item.defaultSelectable !== false;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
interface UseCommandBarRootRuntimeOptions {
|
|
29
24
|
activeCollectionId: string | null;
|
|
30
25
|
activePortfolio?: AppState["config"]["portfolios"][number];
|
|
@@ -343,8 +338,12 @@ export function useCommandBarRootRuntime({
|
|
|
343
338
|
if (shiftedIdx >= 0) return shiftedIdx;
|
|
344
339
|
return clampIndex(current, resultIds.length);
|
|
345
340
|
}
|
|
346
|
-
// Untouched, the selection follows the best row on offer.
|
|
341
|
+
// Untouched, the selection follows the best row on offer. When every row
|
|
342
|
+
// is an offer or a placeholder, none of them is the answer and none is
|
|
343
|
+
// selected: Enter must not take one of them for the ticker still being
|
|
344
|
+
// searched.
|
|
347
345
|
const defaultIdx = orderedRootResults.findIndex(isDefaultSelectable);
|
|
346
|
+
if (defaultIdx < 0) return -1;
|
|
348
347
|
return clampIndex(Math.max(rootResultModel.initialIdx, defaultIdx), resultIds.length);
|
|
349
348
|
});
|
|
350
349
|
}, [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, type MutableRefObject } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
|
|
2
2
|
import type { AppState } from "../../state/app/context";
|
|
3
3
|
import type { TickerRecord } from "../../types/ticker";
|
|
4
4
|
import { t } from "../../i18n";
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
PaneTemplateDef,
|
|
13
13
|
} from "../../types/plugin";
|
|
14
14
|
import { resolveCommandBarMode, type CommandBarMode } from "./view-model";
|
|
15
|
-
import type
|
|
15
|
+
import { isDefaultSelectable, type ListScreenState, type ResultItem } from "./list/model";
|
|
16
16
|
import type { CommandBarRoute } from "./workflow/types";
|
|
17
17
|
import type { CollectionCommandId } from "./helpers";
|
|
18
18
|
import type { CommandBarCollectionWorkflowActions } from "./workflow/collection-actions";
|
|
@@ -67,6 +67,7 @@ interface UseCommandBarSelectionRuntimeOptions {
|
|
|
67
67
|
rootModeKindRef: MutableRefObject<CommandBarMode>;
|
|
68
68
|
rootQuery: string;
|
|
69
69
|
rootQueryRef: MutableRefObject<string>;
|
|
70
|
+
rootSelectedIdx: number;
|
|
70
71
|
rootSelectionNavigatedRef: MutableRefObject<boolean>;
|
|
71
72
|
rootShortcutIntent: ShortcutIntent;
|
|
72
73
|
rootThemeBaseIdRef: MutableRefObject<string | null>;
|
|
@@ -78,6 +79,11 @@ interface UseCommandBarSelectionRuntimeOptions {
|
|
|
78
79
|
*/
|
|
79
80
|
runRootRow?: RunRootRow;
|
|
80
81
|
runSecurityDescriptionShortcut: (query?: string, options?: { forceNewPane?: boolean }) => void | Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* The search that decides the best row is still out, so the list on screen
|
|
84
|
+
* is a stand-in for the answer.
|
|
85
|
+
*/
|
|
86
|
+
searchPending: boolean;
|
|
81
87
|
setRootQuery: (query: string) => void;
|
|
82
88
|
stateConfigLayout: AppState["config"]["layout"];
|
|
83
89
|
stateRef: MutableRefObject<AppState>;
|
|
@@ -108,12 +114,14 @@ export function useCommandBarSelectionRuntime({
|
|
|
108
114
|
rootModeKindRef,
|
|
109
115
|
rootQuery,
|
|
110
116
|
rootQueryRef,
|
|
117
|
+
rootSelectedIdx,
|
|
111
118
|
rootSelectionNavigatedRef,
|
|
112
119
|
rootShortcutIntent,
|
|
113
120
|
rootThemeBaseIdRef,
|
|
114
121
|
runDirectCommand,
|
|
115
122
|
runRootRow,
|
|
116
123
|
runSecurityDescriptionShortcut,
|
|
124
|
+
searchPending,
|
|
117
125
|
setRootQuery,
|
|
118
126
|
stateConfigLayout,
|
|
119
127
|
stateRef,
|
|
@@ -276,6 +284,13 @@ export function useCommandBarSelectionRuntime({
|
|
|
276
284
|
updateTopRoute,
|
|
277
285
|
]);
|
|
278
286
|
|
|
287
|
+
/**
|
|
288
|
+
* An Enter pressed with no row to run while the search is still out. It
|
|
289
|
+
* waits for the answer instead of running whatever else the list shows, and
|
|
290
|
+
* is dropped when the user types, moves the selection or leaves the root.
|
|
291
|
+
*/
|
|
292
|
+
const pendingEnterRef = useRef<{ query: string; secondary: boolean } | null>(null);
|
|
293
|
+
|
|
279
294
|
const activateListSelection = useCallback((options?: { secondary?: boolean; item?: ResultItem }) => {
|
|
280
295
|
const listState = visibleListStateRef.current;
|
|
281
296
|
if (!listState) return;
|
|
@@ -311,7 +326,13 @@ export function useCommandBarSelectionRuntime({
|
|
|
311
326
|
secondaryAction: () => runSecurityDescriptionShortcut(symbol, { forceNewPane: true }) };
|
|
312
327
|
}
|
|
313
328
|
const selected = options?.item ?? typed ?? listState.results[listState.selectedIdx];
|
|
314
|
-
if (!selected
|
|
329
|
+
if (!selected) {
|
|
330
|
+
if (searchPending && !currentRoute && !options?.item && !rootSelectionNavigatedRef.current) {
|
|
331
|
+
pendingEnterRef.current = { query: rootQueryRef.current, secondary: options?.secondary === true };
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (selected.disabled) return;
|
|
315
336
|
|
|
316
337
|
// Opened from an add row: a venue picked from the colon list joins that
|
|
317
338
|
// collection. Any other text or screen runs as usual.
|
|
@@ -396,11 +417,39 @@ export function useCommandBarSelectionRuntime({
|
|
|
396
417
|
rootShortcutIntent,
|
|
397
418
|
runRootRow,
|
|
398
419
|
runSecurityDescriptionShortcut,
|
|
420
|
+
searchPending,
|
|
399
421
|
stateConfigLayout,
|
|
400
422
|
stateRef,
|
|
401
423
|
visibleListStateRef,
|
|
402
424
|
]);
|
|
403
425
|
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
const pending = pendingEnterRef.current;
|
|
428
|
+
if (!pending) return;
|
|
429
|
+
if (currentRoute || rootQueryRef.current !== pending.query || rootSelectionNavigatedRef.current) {
|
|
430
|
+
pendingEnterRef.current = null;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const listState = visibleListStateRef.current;
|
|
434
|
+
if (!listState?.results.some(isDefaultSelectable)) {
|
|
435
|
+
// Still searching: the answer may bring a row. Answered with none: there is nothing to run.
|
|
436
|
+
if (!searchPending) pendingEnterRef.current = null;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// The selection follows the rows in the render after they land.
|
|
440
|
+
if (!listState.results[listState.selectedIdx]) return;
|
|
441
|
+
pendingEnterRef.current = null;
|
|
442
|
+
activateListSelection({ secondary: pending.secondary });
|
|
443
|
+
}, [
|
|
444
|
+
activateListSelection,
|
|
445
|
+
currentRoute,
|
|
446
|
+
rootQueryRef,
|
|
447
|
+
rootSelectedIdx,
|
|
448
|
+
rootSelectionNavigatedRef,
|
|
449
|
+
searchPending,
|
|
450
|
+
visibleListStateRef,
|
|
451
|
+
]);
|
|
452
|
+
|
|
404
453
|
return {
|
|
405
454
|
acceptRootShortcutTab,
|
|
406
455
|
acceptSelectedShortcutTab,
|
|
@@ -532,12 +532,14 @@ export function CommandBar({
|
|
|
532
532
|
rootModeKindRef,
|
|
533
533
|
rootQuery,
|
|
534
534
|
rootQueryRef,
|
|
535
|
+
rootSelectedIdx,
|
|
535
536
|
rootSelectionNavigatedRef,
|
|
536
537
|
rootShortcutIntent,
|
|
537
538
|
rootThemeBaseIdRef,
|
|
538
539
|
runDirectCommand,
|
|
539
540
|
runRootRow,
|
|
540
541
|
runSecurityDescriptionShortcut,
|
|
542
|
+
searchPending: rootSearching || !rootResultsCurrent,
|
|
541
543
|
setRootQuery,
|
|
542
544
|
stateConfigLayout: state.config.layout,
|
|
543
545
|
stateRef,
|
|
@@ -28,10 +28,10 @@ export function usePaneCaptureStamp(paneId: string): string | null {
|
|
|
28
28
|
|
|
29
29
|
export function captureStampFooter(footer: CombinedPaneFooter, text: string | null): CombinedPaneFooter {
|
|
30
30
|
if (!text) return footer;
|
|
31
|
-
//
|
|
31
|
+
// The footer sets it apart from what the pane says about its data, which may itself be a time.
|
|
32
32
|
const segment: PaneFooterSegment = {
|
|
33
33
|
id: "capture-stamp",
|
|
34
|
-
parts: [
|
|
34
|
+
parts: [{ text, tone: "label" }],
|
|
35
35
|
};
|
|
36
36
|
return {
|
|
37
37
|
...footer,
|
|
@@ -11,7 +11,9 @@ import { useOptionalDialog, type PromptContext } from "../../../../ui/dialog";
|
|
|
11
11
|
import { nativePaneFooterRows } from "../sizing";
|
|
12
12
|
import {
|
|
13
13
|
EMPTY_FOOTER,
|
|
14
|
+
PANE_FOOTER_SEPARATOR,
|
|
14
15
|
fitPaneFooterInfo,
|
|
16
|
+
separatesPaneFooterSegments,
|
|
15
17
|
hasPaneFooterContent,
|
|
16
18
|
layoutPaneFooterHintRow,
|
|
17
19
|
totalHintsWidth,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
} from "./model";
|
|
23
25
|
|
|
24
26
|
export {
|
|
27
|
+
companyDataFooterSegment,
|
|
25
28
|
datesPaneData,
|
|
26
29
|
DELAYED_SEGMENT,
|
|
27
30
|
delayedFooterSegment,
|
|
@@ -267,6 +270,7 @@ function FooterContent({
|
|
|
267
270
|
// A segment with shorter forms takes the longest that fits beside the hints, gaps and terminal keys included.
|
|
268
271
|
const keysWidth = nativePaneChrome ? 0 : footer.info.reduce((total, segment) => total + terminalSegmentKey(segment, focused).length, 0);
|
|
269
272
|
const info = infoWidth != null ? fitPaneFooterInfo(footer.info, infoWidth - keysWidth, { besideHints: hasHints }) : footer.info;
|
|
273
|
+
const lastTextIndex = info.findLastIndex((segment) => !segment.icon);
|
|
270
274
|
|
|
271
275
|
if (!hasInfo && !hasHints) {
|
|
272
276
|
return <Box flexGrow={1} height={1} />;
|
|
@@ -291,12 +295,24 @@ function FooterContent({
|
|
|
291
295
|
flexShrink={1}
|
|
292
296
|
{...(infoWidth != null ? { width: infoWidth } : {})}
|
|
293
297
|
>
|
|
294
|
-
{info.map((segment, index) =>
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
{info.map((segment, index) => {
|
|
299
|
+
// Every pane's segments are set apart the same way, so two never read as one sentence.
|
|
300
|
+
const separated = index > 0 && separatesPaneFooterSegments(info[index - 1]!, segment);
|
|
301
|
+
return (
|
|
302
|
+
<Box
|
|
303
|
+
key={segment.id}
|
|
304
|
+
flexDirection="row"
|
|
305
|
+
overflow="hidden"
|
|
306
|
+
// A row too narrow cuts from its end: the last text segment gives way, then the row clips the
|
|
307
|
+
// one before it. An icon is a few cells that mean nothing cut, and earlier text says more.
|
|
308
|
+
flexShrink={!segment.icon && index === lastTextIndex ? 1 : 0}
|
|
309
|
+
marginLeft={index > 0 && !separated ? 1 : 0}
|
|
310
|
+
>
|
|
311
|
+
{separated && <Box flexShrink={0}><Text fg={colors.textMuted}>{PANE_FOOTER_SEPARATOR}</Text></Box>}
|
|
312
|
+
<SegmentView segment={segment} focused={focused} />
|
|
313
|
+
</Box>
|
|
314
|
+
);
|
|
315
|
+
})}
|
|
300
316
|
</Box>
|
|
301
317
|
)}
|
|
302
318
|
{hasHints && (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContextMenuItem } from "../../../../types/context-menu";
|
|
2
|
-
import { t } from "../../../../i18n";
|
|
2
|
+
import { t, tf } from "../../../../i18n";
|
|
3
|
+
import { type CompanyFrom, companyFromLabel } from "../../../../types/company-from";
|
|
3
4
|
import { clipToDisplayWidth, displayWidth } from "../../../../utils/format";
|
|
4
5
|
import { formatShortDate } from "../../../../utils/datetime-format";
|
|
5
6
|
import { getShortcutHintWidth } from "../../../ui/shortcut-hint";
|
|
@@ -44,8 +45,9 @@ export interface PaneFooterSegment {
|
|
|
44
45
|
*/
|
|
45
46
|
quoteStatus?: boolean;
|
|
46
47
|
/**
|
|
47
|
-
* The segment says what the rows leave out (releases a filter hides),
|
|
48
|
-
*
|
|
48
|
+
* The segment says what the rows leave out (releases a filter hides), or
|
|
49
|
+
* whose they are (a receipt's company data from its home line), which a
|
|
50
|
+
* picture of the rows cannot: a `gloom shot` keeps it, as it keeps a
|
|
49
51
|
* warning.
|
|
50
52
|
*/
|
|
51
53
|
capture?: boolean;
|
|
@@ -117,6 +119,26 @@ export function savedCopyFooterSegment(id: string, savedAt: number, now = Date.n
|
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The footer's one form for company data a pane shows from another listing
|
|
124
|
+
* of the company: a receipt or a foreign line (SKHY, HY9H in Frankfurt) reads
|
|
125
|
+
* its home line's news, filings or holders, `company data from 000660 KRX`.
|
|
126
|
+
* Null when the data is the listing's own.
|
|
127
|
+
*/
|
|
128
|
+
export function companyDataFooterSegment(from: CompanyFrom | null | undefined): PaneFooterSegment | null {
|
|
129
|
+
if (!from) return null;
|
|
130
|
+
const listing = companyFromLabel(from);
|
|
131
|
+
const text = tf("company data from {listing}", { listing });
|
|
132
|
+
return {
|
|
133
|
+
id: "company-data",
|
|
134
|
+
title: from.name ? `${text} (${from.name})` : text,
|
|
135
|
+
// A capture of the rows keeps it: the picture cannot say whose they are.
|
|
136
|
+
capture: true,
|
|
137
|
+
parts: [{ text, tone: "muted" }],
|
|
138
|
+
variants: [[{ text: tf("from {listing}", { listing }), tone: "muted" }]],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
/** A pane whose market data is all delayed, by no one figure: the clock alone, its tooltip saying so. */
|
|
121
143
|
export const DELAYED_SEGMENT: PaneFooterSegment = delayedFooterSegment({ id: "delayed", detail: "Delayed market data" });
|
|
122
144
|
|
|
@@ -202,6 +224,25 @@ const INFO_FLOOR_CHARS = 10;
|
|
|
202
224
|
const WARNING_FLOOR_CHARS = 24;
|
|
203
225
|
const HINT_GAP = 1;
|
|
204
226
|
|
|
227
|
+
/**
|
|
228
|
+
* What the footer draws between two info segments, muted, as a status draws
|
|
229
|
+
* between its own parts ("Newest story Mon 14:23 · 12h delayed"): two
|
|
230
|
+
* segments side by side never read as one sentence ("company data from PBR ·
|
|
231
|
+
* as of 2026-06-30"). An icon (`⚠[!]`, `◷15m`) is a mark, not a phrase: a
|
|
232
|
+
* column sets it apart.
|
|
233
|
+
*/
|
|
234
|
+
export const PANE_FOOTER_SEPARATOR = " · ";
|
|
235
|
+
const SEPARATOR_WIDTH = displayWidth(PANE_FOOTER_SEPARATOR);
|
|
236
|
+
|
|
237
|
+
/** Whether the footer draws {@link PANE_FOOTER_SEPARATOR} between two adjacent segments, else a one-column gap. */
|
|
238
|
+
export function separatesPaneFooterSegments(before: Pick<PaneFooterSegment, "icon">, after: Pick<PaneFooterSegment, "icon">): boolean {
|
|
239
|
+
return !before.icon && !after.icon;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function segmentGapWidth(before: PaneFooterSegment, after: PaneFooterSegment): number {
|
|
243
|
+
return separatesPaneFooterSegments(before, after) ? SEPARATOR_WIDTH : 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
function paneHintWidth(hint: Pick<PaneHint, "key" | "label">, prefix = ""): number {
|
|
206
247
|
return getShortcutHintWidth(hint.key, hint.label, prefix);
|
|
207
248
|
}
|
|
@@ -216,15 +257,12 @@ function infoTextWidth(segments: readonly PaneFooterSegment[]): number {
|
|
|
216
257
|
const text = segment.parts.reduce((sum, part, partIndex) => (
|
|
217
258
|
sum + (partIndex > 0 ? 1 : 0) + displayWidth(part.text)
|
|
218
259
|
), 0);
|
|
219
|
-
return total + (index > 0 ? 1 : 0) + text;
|
|
260
|
+
return total + (index > 0 ? segmentGapWidth(segments[index - 1]!, segment) : 0) + text;
|
|
220
261
|
}, 0);
|
|
221
262
|
}
|
|
222
263
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
* together, one variant at a time, until the row fits or none is left.
|
|
226
|
-
*/
|
|
227
|
-
export function fitPaneFooterInfo(info: PaneFooterSegment[], width: number, { besideHints = true }: { besideHints?: boolean } = {}): PaneFooterSegment[] {
|
|
264
|
+
/** Segments with `variants` step down together, one variant at a time, until they fit or none is left. */
|
|
265
|
+
function stepDownTogether(info: PaneFooterSegment[], width: number): PaneFooterSegment[] {
|
|
228
266
|
const stages = Math.max(0, ...info.map((segment) => segment.variants?.length ?? 0));
|
|
229
267
|
let fitted = info;
|
|
230
268
|
for (let stage = 0; stage < stages && infoTextWidth(fitted) > width; stage += 1) {
|
|
@@ -237,20 +275,50 @@ export function fitPaneFooterInfo(info: PaneFooterSegment[], width: number, { be
|
|
|
237
275
|
} : segment;
|
|
238
276
|
});
|
|
239
277
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
278
|
+
return fitted;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A trailing status cut mid-word reads as part of the hints beside it ("quote
|
|
283
|
+
* 12h t[x] put"): one that does not fit ends in an ellipsis a column short of
|
|
284
|
+
* them, or is left out when less than half of it would show, until the row
|
|
285
|
+
* fits or `keep` segments are left. A warning, an icon or a pressable segment
|
|
286
|
+
* stays as it is, clipped by the row if it must be.
|
|
287
|
+
*/
|
|
288
|
+
function trimTrailing(info: PaneFooterSegment[], width: number, keep: number): PaneFooterSegment[] {
|
|
289
|
+
let fitted = info;
|
|
290
|
+
while (fitted.length > keep && infoTextWidth(fitted) > width) {
|
|
245
291
|
const last = fitted.at(-1)!;
|
|
246
292
|
if (last.icon || last.onPress || last.parts.some((part) => part.tone === "warning" || part.tone === "negative")) break;
|
|
247
|
-
|
|
293
|
+
// After the separator, a column short of the hints.
|
|
294
|
+
const room = width - infoTextWidth(fitted.slice(0, -1)) - segmentGapWidth(fitted.at(-2)!, last) - 1;
|
|
248
295
|
if (room * 2 >= infoTextWidth([last])) return [...fitted.slice(0, -1), clipSegment(last, room)];
|
|
249
296
|
fitted = fitted.slice(0, -1);
|
|
250
297
|
}
|
|
251
298
|
return fitted;
|
|
252
299
|
}
|
|
253
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The info segments as they fit `width`: segments with `variants` step down
|
|
303
|
+
* together, one variant at a time, until the row fits or none is left; beside
|
|
304
|
+
* the hints, the trailing status that still does not fit is then cut or left
|
|
305
|
+
* out (trimTrailing). A status after every segment with shorter forms gives
|
|
306
|
+
* way first, before any of them shortens: a pane puts what goes first last
|
|
307
|
+
* (an update time after a note that explains the row in hand).
|
|
308
|
+
*
|
|
309
|
+
* With no hints beside it (a pane picture's footer), the row clips the rest,
|
|
310
|
+
* so a frame that fits its own status line sees it overflow.
|
|
311
|
+
*/
|
|
312
|
+
export function fitPaneFooterInfo(info: PaneFooterSegment[], width: number, { besideHints = true }: { besideHints?: boolean } = {}): PaneFooterSegment[] {
|
|
313
|
+
if (!besideHints) return stepDownTogether(info, width);
|
|
314
|
+
const lastWithVariants = info.findLastIndex((segment) => (segment.variants?.length ?? 0) > 0);
|
|
315
|
+
if (lastWithVariants >= 0 && lastWithVariants < info.length - 1 && infoTextWidth(info) > width) {
|
|
316
|
+
const trimmed = trimTrailing(info, width, lastWithVariants + 1);
|
|
317
|
+
if (infoTextWidth(trimmed) <= width) return trimmed;
|
|
318
|
+
}
|
|
319
|
+
return trimTrailing(stepDownTogether(info, width), width, 1);
|
|
320
|
+
}
|
|
321
|
+
|
|
254
322
|
/** A segment cut to `width` behind an ellipsis, each part keeping its tone; the full text stays as its title. */
|
|
255
323
|
function clipSegment(segment: PaneFooterSegment, width: number): PaneFooterSegment {
|
|
256
324
|
const full = segment.parts.map((part) => part.text).join(" ");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { usePaneFooter, type PaneFooterSegment, type PaneHint } from "./footer";
|
|
1
|
+
import { useId, useMemo } from "react";
|
|
2
|
+
import { companyDataFooterSegment, usePaneFooter, type PaneFooterSegment, type PaneHint } from "./footer";
|
|
3
|
+
import type { CompanyFrom } from "../../../types/company-from";
|
|
4
|
+
import { useAppLanguage } from "../../../i18n/react";
|
|
3
5
|
import { useExternalLinkFooter } from "../../use-external-link-footer";
|
|
4
6
|
import { loadingErrorFooterInfo } from "../../data-table/table-pane";
|
|
5
7
|
|
|
@@ -28,6 +30,37 @@ function buildPaneStatusInfo({
|
|
|
28
30
|
return [...info, ...(stale ? [STALE_SEGMENT] : []), ...loadingErrorFooterInfo(loading, error)];
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Where the company data note sits: after the warning indicator (-10) and
|
|
35
|
+
* before the pane's own status (0 and the ticker quote's -1), so it leads what
|
|
36
|
+
* the pane says about its data and a narrow footer cuts that status first.
|
|
37
|
+
*/
|
|
38
|
+
const COMPANY_DATA_ORDER = -5;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A company pane on a receipt or a foreign line that shows its company's data
|
|
42
|
+
* from the home line says which: `company data from 000660 KRX · as of
|
|
43
|
+
* 2026-06-30`, before the pane's own status (after a warning). Nothing when
|
|
44
|
+
* the data is the listing's own. Every company pane registers it here, so it
|
|
45
|
+
* reads, looks and sits the same in each.
|
|
46
|
+
*/
|
|
47
|
+
export function useCompanyDataFooter(companyFrom: CompanyFrom | null | undefined, enabled = true) {
|
|
48
|
+
const language = useAppLanguage();
|
|
49
|
+
const segment = useMemo(() => companyDataFooterSegment(companyFrom), [
|
|
50
|
+
language,
|
|
51
|
+
companyFrom?.symbol,
|
|
52
|
+
companyFrom?.exchange,
|
|
53
|
+
companyFrom?.name,
|
|
54
|
+
]);
|
|
55
|
+
// Its own registration, so a view nested in a pane (13D/G under holders) never clears the pane's.
|
|
56
|
+
const id = useId();
|
|
57
|
+
usePaneFooter(
|
|
58
|
+
`company-data:${id}`,
|
|
59
|
+
() => enabled && segment ? { order: COMPANY_DATA_ORDER, info: [segment] } : null,
|
|
60
|
+
[enabled, segment],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
export function usePaneStatusFooter({
|
|
32
65
|
registrationId,
|
|
33
66
|
loading = false,
|
|
@@ -53,6 +53,15 @@ export function usePublishStatusWidgetRoom(columns: number): void {
|
|
|
53
53
|
}, []);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Extra columns the other widgets claim, for a widget that sizes itself to
|
|
58
|
+
* what they leave rather than run into them.
|
|
59
|
+
*/
|
|
60
|
+
export function useOtherStatusWidgetClaims(id: string): number {
|
|
61
|
+
const others = () => claimedColumns() - (claims.get(id) ?? 0);
|
|
62
|
+
return useSyncExternalStore(subscribe, others, others);
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/** The columns the widgets can use, as the status bar last published them. */
|
|
57
66
|
export function useStatusWidgetRoom(): number {
|
|
58
67
|
return useSyncExternalStore(subscribe, () => room, () => room);
|