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
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
} from "./plugins/prediction-markets/launch";
|
|
71
71
|
import { saveConfig } from "./data/config-store";
|
|
72
72
|
import { Toaster, toast } from "@opentui-ui/toast/react";
|
|
73
|
-
import { canSelfUpdate,
|
|
73
|
+
import { canSelfUpdate, checkForUpdateDetailed, performUpdate } from "./updater";
|
|
74
74
|
import { VERSION } from "./version";
|
|
75
75
|
import { join } from "path";
|
|
76
76
|
import {
|
|
@@ -543,12 +543,46 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
543
543
|
}
|
|
544
544
|
}, [state.config.brokerInstances, importBrokerPositions]);
|
|
545
545
|
|
|
546
|
+
const runUpdateCheck = useCallback(async (manual = false) => {
|
|
547
|
+
if (manual) {
|
|
548
|
+
dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
|
|
549
|
+
dispatch({ type: "SET_UPDATE_NOTICE", notice: null });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const result = await checkForUpdateDetailed(VERSION);
|
|
553
|
+
|
|
554
|
+
if (!manual) {
|
|
555
|
+
if (result.kind === "available") {
|
|
556
|
+
dispatch({ type: "SET_UPDATE_AVAILABLE", release: result.release });
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
dispatch({ type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: false });
|
|
562
|
+
|
|
563
|
+
if (result.kind === "available") {
|
|
564
|
+
dispatch({ type: "SET_UPDATE_AVAILABLE", release: result.release });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (result.kind === "current") {
|
|
569
|
+
dispatch({ type: "SET_UPDATE_AVAILABLE", release: null });
|
|
570
|
+
dispatch({ type: "SET_UPDATE_NOTICE", notice: `Already on v${VERSION}` });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (result.kind === "disabled") {
|
|
575
|
+
dispatch({ type: "SET_UPDATE_NOTICE", notice: "Update checks are unavailable in source mode" });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
dispatch({ type: "SET_UPDATE_NOTICE", notice: `Update check failed: ${result.error}` });
|
|
580
|
+
}, [dispatch]);
|
|
581
|
+
|
|
546
582
|
// Check for updates on mount
|
|
547
583
|
useEffect(() => {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
});
|
|
551
|
-
}, []);
|
|
584
|
+
void runUpdateCheck(false);
|
|
585
|
+
}, [runUpdateCheck]);
|
|
552
586
|
|
|
553
587
|
// Load tickers on mount
|
|
554
588
|
useEffect(() => {
|
|
@@ -1155,7 +1189,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
1155
1189
|
if (actions.length > 0 && ticker) {
|
|
1156
1190
|
dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
|
|
1157
1191
|
}
|
|
1158
|
-
} else if (event.name === "u" && state.updateAvailable && !state.updateProgress && canSelfUpdate(state.updateAvailable)) {
|
|
1192
|
+
} else if (event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
|
|
1159
1193
|
performUpdate(state.updateAvailable, (progress) => {
|
|
1160
1194
|
dispatch({ type: "SET_UPDATE_PROGRESS", progress });
|
|
1161
1195
|
});
|
|
@@ -1186,6 +1220,7 @@ function AppInner({ pluginRegistry, tickerRepository, dataProvider, marketData,
|
|
|
1186
1220
|
tickerRepository={tickerRepository}
|
|
1187
1221
|
pluginRegistry={pluginRegistry}
|
|
1188
1222
|
quitApp={() => renderer.destroy()}
|
|
1223
|
+
onCheckForUpdates={() => runUpdateCheck(true)}
|
|
1189
1224
|
/>
|
|
1190
1225
|
)}
|
|
1191
1226
|
<Toaster position="bottom-right" />
|
|
@@ -218,6 +218,7 @@ function CommandBarHarness({
|
|
|
218
218
|
configurePluginRegistry,
|
|
219
219
|
dataProvider = makeDataProvider(),
|
|
220
220
|
hasPaneSettings,
|
|
221
|
+
onCheckForUpdates,
|
|
221
222
|
}: {
|
|
222
223
|
query: string;
|
|
223
224
|
disabledPlugins?: string[];
|
|
@@ -231,6 +232,7 @@ function CommandBarHarness({
|
|
|
231
232
|
configurePluginRegistry?: (pluginRegistry: PluginRegistry) => void;
|
|
232
233
|
dataProvider?: DataProvider;
|
|
233
234
|
hasPaneSettings?: (paneId: string) => boolean;
|
|
235
|
+
onCheckForUpdates?: () => void | Promise<void>;
|
|
234
236
|
}) {
|
|
235
237
|
let config = {
|
|
236
238
|
...createDefaultConfig("/tmp/gloomberb-test"),
|
|
@@ -285,6 +287,7 @@ function CommandBarHarness({
|
|
|
285
287
|
tickerRepository={tickerRepository as any}
|
|
286
288
|
pluginRegistry={pluginRegistry}
|
|
287
289
|
quitApp={() => {}}
|
|
290
|
+
onCheckForUpdates={onCheckForUpdates}
|
|
288
291
|
/>
|
|
289
292
|
)}
|
|
290
293
|
</DialogProvider>
|
|
@@ -304,6 +307,25 @@ describe("CommandBar", () => {
|
|
|
304
307
|
expect(testSetup.captureCharFrame()).toMatchSnapshot();
|
|
305
308
|
});
|
|
306
309
|
|
|
310
|
+
test("runs check for updates from the command bar", async () => {
|
|
311
|
+
const calls = [];
|
|
312
|
+
|
|
313
|
+
testSetup = await testRender(<CommandBarHarness query="check for updates" live onCheckForUpdates={() => { calls.push(Date.now()); }} />, {
|
|
314
|
+
width: 80,
|
|
315
|
+
height: 24,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await testSetup.renderOnce();
|
|
319
|
+
expect(testSetup.captureCharFrame()).toContain("Check for Updates");
|
|
320
|
+
|
|
321
|
+
await clickFrameText("Check for Updates");
|
|
322
|
+
await Bun.sleep(0);
|
|
323
|
+
await testSetup.renderOnce();
|
|
324
|
+
|
|
325
|
+
expect(calls).toHaveLength(1);
|
|
326
|
+
expect(testSetup.captureCharFrame()).not.toContain("Commands");
|
|
327
|
+
});
|
|
328
|
+
|
|
307
329
|
test("renders theme prefix results in the root query", async () => {
|
|
308
330
|
testSetup = await testRender(<CommandBarHarness query="TH " />, {
|
|
309
331
|
width: 80,
|
|
@@ -118,6 +118,7 @@ interface CommandBarProps {
|
|
|
118
118
|
tickerRepository: TickerRepository;
|
|
119
119
|
pluginRegistry: PluginRegistry;
|
|
120
120
|
quitApp: () => void;
|
|
121
|
+
onCheckForUpdates?: () => void | Promise<void>;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
interface ResultItem {
|
|
@@ -204,7 +205,13 @@ function getVisibleMultiSelectPickerOptions(
|
|
|
204
205
|
});
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
export function CommandBar({
|
|
208
|
+
export function CommandBar({
|
|
209
|
+
dataProvider,
|
|
210
|
+
tickerRepository,
|
|
211
|
+
pluginRegistry,
|
|
212
|
+
quitApp,
|
|
213
|
+
onCheckForUpdates,
|
|
214
|
+
}: CommandBarProps) {
|
|
208
215
|
const { state, dispatch } = useAppState();
|
|
209
216
|
const stateRef = useRef(state);
|
|
210
217
|
stateRef.current = state;
|
|
@@ -1985,6 +1992,10 @@ export function CommandBar({ dataProvider, tickerRepository, pluginRegistry, qui
|
|
|
1985
1992
|
closeAll({ revertThemePreview: false });
|
|
1986
1993
|
return;
|
|
1987
1994
|
}
|
|
1995
|
+
case "check-for-updates":
|
|
1996
|
+
void onCheckForUpdates?.();
|
|
1997
|
+
closeAll({ revertThemePreview: false });
|
|
1998
|
+
return;
|
|
1988
1999
|
case "search-ticker":
|
|
1989
2000
|
void runTickerSearchShortcut(arg);
|
|
1990
2001
|
return;
|
|
@@ -2016,6 +2027,7 @@ export function CommandBar({ dataProvider, tickerRepository, pluginRegistry, qui
|
|
|
2016
2027
|
openModeRoute,
|
|
2017
2028
|
openPaneSettingsRoute,
|
|
2018
2029
|
openPickerRoute,
|
|
2030
|
+
onCheckForUpdates,
|
|
2019
2031
|
pluginRegistry,
|
|
2020
2032
|
quitApp,
|
|
2021
2033
|
runTickerSearchShortcut,
|
|
@@ -2219,6 +2231,11 @@ export function CommandBar({ dataProvider, tickerRepository, pluginRegistry, qui
|
|
|
2219
2231
|
.filter(Boolean);
|
|
2220
2232
|
return names?.length ? `from "${names.join(", ")}"` : command.description;
|
|
2221
2233
|
}
|
|
2234
|
+
case "check-for-updates":
|
|
2235
|
+
if (state.updateCheckInProgress) return "Checking GitHub releases now";
|
|
2236
|
+
if (state.updateAvailable) return `Latest available: v${state.updateAvailable.version}`;
|
|
2237
|
+
if (state.updateNotice) return state.updateNotice;
|
|
2238
|
+
return command.description;
|
|
2222
2239
|
default:
|
|
2223
2240
|
return command.description;
|
|
2224
2241
|
}
|
|
@@ -2233,6 +2250,7 @@ export function CommandBar({ dataProvider, tickerRepository, pluginRegistry, qui
|
|
|
2233
2250
|
category: command.category,
|
|
2234
2251
|
kind: "command",
|
|
2235
2252
|
right: command.prefix || undefined,
|
|
2253
|
+
disabled: command.id === "check-for-updates" && (state.updateCheckInProgress || !!state.updateProgress),
|
|
2236
2254
|
action: () => runDirectCommand(command, ""),
|
|
2237
2255
|
};
|
|
2238
2256
|
}
|
|
@@ -171,6 +171,14 @@ export const commands: Command[] = [
|
|
|
171
171
|
dispatch({ type: "TOGGLE_STATUS_BAR" });
|
|
172
172
|
},
|
|
173
173
|
},
|
|
174
|
+
{
|
|
175
|
+
id: "check-for-updates",
|
|
176
|
+
prefix: "",
|
|
177
|
+
label: "Check for Updates",
|
|
178
|
+
description: "Check GitHub releases for a newer version",
|
|
179
|
+
category: "Config",
|
|
180
|
+
execute: () => {}, // handled by command bar
|
|
181
|
+
},
|
|
174
182
|
|
|
175
183
|
// Theme
|
|
176
184
|
{
|
|
@@ -11,10 +11,19 @@ import { marketStateLabel, marketStateColor, getExtendedHoursInfo } from "../../
|
|
|
11
11
|
import { VERSION } from "../../version";
|
|
12
12
|
|
|
13
13
|
const SPY_REFRESH_MS = 5 * 60_000; // 5 min
|
|
14
|
+
const UPDATE_NOTICE_DURATION_MS = 5_000;
|
|
14
15
|
|
|
15
16
|
function UpdateStatus() {
|
|
16
|
-
const { state } = useAppState();
|
|
17
|
-
const { updateAvailable, updateProgress } = state;
|
|
17
|
+
const { state, dispatch } = useAppState();
|
|
18
|
+
const { updateAvailable, updateProgress, updateCheckInProgress, updateNotice } = state;
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!updateNotice || updateAvailable || updateProgress || updateCheckInProgress) return;
|
|
22
|
+
const timeout = setTimeout(() => {
|
|
23
|
+
dispatch({ type: "SET_UPDATE_NOTICE", notice: null });
|
|
24
|
+
}, UPDATE_NOTICE_DURATION_MS);
|
|
25
|
+
return () => clearTimeout(timeout);
|
|
26
|
+
}, [dispatch, updateAvailable, updateCheckInProgress, updateNotice, updateProgress]);
|
|
18
27
|
|
|
19
28
|
if (updateProgress) {
|
|
20
29
|
if (updateProgress.phase === "downloading") {
|
|
@@ -43,6 +52,15 @@ function UpdateStatus() {
|
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
if (updateCheckInProgress) {
|
|
56
|
+
return (
|
|
57
|
+
<box flexDirection="row" gap={1}>
|
|
58
|
+
<spinner name="dots" color={colors.headerText} />
|
|
59
|
+
<text fg={colors.headerText}>Checking for updates...</text>
|
|
60
|
+
</box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
if (updateAvailable) {
|
|
47
65
|
if (updateAvailable.updateAction.kind === "manual") {
|
|
48
66
|
return (
|
|
@@ -58,6 +76,10 @@ function UpdateStatus() {
|
|
|
58
76
|
);
|
|
59
77
|
}
|
|
60
78
|
|
|
79
|
+
if (updateNotice) {
|
|
80
|
+
return <text fg={colors.headerText}>{updateNotice}</text>;
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
return null;
|
|
62
84
|
}
|
|
63
85
|
|
|
@@ -160,11 +160,17 @@ function createHeaderDataProvider(): DataProvider {
|
|
|
160
160
|
|
|
161
161
|
function HeaderHarness({
|
|
162
162
|
updateAvailable = null,
|
|
163
|
+
updateCheckInProgress = false,
|
|
164
|
+
updateNotice = null,
|
|
163
165
|
}: {
|
|
164
166
|
updateAvailable?: ReturnType<typeof createInitialState>["updateAvailable"];
|
|
167
|
+
updateCheckInProgress?: boolean;
|
|
168
|
+
updateNotice?: string | null;
|
|
165
169
|
}) {
|
|
166
170
|
const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-header-test"));
|
|
167
171
|
initialState.updateAvailable = updateAvailable;
|
|
172
|
+
initialState.updateCheckInProgress = updateCheckInProgress;
|
|
173
|
+
initialState.updateNotice = updateNotice;
|
|
168
174
|
const [state, dispatch] = useReducer(appReducer, initialState);
|
|
169
175
|
|
|
170
176
|
return (
|
|
@@ -290,6 +296,26 @@ describe("Header", () => {
|
|
|
290
296
|
expect(frame).toContain("run npm install -g gloomberb@latest");
|
|
291
297
|
expect(frame).not.toContain("press u to update");
|
|
292
298
|
});
|
|
299
|
+
|
|
300
|
+
test("shows update check progress and notices", async () => {
|
|
301
|
+
testSetup = await testRender(
|
|
302
|
+
<HeaderHarness updateCheckInProgress />,
|
|
303
|
+
{ width: 120, height: 2 },
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
await testSetup.renderOnce();
|
|
307
|
+
expect(testSetup.captureCharFrame()).toContain("Checking for updates...");
|
|
308
|
+
|
|
309
|
+
testSetup.renderer.destroy();
|
|
310
|
+
|
|
311
|
+
testSetup = await testRender(
|
|
312
|
+
<HeaderHarness updateNotice="Already on v0.3.1" />,
|
|
313
|
+
{ width: 120, height: 2 },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await testSetup.renderOnce();
|
|
317
|
+
expect(testSetup.captureCharFrame()).toContain("Already on v0.3.1");
|
|
318
|
+
});
|
|
293
319
|
});
|
|
294
320
|
|
|
295
321
|
describe("Shell", () => {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { TextAttributes } from "@opentui/core";
|
|
3
|
+
import { TickerBadge } from "./ticker-badge";
|
|
4
|
+
import { tokenizeTickerText } from "../utils/ticker-tokenizer";
|
|
5
|
+
import type { InlineTickerCatalogEntry } from "../state/use-inline-tickers";
|
|
6
|
+
import { colors } from "../theme/colors";
|
|
7
|
+
|
|
8
|
+
export interface MarkdownTextProps {
|
|
9
|
+
text: string;
|
|
10
|
+
lineWidth: number;
|
|
11
|
+
catalog: Record<string, InlineTickerCatalogEntry>;
|
|
12
|
+
textColor: string;
|
|
13
|
+
openTicker: (symbol: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface StyledSegment {
|
|
17
|
+
text: string;
|
|
18
|
+
bold?: boolean;
|
|
19
|
+
italic?: boolean;
|
|
20
|
+
dim?: boolean;
|
|
21
|
+
code?: boolean;
|
|
22
|
+
color?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ParsedLine {
|
|
26
|
+
segments: StyledSegment[];
|
|
27
|
+
heading?: boolean;
|
|
28
|
+
indent?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const INLINE_RE =
|
|
32
|
+
/(\*\*(?<bold>[^*]+)\*\*|(?<!\*)\*(?!\*)(?<italic>[^*]+)\*(?!\*)|`(?<code>[^`]+)`|~~(?<strike>[^~]+)~~)/g;
|
|
33
|
+
|
|
34
|
+
function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
35
|
+
const segments: StyledSegment[] = [];
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
|
|
38
|
+
INLINE_RE.lastIndex = 0;
|
|
39
|
+
let match: RegExpExecArray | null;
|
|
40
|
+
while ((match = INLINE_RE.exec(text)) !== null) {
|
|
41
|
+
if (match.index > cursor) {
|
|
42
|
+
segments.push({ text: text.slice(cursor, match.index) });
|
|
43
|
+
}
|
|
44
|
+
if (match.groups?.bold != null) {
|
|
45
|
+
segments.push({ text: match.groups.bold, bold: true });
|
|
46
|
+
} else if (match.groups?.italic != null) {
|
|
47
|
+
segments.push({ text: match.groups.italic, italic: true });
|
|
48
|
+
} else if (match.groups?.code != null) {
|
|
49
|
+
segments.push({ text: match.groups.code, code: true });
|
|
50
|
+
} else if (match.groups?.strike != null) {
|
|
51
|
+
segments.push({ text: match.groups.strike, dim: true });
|
|
52
|
+
}
|
|
53
|
+
cursor = match.index + match[0].length;
|
|
54
|
+
}
|
|
55
|
+
if (cursor < text.length) {
|
|
56
|
+
segments.push({ text: text.slice(cursor) });
|
|
57
|
+
}
|
|
58
|
+
return segments;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseLine(line: string): ParsedLine {
|
|
62
|
+
// Headings
|
|
63
|
+
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
|
|
64
|
+
if (headingMatch) {
|
|
65
|
+
return {
|
|
66
|
+
heading: true,
|
|
67
|
+
segments: [{ text: headingMatch[2]!, bold: true, color: colors.borderFocused }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// List items
|
|
72
|
+
const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s(.*)$/);
|
|
73
|
+
if (listMatch) {
|
|
74
|
+
const indent = listMatch[1]!.length;
|
|
75
|
+
const marker = listMatch[2]!;
|
|
76
|
+
const content = listMatch[3]!;
|
|
77
|
+
return {
|
|
78
|
+
indent,
|
|
79
|
+
segments: [
|
|
80
|
+
{ text: `${marker} `, bold: true, color: colors.borderFocused },
|
|
81
|
+
...parseInlineMarkdown(content),
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { segments: parseInlineMarkdown(line) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function SegmentSpan({ segment }: { segment: StyledSegment }) {
|
|
90
|
+
if (segment.code) {
|
|
91
|
+
return <span fg={colors.textDim}>{segment.text}</span>;
|
|
92
|
+
}
|
|
93
|
+
const attrs =
|
|
94
|
+
(segment.bold ? TextAttributes.BOLD : 0) |
|
|
95
|
+
(segment.italic ? TextAttributes.ITALIC : 0) |
|
|
96
|
+
(segment.dim ? TextAttributes.DIM : 0);
|
|
97
|
+
return (
|
|
98
|
+
<span
|
|
99
|
+
fg={segment.color ?? undefined}
|
|
100
|
+
attributes={attrs || undefined}
|
|
101
|
+
>
|
|
102
|
+
{segment.text}
|
|
103
|
+
</span>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function MarkdownLine({
|
|
108
|
+
parsed,
|
|
109
|
+
lineWidth,
|
|
110
|
+
catalog,
|
|
111
|
+
textColor,
|
|
112
|
+
openTicker,
|
|
113
|
+
hoveredSymbol,
|
|
114
|
+
onHover,
|
|
115
|
+
}: {
|
|
116
|
+
parsed: ParsedLine;
|
|
117
|
+
lineWidth: number;
|
|
118
|
+
catalog: Record<string, InlineTickerCatalogEntry>;
|
|
119
|
+
textColor: string;
|
|
120
|
+
openTicker: (symbol: string) => void;
|
|
121
|
+
hoveredSymbol: string | null;
|
|
122
|
+
onHover: (symbol: string | null) => void;
|
|
123
|
+
}) {
|
|
124
|
+
const indent = parsed.indent ?? 0;
|
|
125
|
+
const indentStr = indent > 0 ? " ".repeat(indent) : "";
|
|
126
|
+
|
|
127
|
+
// Check if any segment contains ticker symbols
|
|
128
|
+
const fullText = parsed.segments.map((s) => s.text).join("");
|
|
129
|
+
const tickerTokens = tokenizeTickerText(fullText);
|
|
130
|
+
const hasTickers = tickerTokens.some((t) => t.kind === "ticker" && catalog[t.symbol]?.status !== "missing");
|
|
131
|
+
|
|
132
|
+
if (!hasTickers) {
|
|
133
|
+
// Simple case: no tickers, render as styled text
|
|
134
|
+
return (
|
|
135
|
+
<text fg={textColor}>
|
|
136
|
+
{indentStr}
|
|
137
|
+
{parsed.segments.map((segment, i) => (
|
|
138
|
+
<SegmentSpan key={i} segment={segment} />
|
|
139
|
+
))}
|
|
140
|
+
</text>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Complex case: need to handle tickers within styled segments
|
|
145
|
+
// Render as a flex row to allow badge elements
|
|
146
|
+
return (
|
|
147
|
+
<box flexDirection="row" flexWrap="wrap" width={lineWidth}>
|
|
148
|
+
{indentStr ? <text fg={textColor}>{indentStr}</text> : null}
|
|
149
|
+
{parsed.segments.map((segment, segIdx) => {
|
|
150
|
+
const tokens = tokenizeTickerText(segment.text);
|
|
151
|
+
return tokens.map((token, tokIdx) => {
|
|
152
|
+
if (token.kind === "text") {
|
|
153
|
+
if (!token.value) return null;
|
|
154
|
+
return (
|
|
155
|
+
<text key={`${segIdx}:${tokIdx}`} fg={textColor}>
|
|
156
|
+
<SegmentSpan segment={{ ...segment, text: token.value }} />
|
|
157
|
+
</text>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const entry = catalog[token.symbol];
|
|
161
|
+
if (!entry || entry.status === "missing") {
|
|
162
|
+
return (
|
|
163
|
+
<text key={`${segIdx}:${tokIdx}`} fg={textColor}>
|
|
164
|
+
<SegmentSpan segment={{ ...segment, text: token.value }} />
|
|
165
|
+
</text>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return (
|
|
169
|
+
<TickerBadge
|
|
170
|
+
key={`badge:${segIdx}:${tokIdx}:${token.symbol}`}
|
|
171
|
+
symbol={token.symbol}
|
|
172
|
+
status={entry.status}
|
|
173
|
+
quote={entry.quote}
|
|
174
|
+
hovered={hoveredSymbol === token.symbol}
|
|
175
|
+
onHoverStart={() => onHover(token.symbol)}
|
|
176
|
+
onHoverEnd={() => onHover(null)}
|
|
177
|
+
onOpen={openTicker}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
})}
|
|
182
|
+
</box>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function MarkdownText({
|
|
187
|
+
text,
|
|
188
|
+
lineWidth,
|
|
189
|
+
catalog,
|
|
190
|
+
textColor,
|
|
191
|
+
openTicker,
|
|
192
|
+
}: MarkdownTextProps) {
|
|
193
|
+
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
194
|
+
const lines = text.split("\n");
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<box flexDirection="column" width={lineWidth}>
|
|
198
|
+
{lines.map((line, index) => {
|
|
199
|
+
if (line.trim() === "") {
|
|
200
|
+
return <text key={index}>{" "}</text>;
|
|
201
|
+
}
|
|
202
|
+
const parsed = parseLine(line);
|
|
203
|
+
return (
|
|
204
|
+
<MarkdownLine
|
|
205
|
+
key={index}
|
|
206
|
+
parsed={parsed}
|
|
207
|
+
lineWidth={lineWidth}
|
|
208
|
+
catalog={catalog}
|
|
209
|
+
textColor={textColor}
|
|
210
|
+
openTicker={openTicker}
|
|
211
|
+
hoveredSymbol={hoveredSymbol}
|
|
212
|
+
onHover={setHoveredSymbol}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</box>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -3,7 +3,13 @@ import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
import { loadConfig, sanitizeLayout, saveConfig } from "./config-store";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
CURRENT_CONFIG_VERSION,
|
|
8
|
+
DEFAULT_COLUMNS,
|
|
9
|
+
DEFAULT_LAYOUT,
|
|
10
|
+
DEFAULT_PORTFOLIO_COLUMN_IDS,
|
|
11
|
+
findPaneInstance,
|
|
12
|
+
} from "../types/config";
|
|
7
13
|
import { getDockedPaneIds } from "../plugins/pane-manager";
|
|
8
14
|
|
|
9
15
|
const tempDirs: string[] = [];
|
|
@@ -240,7 +246,7 @@ describe("loadConfig", () => {
|
|
|
240
246
|
test("preserves IBKR gateway configs without migration rewrites", async () => {
|
|
241
247
|
const dataDir = await createTempConfigDir();
|
|
242
248
|
await writeConfigJson(dataDir, createSavedConfig({
|
|
243
|
-
configVersion:
|
|
249
|
+
configVersion: 11,
|
|
244
250
|
brokerInstances: [{
|
|
245
251
|
id: "ibkr-interactive-brokers",
|
|
246
252
|
brokerType: "ibkr",
|
|
@@ -265,6 +271,37 @@ describe("loadConfig", () => {
|
|
|
265
271
|
});
|
|
266
272
|
});
|
|
267
273
|
|
|
274
|
+
test("migrates legacy main portfolio panes to the portfolio default columns", async () => {
|
|
275
|
+
const dataDir = await createTempConfigDir();
|
|
276
|
+
const legacyColumnIds = DEFAULT_COLUMNS.map((column) => column.id);
|
|
277
|
+
const legacyLayout = {
|
|
278
|
+
...DEFAULT_LAYOUT,
|
|
279
|
+
instances: DEFAULT_LAYOUT.instances.map((instance) => (
|
|
280
|
+
instance.instanceId === "portfolio-list:main"
|
|
281
|
+
? {
|
|
282
|
+
...instance,
|
|
283
|
+
settings: {
|
|
284
|
+
...(instance.settings ?? {}),
|
|
285
|
+
columnIds: legacyColumnIds,
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
: instance
|
|
289
|
+
)),
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
await writeConfigJson(dataDir, createSavedConfig({
|
|
293
|
+
configVersion: 10,
|
|
294
|
+
layout: legacyLayout,
|
|
295
|
+
layouts: [{ name: "Default", layout: legacyLayout }],
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
const config = await loadConfig(dataDir);
|
|
299
|
+
|
|
300
|
+
expect(findPaneInstance(config.layout, "portfolio-list:main")?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
301
|
+
expect(findPaneInstance(config.layouts[0]?.layout ?? DEFAULT_LAYOUT, "portfolio-list:main")?.settings?.columnIds)
|
|
302
|
+
.toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
303
|
+
});
|
|
304
|
+
|
|
268
305
|
test("falls back to the default layout when persisted layouts use the obsolete column shape", async () => {
|
|
269
306
|
const dataDir = await createTempConfigDir();
|
|
270
307
|
const obsoleteLayout = {
|
package/src/data/config-store.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
createDefaultConfig,
|
|
17
17
|
createPaneInstanceId,
|
|
18
18
|
CURRENT_CONFIG_VERSION,
|
|
19
|
+
DEFAULT_COLUMNS,
|
|
20
|
+
DEFAULT_PORTFOLIO_COLUMN_IDS,
|
|
19
21
|
clonePaneSettings,
|
|
20
22
|
normalizePaneLayout,
|
|
21
23
|
} from "../types/config";
|
|
@@ -23,6 +25,7 @@ import type { Portfolio, Watchlist } from "../types/ticker";
|
|
|
23
25
|
import { debugLog } from "../utils/debug-log";
|
|
24
26
|
|
|
25
27
|
const configLog = debugLog.createLogger("config");
|
|
28
|
+
const LEGACY_MAIN_PORTFOLIO_COLUMN_IDS = DEFAULT_COLUMNS.map((column) => column.id);
|
|
26
29
|
|
|
27
30
|
function getGlobalConfigDir(): string {
|
|
28
31
|
return join(process.env.HOME || "~", ".gloomberb");
|
|
@@ -62,8 +65,16 @@ async function loadConfigState(dataDir: string): Promise<{ config: AppConfig; ne
|
|
|
62
65
|
|
|
63
66
|
function normalizeConfig(saved: Record<string, unknown>, dataDir: string): { config: AppConfig; needsSave: boolean } {
|
|
64
67
|
const defaults = createDefaultConfig(dataDir);
|
|
65
|
-
const
|
|
66
|
-
|
|
68
|
+
const shouldMigratePortfolioDefaults =
|
|
69
|
+
typeof saved.configVersion !== "number" || saved.configVersion < CURRENT_CONFIG_VERSION;
|
|
70
|
+
const directLayout = migrateLegacyPortfolioDefaultColumns(
|
|
71
|
+
sanitizeLayout(saved.layout, defaults.layout),
|
|
72
|
+
shouldMigratePortfolioDefaults,
|
|
73
|
+
);
|
|
74
|
+
const layouts = sanitizeSavedLayouts(saved.layouts, directLayout).map((entry) => ({
|
|
75
|
+
...entry,
|
|
76
|
+
layout: migrateLegacyPortfolioDefaultColumns(entry.layout, shouldMigratePortfolioDefaults),
|
|
77
|
+
}));
|
|
67
78
|
const activeLayoutIndex = sanitizeActiveLayoutIndex(saved.activeLayoutIndex, layouts.length);
|
|
68
79
|
const layout = cloneLayout(layouts[activeLayoutIndex]?.layout ?? directLayout);
|
|
69
80
|
const syncedLayouts = layouts.map((entry, index) => (
|
|
@@ -387,6 +398,33 @@ function sanitizePaneSettings(value: unknown): Record<string, unknown> | undefin
|
|
|
387
398
|
return Object.keys(settings).length > 0 ? clonePaneSettings(settings) : undefined;
|
|
388
399
|
}
|
|
389
400
|
|
|
401
|
+
function hasExactColumnIds(value: unknown, expected: string[]): boolean {
|
|
402
|
+
return Array.isArray(value)
|
|
403
|
+
&& value.length === expected.length
|
|
404
|
+
&& value.every((entry, index) => entry === expected[index]);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function migrateLegacyPortfolioDefaultColumns(layout: LayoutConfig, enabled: boolean): LayoutConfig {
|
|
408
|
+
if (!enabled) return layout;
|
|
409
|
+
|
|
410
|
+
const instanceIndex = layout.instances.findIndex((instance) =>
|
|
411
|
+
instance.instanceId === "portfolio-list:main"
|
|
412
|
+
&& instance.paneId === "portfolio-list"
|
|
413
|
+
&& hasExactColumnIds(instance.settings?.columnIds, LEGACY_MAIN_PORTFOLIO_COLUMN_IDS)
|
|
414
|
+
);
|
|
415
|
+
if (instanceIndex < 0) return layout;
|
|
416
|
+
|
|
417
|
+
const nextLayout = cloneLayout(layout);
|
|
418
|
+
nextLayout.instances[instanceIndex] = {
|
|
419
|
+
...nextLayout.instances[instanceIndex]!,
|
|
420
|
+
settings: {
|
|
421
|
+
...(nextLayout.instances[instanceIndex]?.settings ?? {}),
|
|
422
|
+
columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
return nextLayout;
|
|
426
|
+
}
|
|
427
|
+
|
|
390
428
|
function getDefaultFollowSourceInstanceId(instances: PaneInstanceConfig[]): string | null {
|
|
391
429
|
return instances.find((instance) => instance.paneId === "portfolio-list")?.instanceId ?? null;
|
|
392
430
|
}
|