Skip to content
25 changes: 25 additions & 0 deletions packages/core/src/history/CommandHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
10 changes: 9 additions & 1 deletion packages/core/src/history/CommandHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/motion/src/interpolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 4 additions & 5 deletions packages/quick/src/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/store/src/immutable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
40 changes: 37 additions & 3 deletions packages/store/src/immutable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,47 @@ export function setIn<T>(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;
Expand Down
21 changes: 21 additions & 0 deletions packages/tss/src/tokenizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ describe('TSS Tokenizer', () => {
expect(num!.value).toBe('-2');
});

it('tokenizes scientific notation with positive exponent', () => {
const tokens = tokenize('opacity: 1e5;');
const num = tokens.find(t => t.type === TokenType.Number);
expect(num).toBeDefined();
expect(num!.value).toBe('1e5');
});

it('tokenizes scientific notation with negative exponent', () => {
const tokens = tokenize('width: 1.5e-2;');
const num = tokens.find(t => t.type === TokenType.Number);
expect(num).toBeDefined();
expect(num!.value).toBe('1.5e-2');
});

it('tokenizes scientific notation with explicit positive sign', () => {
const tokens = tokenize('scale: 2E+3;');
const num = tokens.find(t => t.type === TokenType.Number);
expect(num).toBeDefined();
expect(num!.value).toBe('2E+3');
});

it('tokenizes CSS variables --name', () => {
const tokens = tokenize('--primary: cyan;');
const variable = tokens.find(t => t.type === TokenType.Variable);
Expand Down
8 changes: 7 additions & 1 deletion packages/tss/src/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,18 @@ export function tokenize(source: string): Token[] {
continue;
}

// Numbers
// Numbers (including scientific notation: 1e5, 1.5e-2, 2E+3)
if (/[0-9]/.test(ch) || (ch === '-' && /[0-9.]/.test(at(pos + 1)))) {
const startCol = col;
let num = '';
if (ch === '-') num += advance();
while (pos < source.length && /[0-9.]/.test(peek())) num += advance();
// Handle exponent: e or E, optionally followed by +/- then digits
if (pos < source.length && /[eE]/.test(peek())) {
num += advance();
if (pos < source.length && /[+-]/.test(peek())) num += advance();
while (pos < source.length && /[0-9]/.test(peek())) num += advance();
}
tokens.push({ type: TokenType.Number, value: num, line, col: startCol });
continue;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/Tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
Loading