refactor(cli): better react patterns for BaseSettingsDialog (#21206)

This commit is contained in:
Pyush Sinha
2026-03-09 11:35:08 -07:00
committed by GitHub
parent 4c9f9bb3e2
commit b68d7bc0f9
10 changed files with 834 additions and 282 deletions

View File

@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useInlineEditBuffer } from './useInlineEditBuffer.js';
describe('useEditBuffer', () => {
let mockOnCommit: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockOnCommit = vi.fn();
});
it('should initialize with empty state', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
expect(result.current.editState.editingKey).toBeNull();
expect(result.current.editState.buffer).toBe('');
expect(result.current.editState.cursorPos).toBe(0);
});
it('should start editing correctly', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('my-key', 'initial'));
expect(result.current.editState.editingKey).toBe('my-key');
expect(result.current.editState.buffer).toBe('initial');
expect(result.current.editState.cursorPos).toBe(7); // End of string
});
it('should commit edit and reset state', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('my-key', 'text'));
act(() => result.current.commitEdit());
expect(mockOnCommit).toHaveBeenCalledWith('my-key', 'text');
expect(result.current.editState.editingKey).toBeNull();
expect(result.current.editState.buffer).toBe('');
});
it('should move cursor left and right', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', 'ab')); // cursor at 2
act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
expect(result.current.editState.cursorPos).toBe(1);
act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
expect(result.current.editState.cursorPos).toBe(0);
// Shouldn't go below 0
act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
expect(result.current.editState.cursorPos).toBe(0);
act(() => result.current.editDispatch({ type: 'MOVE_RIGHT' }));
expect(result.current.editState.cursorPos).toBe(1);
});
it('should handle home and end', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', 'testing')); // cursor at 7
act(() => result.current.editDispatch({ type: 'HOME' }));
expect(result.current.editState.cursorPos).toBe(0);
act(() => result.current.editDispatch({ type: 'END' }));
expect(result.current.editState.cursorPos).toBe(7);
});
it('should delete characters to the left (backspace)', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', 'abc')); // cursor at 3
act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
expect(result.current.editState.buffer).toBe('ab');
expect(result.current.editState.cursorPos).toBe(2);
// Move to start, shouldn't delete
act(() => result.current.editDispatch({ type: 'HOME' }));
act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
expect(result.current.editState.buffer).toBe('ab');
});
it('should delete characters to the right (delete tab)', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', 'abc'));
act(() => result.current.editDispatch({ type: 'HOME' })); // cursor at 0
act(() => result.current.editDispatch({ type: 'DELETE_RIGHT' }));
expect(result.current.editState.buffer).toBe('bc');
expect(result.current.editState.cursorPos).toBe(0);
});
it('should insert valid characters into string', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', 'ab'));
act(() => result.current.editDispatch({ type: 'MOVE_LEFT' })); // cursor at 1
act(() =>
result.current.editDispatch({
type: 'INSERT_CHAR',
char: 'x',
isNumberType: false,
}),
);
expect(result.current.editState.buffer).toBe('axb');
expect(result.current.editState.cursorPos).toBe(2);
});
it('should validate number character insertions', () => {
const { result } = renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),
);
act(() => result.current.startEditing('key', '12'));
// Valid number char
act(() =>
result.current.editDispatch({
type: 'INSERT_CHAR',
char: '.',
isNumberType: true,
}),
);
expect(result.current.editState.buffer).toBe('12.');
// Invalid number char
act(() =>
result.current.editDispatch({
type: 'INSERT_CHAR',
char: 'a',
isNumberType: true,
}),
);
expect(result.current.editState.buffer).toBe('12.'); // Unchanged
});
});

View File

