gloomberb 0.8.0 → 0.8.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/README.md +102 -52
- package/package.json +1 -1
- package/src/api-client/chat.ts +8 -0
- package/src/api-client/index.test.ts +30 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/normalizers.ts +1 -0
- package/src/api-client/types.ts +1 -0
- package/src/capabilities/factories.ts +4 -0
- package/src/components/chart/comparison/data.test.ts +54 -0
- package/src/components/chart/comparison/data.ts +47 -24
- package/src/components/chart/core/projection.ts +51 -27
- package/src/components/data-table/stack-view.test.tsx +14 -11
- package/src/components/data-table/stack-view.tsx +1 -2
- package/src/components/data-table/view.test.tsx +24 -18
- package/src/components/data-table/view.tsx +313 -83
- package/src/components/feed-data-table/stack-view.tsx +6 -8
- package/src/components/layout/pane/footer/index.test.tsx +6 -0
- package/src/components/layout/pane/footer/index.tsx +14 -2
- package/src/components/layout/pane/title.ts +5 -1
- package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
- package/src/components/pane-settings-dialog/tui.tsx +1 -1
- package/src/components/ticker/list-table-view.tsx +11 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/shortcut-hint.tsx +14 -2
- package/src/components/ui/table-layout.test.ts +2 -2
- package/src/components/ui/table-layout.ts +8 -2
- package/src/components/ui/tabs.tsx +20 -6
- package/src/components/ui/ui.test.tsx +30 -5
- package/src/market-data/coordinator/auxiliary.ts +29 -1
- package/src/market-data/coordinator/index.ts +17 -1
- package/src/market-data/hooks.tsx +17 -1
- package/src/market-data/selectors.ts +4 -0
- package/src/plugins/builtin/account-management/form-components.tsx +0 -4
- package/src/plugins/builtin/account-management/model.test.ts +54 -0
- package/src/plugins/builtin/account-management/model.ts +13 -3
- package/src/plugins/builtin/account-management/pane.tsx +5 -11
- package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
- package/src/plugins/builtin/alerts/pane.tsx +5 -3
- package/src/plugins/builtin/analytics/index.tsx +0 -3
- package/src/plugins/builtin/analytics/view.tsx +6 -5
- package/src/plugins/builtin/broker-manager/index.tsx +6 -6
- package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
- package/src/plugins/builtin/changelog/index.tsx +40 -27
- package/src/plugins/builtin/changelog/model.test.ts +77 -0
- package/src/plugins/builtin/changelog/model.ts +98 -0
- package/src/plugins/builtin/chat/channel-labels.ts +53 -0
- package/src/plugins/builtin/chat/channels.test.ts +28 -0
- package/src/plugins/builtin/chat/channels.ts +36 -19
- package/src/plugins/builtin/chat/chat.test.tsx +264 -5
- package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
- package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
- package/src/plugins/builtin/chat/content/composer.tsx +23 -2
- package/src/plugins/builtin/chat/content/index.tsx +61 -0
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
- package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
- package/src/plugins/builtin/chat/content/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/index.ts +41 -0
- package/src/plugins/builtin/chat/controller/messages.ts +18 -0
- package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
- package/src/plugins/builtin/chat/controller/utils.ts +6 -3
- package/src/plugins/builtin/chat/controller/view.ts +1 -1
- package/src/plugins/builtin/chat/controller.test.ts +189 -0
- package/src/plugins/builtin/chat/edit-window.ts +24 -0
- package/src/plugins/builtin/chat/layout.ts +94 -31
- package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
- package/src/plugins/builtin/chat/message/render-state.ts +5 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
- package/src/plugins/builtin/chat/message/types.ts +2 -0
- package/src/plugins/builtin/chat/pane.tsx +35 -7
- package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
- package/src/plugins/builtin/chat/status-widget.tsx +29 -11
- package/src/plugins/builtin/chat/test-harness.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
- package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
- package/src/plugins/builtin/earnings/index.tsx +8 -7
- package/src/plugins/builtin/econ/index.tsx +6 -8
- package/src/plugins/builtin/help/index.tsx +6 -0
- package/src/plugins/builtin/holders/pane.tsx +7 -6
- package/src/plugins/builtin/market-movers/index.tsx +6 -3
- package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
- package/src/plugins/builtin/options/view.tsx +16 -8
- package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
- package/src/plugins/builtin/research/index.test.ts +30 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
- package/src/plugins/builtin/sec/filing-documents.ts +66 -0
- package/src/plugins/builtin/sec/index.tsx +132 -20
- package/src/plugins/builtin/sectors/index.tsx +13 -23
- package/src/plugins/builtin/shared/ticker-request.ts +1 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/prediction-markets/pane.tsx +10 -9
- package/src/plugins/registry/index.ts +3 -0
- package/src/plugins/runtime/context.tsx +1 -0
- package/src/plugins/runtime/index.tsx +4 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
- package/src/renderers/electrobun/view/host/input.tsx +23 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -0
- package/src/sources/provider-router/cache.ts +1 -0
- package/src/sources/provider-router/document-routes.ts +45 -0
- package/src/sources/provider-router/index.ts +4 -0
- package/src/sources/sec-edgar/content.ts +9 -3
- package/src/sources/sec-edgar.test.ts +108 -1
- package/src/sources/sec-edgar.ts +105 -1
- package/src/sources/yahoo-finance.ts +4 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Portfolio, TickerRecord } from "../../../types/ticker";
|
|
3
|
+
import { buildPortfolioChoices, countPortfolioHoldings, NO_PORTFOLIO_VALUE } from "./model";
|
|
4
|
+
|
|
5
|
+
function makeTicker(symbol: string, portfolios: string[]): TickerRecord {
|
|
6
|
+
return {
|
|
7
|
+
metadata: {
|
|
8
|
+
ticker: symbol,
|
|
9
|
+
exchange: "XNAS",
|
|
10
|
+
currency: "USD",
|
|
11
|
+
name: symbol,
|
|
12
|
+
portfolios,
|
|
13
|
+
watchlists: [],
|
|
14
|
+
positions: [],
|
|
15
|
+
custom: {},
|
|
16
|
+
tags: [],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("account management model", () => {
|
|
22
|
+
test("counts portfolio holdings from app ticker state", () => {
|
|
23
|
+
const tickers = new Map([
|
|
24
|
+
["AAPL", makeTicker("AAPL", ["main"])],
|
|
25
|
+
["MSFT", makeTicker("MSFT", ["main", "income"])],
|
|
26
|
+
["SPY", makeTicker("SPY", [])],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
expect(countPortfolioHoldings(tickers)).toEqual({ main: 2, income: 1 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("describes public portfolio analytics in the shared portfolio picker", () => {
|
|
33
|
+
const portfolios: Portfolio[] = [
|
|
34
|
+
{ id: "main", name: "Main Portfolio", currency: "USD" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const choices = buildPortfolioChoices(portfolios, { main: 2 });
|
|
38
|
+
|
|
39
|
+
expect(choices).toEqual([
|
|
40
|
+
{
|
|
41
|
+
id: NO_PORTFOLIO_VALUE,
|
|
42
|
+
label: "None",
|
|
43
|
+
detail: "Off",
|
|
44
|
+
description: "Do not show portfolio analytics on your public profile.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "main",
|
|
48
|
+
label: "Main Portfolio",
|
|
49
|
+
detail: "2 tickers",
|
|
50
|
+
description: "Shares this portfolio's YTD % and SPY Beta on your public profile.",
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChoiceDialogChoice } from "../../../components";
|
|
2
2
|
import type { AccountProfile } from "../../../api-client";
|
|
3
|
-
import type { Portfolio } from "../../../types/ticker";
|
|
3
|
+
import type { Portfolio, TickerRecord } from "../../../types/ticker";
|
|
4
4
|
|
|
5
5
|
export type AccountFieldKey =
|
|
6
6
|
| "username"
|
|
@@ -75,14 +75,24 @@ export function formatPlan(plan: AccountProfile["plan"] | null | undefined): str
|
|
|
75
75
|
return plan === "pro" ? "Pro" : "Free";
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
export function countPortfolioHoldings(tickers: ReadonlyMap<string, TickerRecord>): Record<string, number> {
|
|
79
|
+
const counts: Record<string, number> = {};
|
|
80
|
+
for (const record of tickers.values()) {
|
|
81
|
+
for (const portfolioId of record.metadata.portfolios) {
|
|
82
|
+
counts[portfolioId] = (counts[portfolioId] ?? 0) + 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return counts;
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
export function buildPortfolioChoices(portfolios: Portfolio[], holdingCounts: Record<string, number>): ChoiceDialogChoice[] {
|
|
79
89
|
return [
|
|
80
|
-
{ id: NO_PORTFOLIO_VALUE, label: "None", detail: "Off", description: "
|
|
90
|
+
{ id: NO_PORTFOLIO_VALUE, label: "None", detail: "Off", description: "Do not show portfolio analytics on your public profile." },
|
|
81
91
|
...portfolios.map((portfolio) => ({
|
|
82
92
|
id: portfolio.id,
|
|
83
93
|
label: portfolio.name,
|
|
84
94
|
detail: `${holdingCounts[portfolio.id] ?? 0} tickers`,
|
|
85
|
-
description: portfolio
|
|
95
|
+
description: "Shares this portfolio's YTD % and SPY Beta on your public profile.",
|
|
86
96
|
})),
|
|
87
97
|
];
|
|
88
98
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
BASE_FIELD_ORDER,
|
|
14
14
|
NO_PORTFOLIO_VALUE,
|
|
15
15
|
buildPortfolioChoices,
|
|
16
|
+
countPortfolioHoldings,
|
|
16
17
|
emptyToNull,
|
|
17
18
|
portfolioOptionIds,
|
|
18
19
|
profileToDraft,
|
|
@@ -49,15 +50,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
49
50
|
const bodyHeight = Math.max(5, height);
|
|
50
51
|
const fieldOrder = BASE_FIELD_ORDER;
|
|
51
52
|
|
|
52
|
-
const portfolioHoldingCounts = useMemo(() =>
|
|
53
|
-
const counts: Record<string, number> = {};
|
|
54
|
-
for (const record of Object.values(tickers)) {
|
|
55
|
-
for (const portfolioId of record.metadata.portfolios) {
|
|
56
|
-
counts[portfolioId] = (counts[portfolioId] ?? 0) + 1;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return counts;
|
|
60
|
-
}, [tickers]);
|
|
53
|
+
const portfolioHoldingCounts = useMemo(() => countPortfolioHoldings(tickers), [tickers]);
|
|
61
54
|
const portfolioChoices = useMemo(
|
|
62
55
|
() => buildPortfolioChoices(portfolios, portfolioHoldingCounts),
|
|
63
56
|
[portfolioHoldingCounts, portfolios],
|
|
@@ -138,6 +131,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
138
131
|
|
|
139
132
|
const openPortfolioDialog = useCallback(async () => {
|
|
140
133
|
setActiveField("sharedPortfolioId");
|
|
134
|
+
const currentPortfolioChoiceId = draftRef.current.sharedPortfolioId || NO_PORTFOLIO_VALUE;
|
|
141
135
|
const selected = await dialog.prompt<string>({
|
|
142
136
|
closeOnClickOutside: false,
|
|
143
137
|
content: (context: PromptContext<string>) => (
|
|
@@ -145,7 +139,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
145
139
|
{...context}
|
|
146
140
|
title="Shared Portfolio"
|
|
147
141
|
choices={portfolioChoices}
|
|
148
|
-
|
|
142
|
+
selectedChoiceId={currentPortfolioChoiceId}
|
|
149
143
|
/>
|
|
150
144
|
),
|
|
151
145
|
}).catch(() => "");
|
|
@@ -365,7 +359,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
365
359
|
<PickerRow
|
|
366
360
|
label="Profile Analytics"
|
|
367
361
|
value={selectedPortfolioLabel(portfolios, draft.sharedPortfolioId)}
|
|
368
|
-
detail={draft.sharedPortfolioId ? "YTD% +
|
|
362
|
+
detail={draft.sharedPortfolioId ? "Shares portfolio YTD % + SPY Beta" : "No public portfolio analytics"}
|
|
369
363
|
active={activeField === "sharedPortfolioId"}
|
|
370
364
|
width={formWidth}
|
|
371
365
|
onFocus={() => setActiveField("sharedPortfolioId")}
|
|
@@ -78,7 +78,7 @@ export function PasswordChangeDialog({
|
|
|
78
78
|
|
|
79
79
|
const fieldWidth = 42;
|
|
80
80
|
return (
|
|
81
|
-
<DialogFrame title="Change Password"
|
|
81
|
+
<DialogFrame title="Change Password">
|
|
82
82
|
<Box flexDirection="column" gap={1}>
|
|
83
83
|
<TextField
|
|
84
84
|
label={activeField === "current" ? "> Current Password" : " Current Password"}
|
|
@@ -279,7 +279,11 @@ export function AlertsPane({ focused, width, height, close }: PaneProps) {
|
|
|
279
279
|
return (
|
|
280
280
|
<DataTableView<AlertRule, AlertColumn>
|
|
281
281
|
focused={focused}
|
|
282
|
-
|
|
282
|
+
selection={{
|
|
283
|
+
kind: "index",
|
|
284
|
+
selectedIndex: selectedIdx,
|
|
285
|
+
onChange: (index) => setSelectedIdx(index),
|
|
286
|
+
}}
|
|
283
287
|
onRootKeyDown={handleTableKeyDown}
|
|
284
288
|
rootWidth={width}
|
|
285
289
|
rootHeight={height}
|
|
@@ -290,8 +294,6 @@ export function AlertsPane({ focused, width, height, close }: PaneProps) {
|
|
|
290
294
|
sortDirection="asc"
|
|
291
295
|
onHeaderClick={() => {}}
|
|
292
296
|
getItemKey={(alert) => alert.id}
|
|
293
|
-
isSelected={(_alert, index) => index === selectedIdx}
|
|
294
|
-
onSelect={(_alert, index) => setSelectedIdx(index)}
|
|
295
297
|
onActivate={(alert) => {
|
|
296
298
|
if (alert.status === "triggered") rearmAlert(alert.id);
|
|
297
299
|
}}
|
|
@@ -198,8 +198,6 @@ function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
|
|
|
198
198
|
const effectiveSelectedSectorId = selectedSectorId && sortedSectorRows.some((row) => row.id === selectedSectorId)
|
|
199
199
|
? selectedSectorId
|
|
200
200
|
: sortedSectorRows[0]?.id ?? null;
|
|
201
|
-
const selectedSectorIdx = sortedSectorRows.findIndex((row) => row.id === effectiveSelectedSectorId);
|
|
202
|
-
const safeSelectedSectorIdx = selectedSectorIdx >= 0 ? selectedSectorIdx : 0;
|
|
203
201
|
const sectorColumns = useMemo(() => buildSectorColumns(width), [width]);
|
|
204
202
|
const hasPositions = portfolioTickers.length > 0;
|
|
205
203
|
|
|
@@ -302,7 +300,6 @@ function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
|
|
|
302
300
|
|
|
303
301
|
<SectorAllocationTable
|
|
304
302
|
focused={focused}
|
|
305
|
-
selectedIndex={safeSelectedSectorIdx}
|
|
306
303
|
resetScrollKey={activePortfolioId}
|
|
307
304
|
columns={sectorColumns}
|
|
308
305
|
rows={sortedSectorRows}
|
|
@@ -143,7 +143,6 @@ export function PortfolioHistorySection({
|
|
|
143
143
|
|
|
144
144
|
export function SectorAllocationTable({
|
|
145
145
|
focused,
|
|
146
|
-
selectedIndex,
|
|
147
146
|
resetScrollKey,
|
|
148
147
|
columns,
|
|
149
148
|
rows,
|
|
@@ -153,7 +152,6 @@ export function SectorAllocationTable({
|
|
|
153
152
|
onSelectSector,
|
|
154
153
|
}: {
|
|
155
154
|
focused: boolean;
|
|
156
|
-
selectedIndex: number;
|
|
157
155
|
resetScrollKey: string;
|
|
158
156
|
columns: SectorTableColumn[];
|
|
159
157
|
rows: SectorTableRow[];
|
|
@@ -165,7 +163,12 @@ export function SectorAllocationTable({
|
|
|
165
163
|
return (
|
|
166
164
|
<DataTableView<SectorTableRow, SectorTableColumn>
|
|
167
165
|
focused={focused}
|
|
168
|
-
|
|
166
|
+
selection={{
|
|
167
|
+
kind: "id",
|
|
168
|
+
selectedId: selectedSectorId,
|
|
169
|
+
getId: (row) => row.id,
|
|
170
|
+
onChange: (id) => onSelectSector(id),
|
|
171
|
+
}}
|
|
169
172
|
resetScrollKey={resetScrollKey}
|
|
170
173
|
columns={columns}
|
|
171
174
|
items={rows}
|
|
@@ -173,8 +176,6 @@ export function SectorAllocationTable({
|
|
|
173
176
|
sortDirection={sort.direction}
|
|
174
177
|
onHeaderClick={onHeaderClick}
|
|
175
178
|
getItemKey={(row) => row.id}
|
|
176
|
-
isSelected={(row) => selectedSectorId === row.id}
|
|
177
|
-
onSelect={(row) => onSelectSector(row.id)}
|
|
178
179
|
emptyStateTitle="No sector data available"
|
|
179
180
|
emptyStateHint="Load profile data or add sectors to the portfolio positions."
|
|
180
181
|
renderCell={(row, column) => {
|
|
@@ -276,18 +276,18 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
|
|
|
276
276
|
detailTitle={selectedRow?.label}
|
|
277
277
|
rootWidth={tableWidth}
|
|
278
278
|
rootHeight={bodyHeight}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
selection={{
|
|
280
|
+
kind: "index",
|
|
281
|
+
selectedIndex: Math.min(selectedIndex, Math.max(0, rows.length - 1)),
|
|
282
|
+
onChange: (index, row) => selectBrokerRow(index, row),
|
|
283
|
+
}}
|
|
284
|
+
onActivate={(row, index) => openSelectedDetail(index, row)}
|
|
282
285
|
columns={columns}
|
|
283
286
|
items={rows}
|
|
284
287
|
sortColumnId={null}
|
|
285
288
|
sortDirection="asc"
|
|
286
289
|
onHeaderClick={() => {}}
|
|
287
290
|
getItemKey={(row) => row.id}
|
|
288
|
-
isSelected={(row) => selectedRow?.id === row.id}
|
|
289
|
-
onSelect={(row, index) => selectBrokerRow(index, row)}
|
|
290
|
-
onActivate={(row, index) => openSelectedDetail(index, row)}
|
|
291
291
|
renderCell={renderBrokerCell}
|
|
292
292
|
emptyStateTitle="No broker profiles."
|
|
293
293
|
emptyStateHint="Add a broker profile to test connections and sync positions."
|
|
@@ -373,16 +373,16 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
|
|
|
373
373
|
)}
|
|
374
374
|
columns={columns}
|
|
375
375
|
items={rows}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
376
|
+
selection={{
|
|
377
|
+
kind: "index",
|
|
378
|
+
selectedIndex,
|
|
379
|
+
onChange: (index) => setSelectedIndex(index),
|
|
380
|
+
}}
|
|
381
|
+
onActivate={activateRow}
|
|
379
382
|
sortColumnId={sortColumnId}
|
|
380
383
|
sortDirection={sortDirection}
|
|
381
384
|
onHeaderClick={handleHeaderClick}
|
|
382
385
|
getItemKey={rowKey}
|
|
383
|
-
isSelected={(row) => selectedRow ? rowKey(row) === rowKey(selectedRow) : false}
|
|
384
|
-
onSelect={(_row, index) => setSelectedIndex(index)}
|
|
385
|
-
onActivate={activateRow}
|
|
386
386
|
renderCell={renderCell}
|
|
387
387
|
bodyAfter={hiddenCompanyCount > 0 ? (
|
|
388
388
|
<CompaniesUpgradeCta
|
|
@@ -16,11 +16,16 @@ import { fetchChangelogReleases, type ChangelogRelease } from "../../../updater/
|
|
|
16
16
|
import { colors } from "../../../theme/colors";
|
|
17
17
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
18
18
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_CHANGELOG_SORT,
|
|
21
|
+
nextChangelogSortPreference,
|
|
22
|
+
resolveSelectedReleaseIndex,
|
|
23
|
+
sortChangelogReleases,
|
|
24
|
+
type ChangelogColumnId,
|
|
25
|
+
} from "./model";
|
|
20
26
|
|
|
21
27
|
const CHANGELOG_LIMIT = 40;
|
|
22
28
|
|
|
23
|
-
type ChangelogColumnId = "date" | "version" | "title";
|
|
24
29
|
type ChangelogColumn = DataTableColumn & { id: ChangelogColumnId };
|
|
25
30
|
type LoadStatus = "idle" | "loading" | "loaded" | "error";
|
|
26
31
|
|
|
@@ -103,7 +108,8 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
103
108
|
const [releases, setReleases] = useState<ChangelogRelease[]>([]);
|
|
104
109
|
const [status, setStatus] = useState<LoadStatus>("idle");
|
|
105
110
|
const [error, setError] = useState<string | null>(null);
|
|
106
|
-
const [
|
|
111
|
+
const [selectedReleaseId, setSelectedReleaseId] = useState<string | null>(null);
|
|
112
|
+
const [sortPreference, setSortPreference] = useState(DEFAULT_CHANGELOG_SORT);
|
|
107
113
|
const [openReleaseId, setOpenReleaseId] = useState<string | null>(null);
|
|
108
114
|
const detailScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
109
115
|
const abortRef = useRef<AbortController | null>(null);
|
|
@@ -143,11 +149,18 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
143
149
|
};
|
|
144
150
|
}, [loadReleases]);
|
|
145
151
|
|
|
152
|
+
const sortedReleases = useMemo(
|
|
153
|
+
() => sortChangelogReleases(releases, sortPreference),
|
|
154
|
+
[releases, sortPreference],
|
|
155
|
+
);
|
|
156
|
+
const activeSelectedIdx = resolveSelectedReleaseIndex(sortedReleases, selectedReleaseId);
|
|
157
|
+
const activeSelectedReleaseId = sortedReleases[activeSelectedIdx]?.id ?? null;
|
|
158
|
+
|
|
146
159
|
useEffect(() => {
|
|
147
|
-
if (
|
|
148
|
-
|
|
160
|
+
if (activeSelectedReleaseId !== selectedReleaseId) {
|
|
161
|
+
setSelectedReleaseId(activeSelectedReleaseId);
|
|
149
162
|
}
|
|
150
|
-
}, [
|
|
163
|
+
}, [activeSelectedReleaseId, selectedReleaseId]);
|
|
151
164
|
|
|
152
165
|
const openRelease = useMemo(
|
|
153
166
|
() =>
|
|
@@ -177,9 +190,6 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
177
190
|
});
|
|
178
191
|
|
|
179
192
|
const columns = useMemo(() => buildColumns(width, releases), [releases, width]);
|
|
180
|
-
const activeSelectedIdx = releases.length > 0
|
|
181
|
-
? Math.max(0, Math.min(selectedIdx, releases.length - 1))
|
|
182
|
-
: -1;
|
|
183
193
|
|
|
184
194
|
const scrollDetailBy = useCallback((delta: number) => {
|
|
185
195
|
const scrollBox = detailScrollRef.current;
|
|
@@ -237,6 +247,14 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
237
247
|
}
|
|
238
248
|
}, []);
|
|
239
249
|
|
|
250
|
+
const handleHeaderClick = useCallback((columnId: string) => {
|
|
251
|
+
setSortPreference((current) => nextChangelogSortPreference(current, columnId));
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
const selectRelease = useCallback((release: ChangelogRelease) => {
|
|
255
|
+
setSelectedReleaseId(release.id);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
240
258
|
const footerInfo = useMemo<PaneFooterSegment[]>(() => {
|
|
241
259
|
const segments: PaneFooterSegment[] = [];
|
|
242
260
|
if (status === "loading") {
|
|
@@ -251,17 +269,8 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
251
269
|
parts: [{ text: "error", tone: "warning" }],
|
|
252
270
|
});
|
|
253
271
|
}
|
|
254
|
-
if (releases.length > 0) {
|
|
255
|
-
segments.push({
|
|
256
|
-
id: "versions",
|
|
257
|
-
parts: [
|
|
258
|
-
{ text: "versions", tone: "label" },
|
|
259
|
-
{ text: String(releases.length), tone: "value" },
|
|
260
|
-
],
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
272
|
return segments;
|
|
264
|
-
}, [
|
|
273
|
+
}, [status]);
|
|
265
274
|
|
|
266
275
|
const footerHints = useMemo<PaneHint[]>(() => [{
|
|
267
276
|
id: "refresh",
|
|
@@ -296,7 +305,7 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
296
305
|
);
|
|
297
306
|
}
|
|
298
307
|
|
|
299
|
-
if (
|
|
308
|
+
if (sortedReleases.length === 0) {
|
|
300
309
|
return (
|
|
301
310
|
<Box paddingX={1} paddingY={1}>
|
|
302
311
|
<Text fg={colors.textDim}>No changelog entries found.</Text>
|
|
@@ -319,18 +328,22 @@ function ChangelogPane({ focused, width, height }: PaneProps) {
|
|
|
319
328
|
<Box flexGrow={1} />
|
|
320
329
|
)}
|
|
321
330
|
detailTitle={openRelease ? releaseDetailTitle(openRelease) : undefined}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
selection={{
|
|
332
|
+
kind: "id",
|
|
333
|
+
selectedId: activeSelectedReleaseId,
|
|
334
|
+
getId: (release) => release.id,
|
|
335
|
+
onChange: (_id, release) => selectRelease(release),
|
|
336
|
+
}}
|
|
337
|
+
onActivate={(release) => setOpenReleaseId(release.id)}
|
|
325
338
|
onDetailKeyDown={handleDetailKeyDown}
|
|
326
339
|
rootWidth={width}
|
|
327
340
|
rootHeight={height}
|
|
328
341
|
columns={columns}
|
|
329
|
-
items={
|
|
342
|
+
items={sortedReleases}
|
|
343
|
+
sortColumnId={sortPreference.columnId}
|
|
344
|
+
sortDirection={sortPreference.direction}
|
|
345
|
+
onHeaderClick={handleHeaderClick}
|
|
330
346
|
getItemKey={(release) => release.id}
|
|
331
|
-
isSelected={(_release, index) => index === activeSelectedIdx}
|
|
332
|
-
onSelect={(_release, index) => setSelectedIdx(index)}
|
|
333
|
-
onActivate={(release) => setOpenReleaseId(release.id)}
|
|
334
347
|
renderCell={renderCell}
|
|
335
348
|
emptyStateTitle="No changelog entries."
|
|
336
349
|
showHorizontalScrollbar={false}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChangelogRelease } from "../../../updater/github-releases";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_CHANGELOG_SORT,
|
|
5
|
+
nextChangelogSortPreference,
|
|
6
|
+
resolveSelectedReleaseIndex,
|
|
7
|
+
sortChangelogReleases,
|
|
8
|
+
} from "./model";
|
|
9
|
+
|
|
10
|
+
function release(
|
|
11
|
+
id: string,
|
|
12
|
+
version: string,
|
|
13
|
+
title: string,
|
|
14
|
+
publishedAt: string,
|
|
15
|
+
): ChangelogRelease {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
tagName: version,
|
|
19
|
+
version,
|
|
20
|
+
title,
|
|
21
|
+
publishedAt,
|
|
22
|
+
body: "",
|
|
23
|
+
url: "",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("changelog table model", () => {
|
|
28
|
+
const releases = [
|
|
29
|
+
release("8", "v0.8.0", "Desktop fixes", "2026-05-20T10:00:00Z"),
|
|
30
|
+
release("10", "v0.10.0", "Accounts", "2026-05-22T10:00:00Z"),
|
|
31
|
+
release("9", "v0.9.0", "Buildout", "2026-05-21T10:00:00Z"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
test("sorts releases by newest date by default", () => {
|
|
35
|
+
expect(
|
|
36
|
+
sortChangelogReleases(releases, DEFAULT_CHANGELOG_SORT).map((entry) => entry.id),
|
|
37
|
+
)
|
|
38
|
+
.toEqual(["10", "9", "8"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("sorts version tags numerically", () => {
|
|
42
|
+
expect(
|
|
43
|
+
sortChangelogReleases(
|
|
44
|
+
releases,
|
|
45
|
+
{ columnId: "version", direction: "asc" },
|
|
46
|
+
).map((entry) => entry.version),
|
|
47
|
+
)
|
|
48
|
+
.toEqual(["v0.8.0", "v0.9.0", "v0.10.0"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("cycles header sort direction without returning an undefined handler state", () => {
|
|
52
|
+
const versionSort = nextChangelogSortPreference(DEFAULT_CHANGELOG_SORT, "version");
|
|
53
|
+
expect(versionSort).toEqual({ columnId: "version", direction: "desc" });
|
|
54
|
+
expect(nextChangelogSortPreference(versionSort, "version"))
|
|
55
|
+
.toEqual({ columnId: "version", direction: "asc" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("ignores unknown header ids instead of creating broken sort state", () => {
|
|
59
|
+
expect(nextChangelogSortPreference(DEFAULT_CHANGELOG_SORT, "missing"))
|
|
60
|
+
.toBe(DEFAULT_CHANGELOG_SORT);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("resolves selected rows by stable release id after sorting", () => {
|
|
64
|
+
const sorted = sortChangelogReleases(
|
|
65
|
+
releases,
|
|
66
|
+
{ columnId: "title", direction: "asc" },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(sorted.map((entry) => entry.id)).toEqual(["10", "9", "8"]);
|
|
70
|
+
expect(resolveSelectedReleaseIndex(sorted, "8")).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("falls back to the first release when the selected id disappears", () => {
|
|
74
|
+
expect(resolveSelectedReleaseIndex(releases, "removed")).toBe(0);
|
|
75
|
+
expect(resolveSelectedReleaseIndex([], "removed")).toBe(-1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ChangelogRelease } from "../../../updater/github-releases";
|
|
2
|
+
import type { SortDirection } from "../../../utils/sort-values";
|
|
3
|
+
|
|
4
|
+
export type ChangelogColumnId = "date" | "version" | "title";
|
|
5
|
+
|
|
6
|
+
export interface ChangelogSortPreference {
|
|
7
|
+
columnId: ChangelogColumnId;
|
|
8
|
+
direction: SortDirection;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CHANGELOG_SORT: ChangelogSortPreference = {
|
|
12
|
+
columnId: "date",
|
|
13
|
+
direction: "desc",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function isChangelogColumnId(columnId: string): columnId is ChangelogColumnId {
|
|
17
|
+
return columnId === "date" || columnId === "version" || columnId === "title";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultDirection(columnId: ChangelogColumnId): SortDirection {
|
|
21
|
+
return columnId === "title" ? "asc" : "desc";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function releaseDateValue(release: ChangelogRelease): number {
|
|
25
|
+
const timestamp = Date.parse(release.publishedAt);
|
|
26
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compareText(left: string, right: string): number {
|
|
30
|
+
return left.localeCompare(right, undefined, {
|
|
31
|
+
numeric: true,
|
|
32
|
+
sensitivity: "base",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compareReleaseValue(
|
|
37
|
+
left: ChangelogRelease,
|
|
38
|
+
right: ChangelogRelease,
|
|
39
|
+
columnId: ChangelogColumnId,
|
|
40
|
+
): number {
|
|
41
|
+
switch (columnId) {
|
|
42
|
+
case "date":
|
|
43
|
+
return releaseDateValue(left) - releaseDateValue(right);
|
|
44
|
+
case "version":
|
|
45
|
+
return compareText(left.version, right.version);
|
|
46
|
+
case "title":
|
|
47
|
+
return compareText(left.title, right.title);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sortChangelogReleases(
|
|
52
|
+
releases: ChangelogRelease[],
|
|
53
|
+
sortPreference: ChangelogSortPreference,
|
|
54
|
+
): ChangelogRelease[] {
|
|
55
|
+
return releases
|
|
56
|
+
.map((release, index) => ({ release, index }))
|
|
57
|
+
.sort((left, right) => {
|
|
58
|
+
const comparison = compareReleaseValue(
|
|
59
|
+
left.release,
|
|
60
|
+
right.release,
|
|
61
|
+
sortPreference.columnId,
|
|
62
|
+
);
|
|
63
|
+
const directed = sortPreference.direction === "asc"
|
|
64
|
+
? comparison
|
|
65
|
+
: -comparison;
|
|
66
|
+
return directed || left.index - right.index;
|
|
67
|
+
})
|
|
68
|
+
.map((entry) => entry.release);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function nextChangelogSortPreference(
|
|
72
|
+
current: ChangelogSortPreference,
|
|
73
|
+
columnId: string,
|
|
74
|
+
): ChangelogSortPreference {
|
|
75
|
+
if (!isChangelogColumnId(columnId)) return current;
|
|
76
|
+
const nextColumnId = columnId;
|
|
77
|
+
if (current.columnId !== nextColumnId) {
|
|
78
|
+
return {
|
|
79
|
+
columnId: nextColumnId,
|
|
80
|
+
direction: defaultDirection(nextColumnId),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
columnId: nextColumnId,
|
|
85
|
+
direction: current.direction === "asc" ? "desc" : "asc",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveSelectedReleaseIndex(
|
|
90
|
+
releases: ChangelogRelease[],
|
|
91
|
+
selectedReleaseId: string | null,
|
|
92
|
+
): number {
|
|
93
|
+
const selectedIndex = selectedReleaseId
|
|
94
|
+
? releases.findIndex((release) => release.id === selectedReleaseId)
|
|
95
|
+
: -1;
|
|
96
|
+
if (selectedIndex >= 0) return selectedIndex;
|
|
97
|
+
return releases.length > 0 ? 0 : -1;
|
|
98
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ChatChannel } from "../../../api-client";
|
|
2
|
+
import { normalizeChannelId } from "./controller/state";
|
|
3
|
+
|
|
4
|
+
function normalizeMentionUsername(username: string | null | undefined): string | null {
|
|
5
|
+
const normalized = username?.trim().replace(/^@+/, "");
|
|
6
|
+
return normalized || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatMentionUsername(username: string | null | undefined): string | null {
|
|
10
|
+
const normalized = normalizeMentionUsername(username);
|
|
11
|
+
return normalized ? `@${normalized}` : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatDirectChannelLabel(channel: ChatChannel, fallbackId: string) {
|
|
15
|
+
const usernameLabel = formatMentionUsername(channel.dmUser?.username);
|
|
16
|
+
if (usernameLabel) return usernameLabel;
|
|
17
|
+
|
|
18
|
+
const displayName = channel.dmUser?.displayName?.trim();
|
|
19
|
+
if (displayName) return displayName;
|
|
20
|
+
|
|
21
|
+
const channelName = channel.name?.trim();
|
|
22
|
+
if (channelName && channelName !== channel.id) {
|
|
23
|
+
return channelName.startsWith("@")
|
|
24
|
+
? formatMentionUsername(channelName) ?? channelName
|
|
25
|
+
: channelName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return fallbackId.startsWith("dm:") ? "DM" : fallbackId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatChannelLabel(channel: ChatChannel | undefined, fallbackId: string) {
|
|
32
|
+
if (!channel) return fallbackId;
|
|
33
|
+
if (channel.kind === "direct") {
|
|
34
|
+
return formatDirectChannelLabel(channel, fallbackId);
|
|
35
|
+
}
|
|
36
|
+
if (channel.kind === "group") {
|
|
37
|
+
return channel.name?.trim() || "Group";
|
|
38
|
+
}
|
|
39
|
+
return channel.name?.trim() || fallbackId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatChatPaneTitle(channel: ChatChannel | undefined, fallbackId: string) {
|
|
43
|
+
const normalizedFallbackId = normalizeChannelId(fallbackId);
|
|
44
|
+
if (channel?.kind === "direct") {
|
|
45
|
+
return formatChannelLabel(channel, normalizedFallbackId);
|
|
46
|
+
}
|
|
47
|
+
if (channel?.kind === "group") {
|
|
48
|
+
return formatChannelLabel(channel, normalizedFallbackId);
|
|
49
|
+
}
|
|
50
|
+
if (!channel && normalizedFallbackId.startsWith("dm:")) return "DM";
|
|
51
|
+
if (!channel && (normalizedFallbackId.startsWith("grp:") || normalizedFallbackId.startsWith("group:"))) return "Group";
|
|
52
|
+
return `#${formatChannelLabel(channel, normalizedFallbackId)}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChatChannel } from "../../../api-client";
|
|
3
|
+
import {
|
|
4
|
+
channelPrefix,
|
|
5
|
+
formatChannelLabel,
|
|
6
|
+
formatChatPaneTitle,
|
|
7
|
+
} from "./channels";
|
|
8
|
+
|
|
9
|
+
describe("chat channel labels", () => {
|
|
10
|
+
test("formats direct-message labels and titles with one mention prefix", () => {
|
|
11
|
+
const channel: ChatChannel = {
|
|
12
|
+
id: "dm:c7f89ce26f9da83d6be08a4838738074",
|
|
13
|
+
name: "@risto",
|
|
14
|
+
kind: "direct",
|
|
15
|
+
created_at: "2026-05-27T10:30:03.712Z",
|
|
16
|
+
dmUser: { id: "u2", username: "@risto", displayName: "Risto" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(formatChannelLabel(channel, channel.id)).toBe("@risto");
|
|
20
|
+
expect(channelPrefix(channel, true)).toBe(" ");
|
|
21
|
+
expect(formatChatPaneTitle(channel, channel.id)).toBe("@risto");
|
|
22
|
+
expect(formatChatPaneTitle(undefined, channel.id)).toBe("DM");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("uses a neutral group title before private channel metadata loads", () => {
|
|
26
|
+
expect(formatChatPaneTitle(undefined, "grp:93f14c6b9827c30f")).toBe("Group");
|
|
27
|
+
});
|
|
28
|
+
});
|