gloomberb 0.3.1 → 0.3.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/package.json +1 -1
- package/src/app.tsx +41 -6
- package/src/components/command-bar/command-bar.test.tsx +22 -0
- package/src/components/command-bar/command-bar.tsx +19 -1
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/layout/header.tsx +24 -2
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/markdown-text.tsx +218 -0
- package/src/data/config-store.test.ts +39 -2
- package/src/data/config-store.ts +40 -2
- package/src/pane-settings.test.ts +3 -19
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +17 -5
- package/src/plugins/builtin/portfolio-list/metrics.ts +38 -14
- package/src/plugins/builtin/portfolio-list/settings.ts +1 -11
- package/src/plugins/builtin/portfolio-metrics.test.ts +71 -0
- package/src/state/app-context.test.tsx +30 -0
- package/src/state/app-context.tsx +15 -3
- package/src/types/config.ts +12 -2
- package/src/updater.test.ts +121 -1
- package/src/updater.ts +75 -19
- package/src/utils/api-client.test.ts +86 -0
- package/src/utils/api-client.ts +30 -5
- package/src/version.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { cloneLayout, createDefaultConfig, createPaneInstance, type LayoutConfig } from "./types/config";
|
|
2
|
+
import { cloneLayout, createDefaultConfig, createPaneInstance, DEFAULT_PORTFOLIO_COLUMN_IDS, type LayoutConfig } from "./types/config";
|
|
3
3
|
import { deletePaneSetting, getPaneSettingValue, setPaneSetting } from "./pane-settings";
|
|
4
4
|
|
|
5
5
|
describe("pane settings helpers", () => {
|
|
@@ -13,15 +13,7 @@ describe("pane settings helpers", () => {
|
|
|
13
13
|
hideTabs: false,
|
|
14
14
|
lockedCollectionId: "main",
|
|
15
15
|
});
|
|
16
|
-
expect(portfolioPane?.settings?.columnIds).toEqual(
|
|
17
|
-
"ticker",
|
|
18
|
-
"price",
|
|
19
|
-
"change_pct",
|
|
20
|
-
"market_cap",
|
|
21
|
-
"pe",
|
|
22
|
-
"forward_pe",
|
|
23
|
-
"latency",
|
|
24
|
-
]);
|
|
16
|
+
expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
25
17
|
expect(detailPane?.settings).toEqual({
|
|
26
18
|
hideTabs: false,
|
|
27
19
|
lockedTabId: "overview",
|
|
@@ -38,15 +30,7 @@ describe("pane settings helpers", () => {
|
|
|
38
30
|
expect(clonedPortfolioSettings).not.toBe(originalPortfolioSettings);
|
|
39
31
|
|
|
40
32
|
(clonedPortfolioSettings?.columnIds as string[]).push("change");
|
|
41
|
-
expect(originalPortfolioSettings?.columnIds).toEqual(
|
|
42
|
-
"ticker",
|
|
43
|
-
"price",
|
|
44
|
-
"change_pct",
|
|
45
|
-
"market_cap",
|
|
46
|
-
"pe",
|
|
47
|
-
"forward_pe",
|
|
48
|
-
"latency",
|
|
49
|
-
]);
|
|
33
|
+
expect(originalPortfolioSettings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
50
34
|
});
|
|
51
35
|
|
|
52
36
|
test("setPaneSetting and deletePaneSetting update pane-scoped settings immutably", () => {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useRef, useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { useKeyboard } from "@opentui/react";
|
|
3
3
|
import { TextAttributes } from "@opentui/core";
|
|
4
|
-
import type { InputRenderable } from "@opentui/core";
|
|
4
|
+
import type { InputRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
5
5
|
import type { DetailTabProps } from "../../../types/plugin";
|
|
6
6
|
import { useAppState, usePaneTicker } from "../../../state/app-context";
|
|
7
7
|
import { useFxRatesMap } from "../../../market-data/hooks";
|
|
8
8
|
import { usePluginState } from "../../../plugins/plugin-runtime";
|
|
9
9
|
import { useInlineTickers } from "../../../state/use-inline-tickers";
|
|
10
|
-
import {
|
|
10
|
+
import { MarkdownText } from "../../../components/markdown-text";
|
|
11
11
|
import { colors } from "../../../theme/colors";
|
|
12
12
|
import { Spinner } from "../../../components/spinner";
|
|
13
13
|
import { buildTickerAiContext } from "./ticker-context";
|
|
@@ -80,6 +80,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
80
80
|
const [inputFocused, setInputFocused] = useState(false);
|
|
81
81
|
const [inputValue, setInputValue] = useState("");
|
|
82
82
|
const inputRef = useRef<InputRenderable>(null);
|
|
83
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
83
84
|
const runRef = useRef<ReturnType<typeof runAiPrompt> | null>(null);
|
|
84
85
|
const availableProviders = getAvailableProviders(providers);
|
|
85
86
|
const currentProvider = providers.find((provider) => provider.id === providerId && provider.available)
|
|
@@ -116,7 +117,12 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
116
117
|
|
|
117
118
|
useEffect(() => {
|
|
118
119
|
if (!tickerSymbol || !currentProvider || messages.length === 0) return;
|
|
119
|
-
const
|
|
120
|
+
const hasLoading = messages.some((message) => message.loading);
|
|
121
|
+
if (hasLoading) {
|
|
122
|
+
chatHistories.set(tickerSymbol, messages);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const trimmed = messages.slice(-ASK_AI_HISTORY_LIMIT);
|
|
120
126
|
chatHistories.set(tickerSymbol, trimmed);
|
|
121
127
|
setPersistedConversation({
|
|
122
128
|
updatedAt: Date.now(),
|
|
@@ -124,6 +130,12 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
124
130
|
});
|
|
125
131
|
}, [currentProvider, messages, setPersistedConversation, tickerSymbol]);
|
|
126
132
|
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const sb = scrollRef.current;
|
|
135
|
+
if (!sb || messages.length === 0) return;
|
|
136
|
+
sb.scrollTo({ x: 0, y: Math.max(0, sb.scrollHeight - sb.viewport.height) });
|
|
137
|
+
}, [messages, focused]);
|
|
138
|
+
|
|
127
139
|
const cycleProvider = useCallback(() => {
|
|
128
140
|
if (availableProviders.length <= 1) return;
|
|
129
141
|
setProviderId((current) => {
|
|
@@ -267,7 +279,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
267
279
|
</box>
|
|
268
280
|
</box>
|
|
269
281
|
|
|
270
|
-
<scrollbox height={chatHeight} scrollY>
|
|
282
|
+
<scrollbox ref={scrollRef} height={chatHeight} scrollY>
|
|
271
283
|
<box flexDirection="column">
|
|
272
284
|
{messages.length === 0 ? (
|
|
273
285
|
<box paddingTop={1}>
|
|
@@ -289,7 +301,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
289
301
|
</box>
|
|
290
302
|
<box>
|
|
291
303
|
{message.content ? (
|
|
292
|
-
<
|
|
304
|
+
<MarkdownText
|
|
293
305
|
text={message.content}
|
|
294
306
|
lineWidth={contentWidth}
|
|
295
307
|
catalog={catalog}
|
|
@@ -57,6 +57,31 @@ function getPositionCurrency(
|
|
|
57
57
|
return positions.find((position) => position.currency)?.currency || fallbackCurrency;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function normalizePositionMultiplier(multiplier: number | undefined): number {
|
|
61
|
+
return typeof multiplier === "number" && Number.isFinite(multiplier) && multiplier > 0
|
|
62
|
+
? multiplier
|
|
63
|
+
: 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePositionCostMultiplier(
|
|
67
|
+
position: TickerRecord["metadata"]["positions"][number],
|
|
68
|
+
): number {
|
|
69
|
+
const priceMultiplier = normalizePositionMultiplier(position.multiplier);
|
|
70
|
+
if (priceMultiplier === 1) return 1;
|
|
71
|
+
|
|
72
|
+
if (position.marketValue == null || position.unrealizedPnl == null) {
|
|
73
|
+
return priceMultiplier;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const costWithoutMultiplier = position.shares * position.avgCost;
|
|
77
|
+
const costWithMultiplier = costWithoutMultiplier * priceMultiplier;
|
|
78
|
+
const withoutMultiplierError = Math.abs((position.marketValue - costWithoutMultiplier) - position.unrealizedPnl);
|
|
79
|
+
const withMultiplierError = Math.abs((position.marketValue - costWithMultiplier) - position.unrealizedPnl);
|
|
80
|
+
|
|
81
|
+
// IBKR gateway derivatives can report avgCost already scaled to the contract.
|
|
82
|
+
return withoutMultiplierError < withMultiplierError ? 1 : priceMultiplier;
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
function getPortfolioPositionMetrics(
|
|
61
86
|
ticker: TickerRecord,
|
|
62
87
|
activeTab: string | undefined,
|
|
@@ -66,25 +91,24 @@ function getPortfolioPositionMetrics(
|
|
|
66
91
|
? ticker.metadata.positions.filter((position) => position.portfolio === activeTab)
|
|
67
92
|
: ticker.metadata.positions;
|
|
68
93
|
const positionCurrency = getPositionCurrency(tabPositions, fallbackCurrency);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
74
|
-
const totalCostUnits = tabPositions.reduce(
|
|
75
|
-
(sum, position) => sum + position.shares * (position.multiplier || 1),
|
|
76
|
-
0,
|
|
77
|
-
);
|
|
78
|
-
const totalPriceUnits = tabPositions.reduce(
|
|
79
|
-
(sum, position) => sum + position.shares * (position.multiplier || 1) * (position.side === "short" ? -1 : 1),
|
|
80
|
-
0,
|
|
81
|
-
);
|
|
82
|
-
|
|
94
|
+
let totalShares = 0;
|
|
95
|
+
let totalCost = 0;
|
|
96
|
+
let totalCostUnits = 0;
|
|
97
|
+
let totalPriceUnits = 0;
|
|
83
98
|
let brokerMktValue = 0;
|
|
84
99
|
let hasBrokerMktValue = false;
|
|
85
100
|
let brokerPnl = 0;
|
|
86
101
|
let hasBrokerPnl = false;
|
|
87
102
|
for (const position of tabPositions) {
|
|
103
|
+
const direction = position.side === "short" ? -1 : 1;
|
|
104
|
+
const priceMultiplier = normalizePositionMultiplier(position.multiplier);
|
|
105
|
+
const costMultiplier = resolvePositionCostMultiplier(position);
|
|
106
|
+
|
|
107
|
+
totalShares += position.shares * direction;
|
|
108
|
+
totalCost += position.shares * position.avgCost * costMultiplier;
|
|
109
|
+
totalCostUnits += position.shares * costMultiplier;
|
|
110
|
+
totalPriceUnits += position.shares * priceMultiplier * direction;
|
|
111
|
+
|
|
88
112
|
if (position.marketValue != null) {
|
|
89
113
|
brokerMktValue += position.marketValue;
|
|
90
114
|
hasBrokerMktValue = true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PaneSettingOption, PaneSettingsDef, PaneTemplateContext } from "../../../types/plugin";
|
|
2
|
-
import { DEFAULT_COLUMNS, type AppConfig, type ColumnConfig } from "../../../types/config";
|
|
2
|
+
import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type ColumnConfig } from "../../../types/config";
|
|
3
3
|
|
|
4
4
|
export type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
|
|
5
5
|
|
|
@@ -35,16 +35,6 @@ export const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
|
|
|
35
35
|
{ id: "pnl_pct", label: "P&L%", width: 8, align: "right", format: "percent" },
|
|
36
36
|
];
|
|
37
37
|
|
|
38
|
-
export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
|
|
39
|
-
...DEFAULT_COLUMNS.map((column) => column.id),
|
|
40
|
-
"shares",
|
|
41
|
-
"avg_cost",
|
|
42
|
-
"cost_basis",
|
|
43
|
-
"mkt_value",
|
|
44
|
-
"pnl",
|
|
45
|
-
"pnl_pct",
|
|
46
|
-
];
|
|
47
|
-
|
|
48
38
|
const PORTFOLIO_COLUMNS_BY_ID = new Map(PORTFOLIO_COLUMN_DEFS.map((column) => [column.id, column]));
|
|
49
39
|
const PORTFOLIO_ONLY_COLUMN_IDS = new Set([
|
|
50
40
|
"shares",
|
|
@@ -133,6 +133,77 @@ describe("portfolio-metrics", () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("does not double-apply IBKR option multipliers when avgCost is already contract-scaled", () => {
|
|
137
|
+
const ticker = createTicker({
|
|
138
|
+
ticker: "AMD 270917C00230000",
|
|
139
|
+
assetCategory: "OPT",
|
|
140
|
+
positions: [{
|
|
141
|
+
portfolio: "main",
|
|
142
|
+
shares: 10,
|
|
143
|
+
avgCost: 5095.07295,
|
|
144
|
+
broker: "ibkr",
|
|
145
|
+
currency: "USD",
|
|
146
|
+
marketValue: 58803.06,
|
|
147
|
+
unrealizedPnl: 7852.33,
|
|
148
|
+
multiplier: 100,
|
|
149
|
+
markPrice: 58.8030586,
|
|
150
|
+
}],
|
|
151
|
+
});
|
|
152
|
+
const financialsMap = new Map<string, TickerFinancials>();
|
|
153
|
+
|
|
154
|
+
const totals = calculatePortfolioSummaryTotals(
|
|
155
|
+
[ticker],
|
|
156
|
+
financialsMap,
|
|
157
|
+
"USD",
|
|
158
|
+
new Map([["USD", 1]]),
|
|
159
|
+
true,
|
|
160
|
+
"main",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(totals.totalMktValue).toBeCloseTo(58803.06, 2);
|
|
164
|
+
expect(totals.totalCostBasis).toBeCloseTo(50950.7295, 4);
|
|
165
|
+
expect(totals.unrealizedPnl).toBeCloseTo(7852.3305, 4);
|
|
166
|
+
expect(totals.hasPositions).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("keeps option avg cost display contract-scaled while using the correct cost basis", () => {
|
|
170
|
+
const ticker = createTicker({
|
|
171
|
+
ticker: "AMD 270917C00230000",
|
|
172
|
+
assetCategory: "OPT",
|
|
173
|
+
positions: [{
|
|
174
|
+
portfolio: "main",
|
|
175
|
+
shares: 10,
|
|
176
|
+
avgCost: 5095.07295,
|
|
177
|
+
broker: "ibkr",
|
|
178
|
+
currency: "USD",
|
|
179
|
+
marketValue: 58803.06,
|
|
180
|
+
unrealizedPnl: 7852.33,
|
|
181
|
+
multiplier: 100,
|
|
182
|
+
markPrice: 58.8030586,
|
|
183
|
+
}],
|
|
184
|
+
});
|
|
185
|
+
const financials = createFinancials({
|
|
186
|
+
quote: {
|
|
187
|
+
symbol: "AMD 270917C00230000",
|
|
188
|
+
price: 58.8030586,
|
|
189
|
+
currency: "USD",
|
|
190
|
+
change: 0,
|
|
191
|
+
changePercent: 0,
|
|
192
|
+
previousClose: 58.8030586,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
const avgCostColumn: ColumnConfig = { id: "avg_cost", label: "AVG COST", width: 10, align: "right", format: "currency" };
|
|
196
|
+
const pnlColumn: ColumnConfig = { id: "pnl", label: "P&L", width: 10, align: "right", format: "compact" };
|
|
197
|
+
|
|
198
|
+
expect(getColumnValue(avgCostColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
199
|
+
text: "5,095.07",
|
|
200
|
+
});
|
|
201
|
+
expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
202
|
+
text: "+7.9k",
|
|
203
|
+
color: expect.any(String),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
136
207
|
test("formats portfolio-only column values and sort keys consistently", () => {
|
|
137
208
|
const ticker = createTicker({
|
|
138
209
|
positions: [{ portfolio: "main", shares: 10, avgCost: 100, broker: "manual" }],
|
|
@@ -129,4 +129,34 @@ describe("appReducer command bar state", () => {
|
|
|
129
129
|
? secondUndone.config.layout.dockRoot.ratio
|
|
130
130
|
: null).toBe(0.4);
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test("tracks manual update-check feedback", () => {
|
|
134
|
+
const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
135
|
+
const checking = appReducer(initial, { type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
|
|
136
|
+
const noticed = appReducer(checking, { type: "SET_UPDATE_NOTICE", notice: "Already on v0.3.1" });
|
|
137
|
+
|
|
138
|
+
expect(checking.updateCheckInProgress).toBe(true);
|
|
139
|
+
expect(noticed.updateNotice).toBe("Already on v0.3.1");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("clears stale update notices when an update becomes available", () => {
|
|
143
|
+
const initial = {
|
|
144
|
+
...createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
|
|
145
|
+
updateNotice: "Already on v0.3.1",
|
|
146
|
+
};
|
|
147
|
+
const next = appReducer(initial, {
|
|
148
|
+
type: "SET_UPDATE_AVAILABLE",
|
|
149
|
+
release: {
|
|
150
|
+
version: "0.3.2",
|
|
151
|
+
tagName: "v0.3.2",
|
|
152
|
+
downloadUrl: "https://example.com/gloomberb.gz",
|
|
153
|
+
publishedAt: "2026-04-03T00:00:00Z",
|
|
154
|
+
updateAction: { kind: "self" },
|
|
155
|
+
compressed: true,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(next.updateAvailable?.version).toBe("0.3.2");
|
|
160
|
+
expect(next.updateNotice).toBeNull();
|
|
161
|
+
});
|
|
132
162
|
});
|
|
@@ -78,6 +78,8 @@ export interface AppState {
|
|
|
78
78
|
inputCaptured: boolean;
|
|
79
79
|
updateAvailable: ReleaseInfo | null;
|
|
80
80
|
updateProgress: UpdateProgress | null;
|
|
81
|
+
updateCheckInProgress: boolean;
|
|
82
|
+
updateNotice: string | null;
|
|
81
83
|
layoutHistory: Record<number, LayoutHistoryEntry>;
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -101,8 +103,10 @@ export type AppAction =
|
|
|
101
103
|
| { type: "SHOW_GRIDLOCK_TIP" }
|
|
102
104
|
| { type: "DISMISS_GRIDLOCK_TIP" }
|
|
103
105
|
| { type: "SET_THEME"; theme: string }
|
|
104
|
-
| { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo }
|
|
106
|
+
| { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo | null }
|
|
105
107
|
| { type: "SET_UPDATE_PROGRESS"; progress: UpdateProgress | null }
|
|
108
|
+
| { type: "SET_UPDATE_CHECK_IN_PROGRESS"; checking: boolean }
|
|
109
|
+
| { type: "SET_UPDATE_NOTICE"; notice: string | null }
|
|
106
110
|
| { type: "TOGGLE_PLUGIN"; pluginId: string }
|
|
107
111
|
| { type: "SET_INPUT_CAPTURED"; captured: boolean }
|
|
108
112
|
| { type: "SET_EXCHANGE_RATE"; currency: string; rate: number }
|
|
@@ -548,10 +552,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
548
552
|
return { ...state, config: { ...state.config, theme: action.theme } };
|
|
549
553
|
|
|
550
554
|
case "SET_UPDATE_AVAILABLE":
|
|
551
|
-
return { ...state, updateAvailable: action.release };
|
|
555
|
+
return { ...state, updateAvailable: action.release, updateNotice: action.release ? null : state.updateNotice };
|
|
552
556
|
|
|
553
557
|
case "SET_UPDATE_PROGRESS":
|
|
554
|
-
return { ...state, updateProgress: action.progress };
|
|
558
|
+
return { ...state, updateProgress: action.progress, updateNotice: action.progress ? null : state.updateNotice };
|
|
559
|
+
|
|
560
|
+
case "SET_UPDATE_CHECK_IN_PROGRESS":
|
|
561
|
+
return { ...state, updateCheckInProgress: action.checking };
|
|
562
|
+
|
|
563
|
+
case "SET_UPDATE_NOTICE":
|
|
564
|
+
return { ...state, updateNotice: action.notice };
|
|
555
565
|
|
|
556
566
|
case "TOGGLE_PLUGIN": {
|
|
557
567
|
const disabledPlugins = state.config.disabledPlugins.includes(action.pluginId)
|
|
@@ -936,6 +946,8 @@ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessio
|
|
|
936
946
|
inputCaptured: false,
|
|
937
947
|
updateAvailable: null,
|
|
938
948
|
updateProgress: null,
|
|
949
|
+
updateCheckInProgress: false,
|
|
950
|
+
updateNotice: null,
|
|
939
951
|
layoutHistory: {},
|
|
940
952
|
};
|
|
941
953
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Portfolio, Watchlist } from "./ticker";
|
|
2
2
|
|
|
3
|
-
export const CURRENT_CONFIG_VERSION =
|
|
3
|
+
export const CURRENT_CONFIG_VERSION = 11;
|
|
4
4
|
|
|
5
5
|
export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
|
|
6
6
|
export type ChartRendererPreference = "auto" | "kitty" | "braille";
|
|
@@ -134,6 +134,16 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
|
|
|
134
134
|
{ id: "latency", label: "AGE", width: 6, align: "right" },
|
|
135
135
|
];
|
|
136
136
|
|
|
137
|
+
export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
|
|
138
|
+
...DEFAULT_COLUMNS.map((column) => column.id),
|
|
139
|
+
"shares",
|
|
140
|
+
"avg_cost",
|
|
141
|
+
"cost_basis",
|
|
142
|
+
"mkt_value",
|
|
143
|
+
"pnl",
|
|
144
|
+
"pnl_pct",
|
|
145
|
+
];
|
|
146
|
+
|
|
137
147
|
export const DEFAULT_LAYOUT: LayoutConfig = {
|
|
138
148
|
dockRoot: {
|
|
139
149
|
kind: "split",
|
|
@@ -148,7 +158,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
|
|
|
148
158
|
paneId: "portfolio-list",
|
|
149
159
|
params: { collectionId: "main" },
|
|
150
160
|
settings: {
|
|
151
|
-
columnIds:
|
|
161
|
+
columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
|
|
152
162
|
collectionScope: "all",
|
|
153
163
|
visibleCollectionIds: [],
|
|
154
164
|
hideTabs: false,
|
package/src/updater.test.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, it, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { gzipSync } from "zlib";
|
|
2
6
|
import {
|
|
3
7
|
canSelfUpdate,
|
|
4
8
|
checkForUpdate,
|
|
9
|
+
checkForUpdateDetailed,
|
|
5
10
|
detectUpdateAction,
|
|
6
11
|
performUpdate,
|
|
7
12
|
resolveSelfUpdateTargetPath,
|
|
@@ -9,6 +14,18 @@ import {
|
|
|
9
14
|
type UpdateProgress,
|
|
10
15
|
} from "./updater";
|
|
11
16
|
|
|
17
|
+
const originalFetch = globalThis.fetch;
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
globalThis.fetch = originalFetch;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function expectedAssetName(compressed = false): string {
|
|
24
|
+
const os = process.platform === "darwin" ? "darwin" : "linux";
|
|
25
|
+
const arch = os === "darwin" || process.arch === "arm64" ? "arm64" : "x64";
|
|
26
|
+
return compressed ? `gloomberb-${os}-${arch}.gz` : `gloomberb-${os}-${arch}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
describe("detectUpdateAction", () => {
|
|
13
30
|
test("uses self-update for standalone binaries", () => {
|
|
14
31
|
expect(detectUpdateAction(
|
|
@@ -100,6 +117,66 @@ describe("checkForUpdate", () => {
|
|
|
100
117
|
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
101
118
|
}
|
|
102
119
|
});
|
|
120
|
+
|
|
121
|
+
it("finds gzipped release assets published on GitHub", async () => {
|
|
122
|
+
const originalExecPath = process.execPath;
|
|
123
|
+
const originalArgv = process.argv;
|
|
124
|
+
globalThis.fetch = (async () => new Response(JSON.stringify({
|
|
125
|
+
tag_name: "v0.3.2",
|
|
126
|
+
published_at: "2026-04-03T00:00:00Z",
|
|
127
|
+
assets: [{
|
|
128
|
+
name: expectedAssetName(true),
|
|
129
|
+
browser_download_url: "https://example.com/gloomberb.gz",
|
|
130
|
+
}],
|
|
131
|
+
}), {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
})) as typeof fetch;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
Object.defineProperty(process, "execPath", { value: "/Applications/gloomberb", configurable: true });
|
|
138
|
+
Object.defineProperty(process, "argv", {
|
|
139
|
+
value: ["/Applications/gloomberb"],
|
|
140
|
+
configurable: true,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(checkForUpdate("0.3.1")).resolves.toEqual({
|
|
144
|
+
version: "0.3.2",
|
|
145
|
+
tagName: "v0.3.2",
|
|
146
|
+
downloadUrl: "https://example.com/gloomberb.gz",
|
|
147
|
+
publishedAt: "2026-04-03T00:00:00Z",
|
|
148
|
+
updateAction: { kind: "self" },
|
|
149
|
+
compressed: true,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
|
153
|
+
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("checkForUpdateDetailed", () => {
|
|
159
|
+
it("returns a useful error when GitHub rejects the request", async () => {
|
|
160
|
+
const originalExecPath = process.execPath;
|
|
161
|
+
const originalArgv = process.argv;
|
|
162
|
+
globalThis.fetch = (async () => new Response("busy", { status: 503 })) as typeof fetch;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
Object.defineProperty(process, "execPath", { value: "/Applications/gloomberb", configurable: true });
|
|
166
|
+
Object.defineProperty(process, "argv", {
|
|
167
|
+
value: ["/Applications/gloomberb"],
|
|
168
|
+
configurable: true,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await expect(checkForUpdateDetailed("0.3.1")).resolves.toEqual({
|
|
172
|
+
kind: "error",
|
|
173
|
+
error: "GitHub returned 503",
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
|
177
|
+
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
103
180
|
});
|
|
104
181
|
|
|
105
182
|
describe("performUpdate", () => {
|
|
@@ -159,4 +236,47 @@ describe("performUpdate", () => {
|
|
|
159
236
|
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
160
237
|
}
|
|
161
238
|
});
|
|
239
|
+
|
|
240
|
+
it("decompresses gzipped release assets before replacing the binary", async () => {
|
|
241
|
+
const originalExecPath = process.execPath;
|
|
242
|
+
const originalArgv = process.argv;
|
|
243
|
+
const tempDir = mkdtempSync(join(tmpdir(), "gloomberb-update-"));
|
|
244
|
+
const execPath = join(tempDir, "gloomberb");
|
|
245
|
+
const nextBinary = Buffer.from("new-binary");
|
|
246
|
+
const payload = gzipSync(nextBinary);
|
|
247
|
+
const progress: UpdateProgress[] = [];
|
|
248
|
+
|
|
249
|
+
writeFileSync(execPath, Buffer.from("old-binary"));
|
|
250
|
+
chmodSync(execPath, 0o755);
|
|
251
|
+
globalThis.fetch = (async () => new Response(payload, {
|
|
252
|
+
status: 200,
|
|
253
|
+
headers: { "content-length": String(payload.length) },
|
|
254
|
+
})) as typeof fetch;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
Object.defineProperty(process, "execPath", { value: execPath, configurable: true });
|
|
258
|
+
Object.defineProperty(process, "argv", {
|
|
259
|
+
value: [execPath],
|
|
260
|
+
configurable: true,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await performUpdate({
|
|
264
|
+
version: "9.9.9",
|
|
265
|
+
tagName: "v9.9.9",
|
|
266
|
+
downloadUrl: "https://example.com/gloomberb.gz",
|
|
267
|
+
publishedAt: "2026-04-03T00:00:00Z",
|
|
268
|
+
updateAction: { kind: "self" },
|
|
269
|
+
compressed: true,
|
|
270
|
+
}, (entry) => {
|
|
271
|
+
progress.push(entry);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(readFileSync(execPath)).toEqual(nextBinary);
|
|
275
|
+
expect(progress.at(-1)).toEqual({ phase: "done" });
|
|
276
|
+
} finally {
|
|
277
|
+
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
|
278
|
+
Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
|
|
279
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
162
282
|
});
|