@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useReducer, useCallback, useEffect, useState } from 'react';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
export interface EditBufferState {
editingKey: string | null;
buffer: string;
cursorPos: number;
}
export type EditBufferAction =
| { type: 'START_EDIT'; key: string; initialValue: string }
| { type: 'COMMIT_EDIT' }
| { type: 'MOVE_LEFT' }
| { type: 'MOVE_RIGHT' }
| { type: 'HOME' }
| { type: 'END' }
| { type: 'DELETE_LEFT' }
| { type: 'DELETE_RIGHT' }
| { type: 'INSERT_CHAR'; char: string; isNumberType: boolean };
const initialState: EditBufferState = {
editingKey: null,
buffer: '',
cursorPos: 0,
};
function editBufferReducer(
state: EditBufferState,
action: EditBufferAction,
): EditBufferState {
switch (action.type) {
case 'START_EDIT':
return {
editingKey: action.key,
buffer: action.initialValue,
cursorPos: cpLen(action.initialValue),
};
case 'COMMIT_EDIT':
return initialState;
case 'MOVE_LEFT':
return {
...state,
cursorPos: Math.max(0, state.cursorPos - 1),
};
case 'MOVE_RIGHT':
return {
...state,
cursorPos: Math.min(cpLen(state.buffer), state.cursorPos + 1),
};
case 'HOME':
return { ...state, cursorPos: 0 };
case 'END':
return { ...state, cursorPos: cpLen(state.buffer) };
case 'DELETE_LEFT': {
if (state.cursorPos === 0) return state;
const before = cpSlice(state.buffer, 0, state.cursorPos - 1);
const after = cpSlice(state.buffer, state.cursorPos);
return {
...state,
buffer: before + after,
cursorPos: state.cursorPos - 1,
};
}
case 'DELETE_RIGHT': {
if (state.cursorPos === cpLen(state.buffer)) return state;
const before = cpSlice(state.buffer, 0, state.cursorPos);
const after = cpSlice(state.buffer, state.cursorPos + 1);
return {
...state,
buffer: before + after,
};
}
case 'INSERT_CHAR': {
let ch = action.char;
let isValidChar = false;
if (action.isNumberType) {
isValidChar = /[0-9\-+.]/.test(ch);
} else {
isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
ch = stripUnsafeCharacters(ch);
}
if (!isValidChar || ch.length === 0) return state;
const before = cpSlice(state.buffer, 0, state.cursorPos);
const after = cpSlice(state.buffer, state.cursorPos);
return {
...state,
buffer: before + ch + after,
cursorPos: state.cursorPos + 1,
};
}
default:
return state;
}
}
export interface UseEditBufferProps {
onCommit: (key: string, value: string) => void;
}
export function useInlineEditBuffer({ onCommit }: UseEditBufferProps) {
const [state, dispatch] = useReducer(editBufferReducer, initialState);
const [cursorVisible, setCursorVisible] = useState(true);
useEffect(() => {
if (!state.editingKey) {
setCursorVisible(true);
return;
}
setCursorVisible(true);
const interval = setInterval(() => {
setCursorVisible((v) => !v);
}, 500);
return () => clearInterval(interval);
}, [state.editingKey, state.buffer, state.cursorPos]);
const startEditing = useCallback((key: string, initialValue: string) => {
dispatch({ type: 'START_EDIT', key, initialValue });
}, []);
const commitEdit = useCallback(() => {
if (state.editingKey) {
onCommit(state.editingKey, state.buffer);
}
dispatch({ type: 'COMMIT_EDIT' });
}, [state.editingKey, state.buffer, onCommit]);
return {
editState: state,
editDispatch: dispatch,
startEditing,
commitEdit,
cursorVisible,
};
}

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect } from 'vitest';
import { useSettingsNavigation } from './useSettingsNavigation.js';
describe('useSettingsNavigation', () => {
const mockItems = [
{ key: 'a' },
{ key: 'b' },
{ key: 'c' },
{ key: 'd' },
{ key: 'e' },
];
it('should initialize with the first item active', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
expect(result.current.activeIndex).toBe(0);
expect(result.current.activeItemKey).toBe('a');
expect(result.current.windowStart).toBe(0);
});
it('should move down correctly', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
act(() => result.current.moveDown());
expect(result.current.activeIndex).toBe(1);
expect(result.current.activeItemKey).toBe('b');
});
it('should move up correctly', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
act(() => result.current.moveDown()); // to index 1
act(() => result.current.moveUp()); // back to 0
expect(result.current.activeIndex).toBe(0);
});
it('should wrap around from top to bottom', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
act(() => result.current.moveUp());
expect(result.current.activeIndex).toBe(4);
expect(result.current.activeItemKey).toBe('e');
});
it('should wrap around from bottom to top', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
// Move to last item
// Move to last item (index 4)
act(() => result.current.moveDown()); // 1
act(() => result.current.moveDown()); // 2
act(() => result.current.moveDown()); // 3
act(() => result.current.moveDown()); // 4
expect(result.current.activeIndex).toBe(4);
// Move down once more
act(() => result.current.moveDown());
expect(result.current.activeIndex).toBe(0);
});
it('should adjust scrollOffset when moving down past visible area', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
act(() => result.current.moveDown()); // index 1
act(() => result.current.moveDown()); // index 2, still offset 0
expect(result.current.windowStart).toBe(0);
act(() => result.current.moveDown()); // index 3, offset should be 1
expect(result.current.windowStart).toBe(1);
});
it('should adjust scrollOffset when moving up past visible area', () => {
const { result } = renderHook(() =>
useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
);
act(() => result.current.moveDown()); // 1
act(() => result.current.moveDown()); // 2
act(() => result.current.moveDown()); // 3
expect(result.current.windowStart).toBe(1);
act(() => result.current.moveUp()); // index 2
act(() => result.current.moveUp()); // index 1, offset should become 1
act(() => result.current.moveUp()); // index 0, offset should become 0
expect(result.current.windowStart).toBe(0);
});
it('should handle item preservation when list filters (Part 1 logic)', () => {
let items = mockItems;
const { result, rerender } = renderHook(
({ list }) => useSettingsNavigation({ items: list, maxItemsToShow: 3 }),
{ initialProps: { list: items } },
);
act(() => result.current.moveDown());
act(() => result.current.moveDown()); // Item 'c'
expect(result.current.activeItemKey).toBe('c');
// Filter items but keep 'c'
items = [mockItems[0], mockItems[2], mockItems[4]]; // 'a', 'c', 'e'
rerender({ list: items });
expect(result.current.activeItemKey).toBe('c');
expect(result.current.activeIndex).toBe(1); // 'c' is now at index 1
});
});

