gloomberb 0.3.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
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, checkForUpdate, performUpdate } from "./updater";
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
- checkForUpdate(VERSION).then((release) => {
549
- if (release) dispatch({ type: "SET_UPDATE_AVAILABLE", release });
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({ dataProvider, tickerRepository, pluginRegistry, quitApp }: CommandBarProps) {
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 { CURRENT_CONFIG_VERSION, DEFAULT_LAYOUT, findPaneInstance } from "../types/config";
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: 10,
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 = {
@@ -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 directLayout = sanitizeLayout(saved.layout, defaults.layout);
66
- const layouts = sanitizeSavedLayouts(saved.layouts, directLayout);
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
  }