mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-20 19:11:23 -07:00
refactor(cli): better react patterns for BaseSettingsDialog (#21206)
This commit is contained in:
158
packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
Normal file
158
packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
152
packages/cli/src/ui/hooks/useInlineEditBuffer.ts
Normal file
152
packages/cli/src/ui/hooks/useInlineEditBuffer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
121
packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
Normal file
121
packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
124
packages/cli/src/ui/hooks/useSettingsNavigation.ts
Normal file
124
packages/cli/src/ui/hooks/useSettingsNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user