View File

@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo, useReducer, useCallback } from 'react';
export interface UseSettingsNavigationProps {
items: Array<{ key: string }>;
maxItemsToShow: number;
}
type NavState = {
activeItemKey: string | null;
windowStart: number;
};
type NavAction = { type: 'MOVE_UP' } | { type: 'MOVE_DOWN' };
function calculateSlidingWindow(
start: number,
activeIndex: number,
itemCount: number,
windowSize: number,
): number {
// User moves up above the window start
if (activeIndex < start) {
start = activeIndex;
// User moves down below the window end
} else if (activeIndex >= start + windowSize) {
start = activeIndex - windowSize + 1;
}
// User is inside the window but performed search or terminal resized
const maxScroll = Math.max(0, itemCount - windowSize);
const bounded = Math.min(start, maxScroll);
return Math.max(0, bounded);
}
function createNavReducer(
items: Array<{ key: string }>,
maxItemsToShow: number,
) {
return function navReducer(state: NavState, action: NavAction): NavState {
if (items.length === 0) return state;
const currentIndex = items.findIndex((i) => i.key === state.activeItemKey);
const activeIndex = currentIndex !== -1 ? currentIndex : 0;
switch (action.type) {
case 'MOVE_UP': {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
return {
activeItemKey: items[newIndex].key,
windowStart: calculateSlidingWindow(
state.windowStart,
newIndex,
items.length,
maxItemsToShow,
),
};
}
case 'MOVE_DOWN': {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
return {
activeItemKey: items[newIndex].key,
windowStart: calculateSlidingWindow(
state.windowStart,
newIndex,
items.length,
maxItemsToShow,
),
};
}
default: {
return state;
}
}
};
}
export function useSettingsNavigation({
items,
maxItemsToShow,
}: UseSettingsNavigationProps) {
const reducer = useMemo(
() => createNavReducer(items, maxItemsToShow),
[items, maxItemsToShow],
);
const [state, dispatch] = useReducer(reducer, {
activeItemKey: items[0]?.key ?? null,
windowStart: 0,
});
// Retain the proper highlighting when items change (e.g. search)
const activeIndex = useMemo(() => {
if (items.length === 0) return 0;
const idx = items.findIndex((i) => i.key === state.activeItemKey);
return idx !== -1 ? idx : 0;
}, [items, state.activeItemKey]);
const windowStart = useMemo(
() =>
calculateSlidingWindow(
state.windowStart,
activeIndex,
items.length,
maxItemsToShow,
),
[state.windowStart, activeIndex, items.length, maxItemsToShow],
);
const moveUp = useCallback(() => dispatch({ type: 'MOVE_UP' }), []);
const moveDown = useCallback(() => dispatch({ type: 'MOVE_DOWN' }), []);
return {
activeItemKey: state.activeItemKey,
activeIndex,
windowStart,
moveUp,
moveDown,
};
}