diff --git a/packages/core/src/history/CommandHistory.test.ts b/packages/core/src/history/CommandHistory.test.ts index 65906958a..52b2137c4 100644 --- a/packages/core/src/history/CommandHistory.test.ts +++ b/packages/core/src/history/CommandHistory.test.ts @@ -199,4 +199,29 @@ describe('CommandHistory', () => { expect(ch.getAll()).toHaveLength(3); expect(ch.getAll()).toEqual(smallHistory); }); + + it('import() is a no-op for malformed JSON', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import('not valid json {{{'); + expect(ch.getAll()).toEqual(['existing']); + }); + + it('import() is a no-op for non-array JSON values', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import(JSON.stringify({ commands: ['cmd1', 'cmd2'] })); + expect(ch.getAll()).toEqual(['existing']); + ch.import(JSON.stringify(42)); + expect(ch.getAll()).toEqual(['existing']); + ch.import(JSON.stringify(null)); + expect(ch.getAll()).toEqual(['existing']); + }); + + it('import() is a no-op for arrays containing non-string elements', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import(JSON.stringify(['valid', 123, 'also valid'] as unknown[])); + expect(ch.getAll()).toEqual(['existing']); + }); }); \ No newline at end of file diff --git a/packages/core/src/history/CommandHistory.ts b/packages/core/src/history/CommandHistory.ts index 20fc4d216..9f420a828 100644 --- a/packages/core/src/history/CommandHistory.ts +++ b/packages/core/src/history/CommandHistory.ts @@ -99,7 +99,15 @@ export class CommandHistory { /** Restore history from a JSON string produced by {@link export}. */ import(data: string): void { - const parsed: string[] = JSON.parse(data); + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return; + } + if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) { + return; + } this.commands = parsed.slice(-this.maxSize); this.index = this.commands.length; } diff --git a/packages/motion/src/interpolate.ts b/packages/motion/src/interpolate.ts index 0d1c4866c..c4794bdca 100644 --- a/packages/motion/src/interpolate.ts +++ b/packages/motion/src/interpolate.ts @@ -21,6 +21,9 @@ export function mapRange( const clamp = options?.clamp ?? true; if (inMin === inMax) { + if (clamp && outMin !== outMax) { + return Math.min(outMin, outMax); + } return outMin; } diff --git a/packages/quick/src/widgets.ts b/packages/quick/src/widgets.ts index 1856e4134..b70e2ba88 100644 --- a/packages/quick/src/widgets.ts +++ b/packages/quick/src/widgets.ts @@ -402,14 +402,13 @@ export function tabs(items: Array<[string, Widget]>, opts?: QuickTabsOptions): W const formattedTabs = items.map(([label, content]) => ({ label, content })); const t = new Tabs(formattedTabs, { activeIndex: opts?.active ?? 0, - active: opts?.active ?? 0 - } as any); // as any: Tabs constructor options not exported from @termuijs/ui + }); if (opts?.onChange) { - if (typeof (t as any).on === 'function') { // as any: Tabs constructor options not exported from @termuijs/ui - (t as any).on('change', opts.onChange); // as any: Tabs constructor options not exported from @termuijs/ui + if (typeof (t as any).on === 'function') { // as any: Tabs does not expose change event API + (t as any).on('change', opts.onChange); // as any: Tabs does not expose change event API } else { - (t as any).onChange = opts.onChange; // as any: Tabs constructor options not exported from @termuijs/ui + (t as any).onChange = opts.onChange; // as any: Tabs does not expose onChange option } } diff --git a/packages/store/src/immutable.test.ts b/packages/store/src/immutable.test.ts index 47ed8762e..01afccbae 100644 --- a/packages/store/src/immutable.test.ts +++ b/packages/store/src/immutable.test.ts @@ -43,6 +43,17 @@ describe('immutable helpers', () => { expect(updated).toEqual({ list: [1, 20, 3] }); expect(original.list).toEqual([1, 2, 3]); // should not mutate }); + + it('setIn from null builds nested path correctly', () => { + const updated = setIn(null, ['a', 'b'], 'value'); + expect(updated).toEqual({ a: { b: 'value' } }); + }); + + it('setIn from null with numeric index creates array', () => { + const updated = setIn(null, ['a', 0, 'b'], 'value'); + expect(updated).toEqual({ a: [{ b: 'value' }] }); + expect(Array.isArray((updated as any).a)).toBe(true); + }); }); describe('updateIn', () => { diff --git a/packages/store/src/immutable.ts b/packages/store/src/immutable.ts index c0acdc5ae..d406a7775 100644 --- a/packages/store/src/immutable.ts +++ b/packages/store/src/immutable.ts @@ -24,13 +24,47 @@ export function setIn(obj: T, path: (string | number)[], value: any): T { clone = [...obj]; } else if (obj !== null && typeof obj === 'object') { clone = { ...obj }; + // If clone is effectively empty (from spreading {} or null-ish) and + // the next key is numeric, replace with an array so that clone[0] = x + // creates a real array element instead of a string-keyed property. + if (clone && typeof key === 'number' && Object.keys(clone).length === 0) { + clone = []; + } } else { + // obj is null or a primitive; create the appropriate container + // based on the type of the next key to construct the nested path clone = typeof key === 'number' ? [] : {}; } - const nextVal = rest.length > 0 - ? setIn(clone[key], rest, value) - : value; + // When recursing, determine the correct container for the next level. + // Reuse an existing non-empty object/array to preserve sibling values. + // Otherwise create a container matching the key type: array for numeric keys, + // object for string keys. + const existing = clone[key]; + const isNonEmptyContainer = existing !== undefined + && existing !== null + && typeof existing === 'object' + && Object.keys(existing).length > 0; + + // For the terminal step (rest.length === 0) with a string key and an empty + // container, set the value directly on clone. If clone is an array and the key + // is a string, convert to a plain object first so the property survives + // JSON serialization; for numeric keys the array form is correct. + if (rest.length === 0) { + if (typeof key !== 'number' && Array.isArray(clone)) { + // Convert array to plain object to preserve the string-keyed property + const converted: any = {}; + for (const k of Object.keys(clone)) converted[k] = clone[k as any]; + clone = converted; + } + clone[key] = value; + return clone as T; + } + + const container = isNonEmptyContainer + ? existing + : (typeof key === 'number' ? [] : {}); + const nextVal = setIn(container, rest, value); clone[key] = nextVal; return clone as T; diff --git a/packages/ui/src/Tabs.ts b/packages/ui/src/Tabs.ts index 2185b6366..fff59165a 100644 --- a/packages/ui/src/Tabs.ts +++ b/packages/ui/src/Tabs.ts @@ -7,6 +7,8 @@ export interface TabsOptions { activeColor?: Style['fg']; inactiveColor?: Style['fg']; border?: Style['border']; + /** Initial active tab index. Defaults to 0. */ + activeIndex?: number; } export class Tabs extends Widget { @@ -21,6 +23,7 @@ export class Tabs extends Widget { constructor(tabs: Tab[], options: TabsOptions = {}) { super(mergeStyles(defaultStyle(), { flexGrow: 1, border: options.border ?? 'single' })); this._tabs = tabs; + this._activeIndex = options.activeIndex ?? 0; this._activeColor = options.activeColor ?? { type: 'named', name: 'cyan' }; this._inactiveColor = options.inactiveColor ?? { type: 'named', name: 'brightBlack' }; this.events.on('key', this._keyHandler);