mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-20 11:00:40 -07:00
feat(ui): Move keyboard handling into BaseSettingsDialog (#17404)
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import {
|
||||
BaseSettingsDialog,
|
||||
type BaseSettingsDialogProps,
|
||||
type SettingsDialogItem,
|
||||
} from './BaseSettingsDialog.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { SettingScope } from '../../../config/settings.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
mainAreaWidth: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
enum TerminalKeys {
|
||||
ENTER = '\u000D',
|
||||
TAB = '\t',
|
||||
UP_ARROW = '\u001B[A',
|
||||
DOWN_ARROW = '\u001B[B',
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_L = '\u000C',
|
||||
}
|
||||
|
||||
const createMockItems = (): SettingsDialogItem[] => [
|
||||
{
|
||||
key: 'boolean-setting',
|
||||
label: 'Boolean Setting',
|
||||
description: 'A boolean setting for testing',
|
||||
displayValue: 'true',
|
||||
rawValue: true,
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
key: 'string-setting',
|
||||
label: 'String Setting',
|
||||
description: 'A string setting for testing',
|
||||
displayValue: 'test-value',
|
||||
rawValue: 'test-value',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'number-setting',
|
||||
label: 'Number Setting',
|
||||
description: 'A number setting for testing',
|
||||
displayValue: '42',
|
||||
rawValue: 42,
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
key: 'enum-setting',
|
||||
label: 'Enum Setting',
|
||||
description: 'An enum setting for testing',
|
||||
displayValue: 'option-a',
|
||||
rawValue: 'option-a',
|
||||
type: 'enum',
|
||||
},
|
||||
];
|
||||
|
||||
describe('BaseSettingsDialog', () => {
|
||||
let mockOnItemToggle: ReturnType<typeof vi.fn>;
|
||||
let mockOnEditCommit: ReturnType<typeof vi.fn>;
|
||||
let mockOnItemClear: ReturnType<typeof vi.fn>;
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
let mockOnScopeChange: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnItemToggle = vi.fn();
|
||||
mockOnEditCommit = vi.fn();
|
||||
mockOnItemClear = vi.fn();
|
||||
mockOnClose = vi.fn();
|
||||
mockOnScopeChange = vi.fn();
|
||||
});
|
||||
|
||||
const renderDialog = (props: Partial<BaseSettingsDialogProps> = {}) => {
|
||||
const defaultProps: BaseSettingsDialogProps = {
|
||||
title: 'Test Settings',
|
||||
items: createMockItems(),
|
||||
selectedScope: SettingScope.User,
|
||||
maxItemsToShow: 8,
|
||||
onItemToggle: mockOnItemToggle,
|
||||
onEditCommit: mockOnEditCommit,
|
||||
onItemClear: mockOnItemClear,
|
||||
onClose: mockOnClose,
|
||||
...props,
|
||||
};
|
||||
|
||||
return render(
|
||||
<KeypressProvider>
|
||||
<BaseSettingsDialog {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the dialog with title', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
expect(lastFrame()).toContain('Test Settings');
|
||||
});
|
||||
|
||||
it('should render all items', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Boolean Setting');
|
||||
expect(frame).toContain('String Setting');
|
||||
expect(frame).toContain('Number Setting');
|
||||
expect(frame).toContain('Enum Setting');
|
||||
});
|
||||
|
||||
it('should render help text with Ctrl+L for reset', () => {
|
||||
const { lastFrame } = renderDialog();
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Use Enter to select');
|
||||
expect(frame).toContain('Ctrl+L to reset');
|
||||
expect(frame).toContain('Tab to change focus');
|
||||
expect(frame).toContain('Esc to close');
|
||||
});
|
||||
|
||||
it('should render scope selector when showScopeSelector is true', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
showScopeSelector: true,
|
||||
onScopeChange: mockOnScopeChange,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Apply To');
|
||||
});
|
||||
|
||||
it('should not render scope selector when showScopeSelector is false', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
showScopeSelector: false,
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('Apply To');
|
||||
});
|
||||
|
||||
it('should render footer content when provided', () => {
|
||||
const { lastFrame } = renderDialog({
|
||||
footerContent: <Text>Custom Footer</Text>,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Custom Footer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should close dialog on Escape', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ESCAPE);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate down with arrow key', async () => {
|
||||
const { lastFrame, stdin } = renderDialog();
|
||||
|
||||
// Initially first item is active (indicated by bullet point)
|
||||
const initialFrame = lastFrame();
|
||||
expect(initialFrame).toContain('Boolean Setting');
|
||||
|
||||
// Press down arrow
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
// Navigation should move to next item
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// The active indicator should now be on a different row
|
||||
expect(frame).toContain('String Setting');
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate up with arrow key', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
|
||||
// Press down then up
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
|
||||
// Should be back at first item
|
||||
await waitFor(() => {
|
||||
// First item should be active again
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around when navigating past last item', async () => {
|
||||
const items = createMockItems().slice(0, 2); // Only 2 items
|
||||
const { stdin } = renderDialog({ items });
|
||||
|
||||
// Press down twice to go past the last item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
// Should wrap to first item - verify no crash
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around when navigating before first item', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
|
||||
// Press up at first item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
|
||||
// Should wrap to last item - verify no crash
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch focus with Tab when scope selector is shown', async () => {
|
||||
const { lastFrame, stdin } = renderDialog({
|
||||
showScopeSelector: true,
|
||||
onScopeChange: mockOnScopeChange,
|
||||
});
|
||||
|
||||
// Initially settings section is focused (indicated by >)
|
||||
expect(lastFrame()).toContain('> Test Settings');
|
||||
|
||||
// Press Tab to switch to scope selector
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.TAB);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('> Apply To');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('item interactions', () => {
|
||||
it('should call onItemToggle for boolean items on Enter', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
|
||||
// Press Enter on first item (boolean)
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||||
'boolean-setting',
|
||||
expect.objectContaining({ type: 'boolean' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onItemToggle for enum items on Enter', async () => {
|
||||
const items = createMockItems();
|
||||
// Move enum to first position
|
||||
const enumItem = items.find((i) => i.type === 'enum')!;
|
||||
const { stdin } = renderDialog({ items: [enumItem] });
|
||||
|
||||
// Press Enter on enum item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||||
'enum-setting',
|
||||
expect.objectContaining({ type: 'enum' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should enter edit mode for string items on Enter', async () => {
|
||||
const items = createMockItems();
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { lastFrame, stdin } = renderDialog({ items: [stringItem] });
|
||||
|
||||
// Press Enter to start editing
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Should show the edit buffer with cursor
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// In edit mode, the value should be displayed (possibly with cursor)
|
||||
expect(frame).toContain('test-value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should enter edit mode for number items on Enter', async () => {
|
||||
const items = createMockItems();
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { lastFrame, stdin } = renderDialog({ items: [numberItem] });
|
||||
|
||||
// Press Enter to start editing
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Should show the edit buffer
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('42');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onItemClear on Ctrl+L', async () => {
|
||||
const { stdin } = renderDialog();
|
||||
|
||||
// Press Ctrl+L to reset
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.CTRL_L);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnItemClear).toHaveBeenCalledWith(
|
||||
'boolean-setting',
|
||||
expect.objectContaining({ type: 'boolean' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should commit edit on Enter', async () => {
|
||||
const items = createMockItems();
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem] });
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Type some characters
|
||||
await act(async () => {
|
||||
stdin.write('x');
|
||||
});
|
||||
|
||||
// Commit with Enter
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'string-setting',
|
||||
'test-valuex',
|
||||
expect.objectContaining({ type: 'string' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit on Escape', async () => {
|
||||
const items = createMockItems();
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem] });
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Commit with Escape
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ESCAPE);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit and navigate on Down arrow', async () => {
|
||||
const items = createMockItems();
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Press Down to commit and navigate
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit and navigate on Up arrow', async () => {
|
||||
const items = createMockItems();
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||||
|
||||
// Navigate to second item
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Press Up to commit and navigate
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow number input for number fields', async () => {
|
||||
const items = createMockItems();
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [numberItem] });
|
||||
|
||||
// Enter edit mode
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
// Type numbers one at a time
|
||||
await act(async () => {
|
||||
stdin.write('1');
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('2');
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('3');
|
||||
});
|
||||
|
||||
// Commit
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'number-setting',
|
||||
'42123',
|
||||
expect.objectContaining({ type: 'number' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should support quick number entry for number fields', async () => {
|
||||
const items = createMockItems();
|
||||
const numberItem = items.find((i) => i.type === 'number')!;
|
||||
const { stdin } = renderDialog({ items: [numberItem] });
|
||||
|
||||
// Type a number directly (without Enter first)
|
||||
await act(async () => {
|
||||
stdin.write('5');
|
||||
});
|
||||
|
||||
// Should start editing with that number
|
||||
await waitFor(() => {
|
||||
// Commit to verify
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'number-setting',
|
||||
'5',
|
||||
expect.objectContaining({ type: 'number' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom key handling', () => {
|
||||
it('should call onKeyPress and respect its return value', async () => {
|
||||
const customKeyHandler = vi.fn().mockReturnValue(true);
|
||||
const { stdin } = renderDialog({
|
||||
onKeyPress: customKeyHandler,
|
||||
});
|
||||
|
||||
// Press a key
|
||||
await act(async () => {
|
||||
stdin.write('r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(customKeyHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Since handler returned true, default behavior should be blocked
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus management', () => {
|
||||
it('should keep focus on settings when scope selector is hidden', async () => {
|
||||
const { lastFrame, stdin } = renderDialog({
|
||||
showScopeSelector: false,
|
||||
});
|
||||
|
||||
// Press Tab - should not crash and focus should stay on settings
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.TAB);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should still show settings as focused
|
||||
expect(lastFrame()).toContain('> Test Settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -13,7 +13,13 @@ import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
||||
import {
|
||||
cpSlice,
|
||||
cpLen,
|
||||
stripUnsafeCharacters,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
/**
|
||||
* Represents a single item in the settings dialog.
|
||||
@@ -33,6 +39,8 @@ export interface SettingsDialogItem {
|
||||
isGreyedOut?: boolean;
|
||||
/** Scope message e.g., "(Modified in Workspace)" */
|
||||
scopeMessage?: string;
|
||||
/** Raw value for edit mode initialization */
|
||||
rawValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,51 +62,48 @@ export interface BaseSettingsDialogProps {
|
||||
// Items - parent provides the list
|
||||
/** List of items to display */
|
||||
items: SettingsDialogItem[];
|
||||
/** Currently active/highlighted item index */
|
||||
activeIndex: number;
|
||||
|
||||
// Edit mode state
|
||||
/** Key of the item currently being edited, or null if not editing */
|
||||
editingKey: string | null;
|
||||
/** Current edit buffer content */
|
||||
editBuffer: string;
|
||||
/** Cursor position within edit buffer */
|
||||
editCursorPos: number;
|
||||
/** Whether cursor is visible (for blinking effect) */
|
||||
cursorVisible: boolean;
|
||||
|
||||
// Scope selector
|
||||
/** Whether to show the scope selector. Default: true */
|
||||
showScopeSelector?: boolean;
|
||||
/** Currently selected scope */
|
||||
selectedScope: LoadableSettingScope;
|
||||
/** Callback when scope is highlighted (hovered/navigated to) */
|
||||
onScopeHighlight?: (scope: LoadableSettingScope) => void;
|
||||
/** Callback when scope is selected (Enter pressed) */
|
||||
onScopeSelect?: (scope: LoadableSettingScope) => void;
|
||||
|
||||
// Focus management
|
||||
/** Which section has focus: 'settings' or 'scope' */
|
||||
focusSection: 'settings' | 'scope';
|
||||
|
||||
// Scroll
|
||||
/** Current scroll offset */
|
||||
scrollOffset: number;
|
||||
/** Maximum number of items to show at once */
|
||||
maxItemsToShow: number;
|
||||
/** Callback when scope changes */
|
||||
onScopeChange?: (scope: LoadableSettingScope) => void;
|
||||
|
||||
// Layout
|
||||
/** Maximum number of items to show at once */
|
||||
maxItemsToShow: number;
|
||||
/** Maximum label width for alignment */
|
||||
maxLabelWidth?: number;
|
||||
|
||||
// Action callbacks
|
||||
/** Called when a boolean/enum item is toggled */
|
||||
onItemToggle: (key: string, item: SettingsDialogItem) => void;
|
||||
/** Called when edit mode is committed with new value */
|
||||
onEditCommit: (
|
||||
key: string,
|
||||
newValue: string,
|
||||
item: SettingsDialogItem,
|
||||
) => void;
|
||||
/** Called when Ctrl+C is pressed to clear/reset an item */
|
||||
onItemClear: (key: string, item: SettingsDialogItem) => void;
|
||||
/** Called when dialog should close */
|
||||
onClose: () => void;
|
||||
/** Optional custom key handler for parent-specific keys. Return true if handled. */
|
||||
onKeyPress?: (
|
||||
key: Key,
|
||||
currentItem: SettingsDialogItem | undefined,
|
||||
) => boolean;
|
||||
|
||||
// Optional extra content below help text (for restart prompt, etc.)
|
||||
/** Optional footer content (e.g., restart prompt) */
|
||||
footerContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A base settings dialog component that handles rendering and layout.
|
||||
* Parent components handle business logic (saving, filtering, etc.).
|
||||
* A base settings dialog component that handles rendering, layout, and keyboard navigation.
|
||||
* Parent components handle business logic (saving, filtering, etc.) via callbacks.
|
||||
*/
|
||||
export function BaseSettingsDialog({
|
||||
title,
|
||||
@@ -106,21 +111,53 @@ export function BaseSettingsDialog({
|
||||
searchPlaceholder = 'Search to filter',
|
||||
searchBuffer,
|
||||
items,
|
||||
activeIndex,
|
||||
editingKey,
|
||||
editBuffer,
|
||||
editCursorPos,
|
||||
cursorVisible,
|
||||
showScopeSelector = true,
|
||||
selectedScope,
|
||||
onScopeHighlight,
|
||||
onScopeSelect,
|
||||
focusSection,
|
||||
scrollOffset,
|
||||
onScopeChange,
|
||||
maxItemsToShow,
|
||||
maxLabelWidth,
|
||||
onItemToggle,
|
||||
onEditCommit,
|
||||
onItemClear,
|
||||
onClose,
|
||||
onKeyPress,
|
||||
footerContent,
|
||||
}: BaseSettingsDialogProps): React.JSX.Element {
|
||||
// Internal state
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||
'settings',
|
||||
);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState('');
|
||||
const [editCursorPos, setEditCursorPos] = useState(0);
|
||||
const [cursorVisible, setCursorVisible] = useState(true);
|
||||
|
||||
// Reset active index when items change (e.g., search filter)
|
||||
useEffect(() => {
|
||||
if (activeIndex >= items.length) {
|
||||
setActiveIndex(Math.max(0, items.length - 1));
|
||||
}
|
||||
}, [items.length, activeIndex]);
|
||||
|
||||
// Cursor blink effect
|
||||
useEffect(() => {
|
||||
if (!editingKey) return;
|
||||
setCursorVisible(true);
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v);
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [editingKey]);
|
||||
|
||||
// Ensure focus stays on settings when scope selection is hidden
|
||||
useEffect(() => {
|
||||
if (!showScopeSelector && focusSection === 'scope') {
|
||||
setFocusSection('settings');
|
||||
}
|
||||
}, [showScopeSelector, focusSection]);
|
||||
|
||||
// Scope selector items
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
...item,
|
||||
@@ -134,6 +171,222 @@ export function BaseSettingsDialog({
|
||||
const showScrollUp = items.length > maxItemsToShow;
|
||||
const showScrollDown = items.length > maxItemsToShow;
|
||||
|
||||
// Get current item
|
||||
const currentItem = items[activeIndex];
|
||||
|
||||
// Start editing a field
|
||||
const startEditing = useCallback((key: string, initialValue: string) => {
|
||||
setEditingKey(key);
|
||||
setEditBuffer(initialValue);
|
||||
setEditCursorPos(cpLen(initialValue));
|
||||
setCursorVisible(true);
|
||||
}, []);
|
||||
|
||||
// Commit edit and exit edit mode
|
||||
const commitEdit = useCallback(() => {
|
||||
if (editingKey && currentItem) {
|
||||
onEditCommit(editingKey, editBuffer, currentItem);
|
||||
}
|
||||
setEditingKey(null);
|
||||
setEditBuffer('');
|
||||
setEditCursorPos(0);
|
||||
}, [editingKey, editBuffer, currentItem, onEditCommit]);
|
||||
|
||||
// Handle scope highlight (for RadioButtonSelect)
|
||||
const handleScopeHighlight = useCallback(
|
||||
(scope: LoadableSettingScope) => {
|
||||
onScopeChange?.(scope);
|
||||
},
|
||||
[onScopeChange],
|
||||
);
|
||||
|
||||
// Handle scope select (for RadioButtonSelect)
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: LoadableSettingScope) => {
|
||||
onScopeChange?.(scope);
|
||||
},
|
||||
[onScopeChange],
|
||||
);
|
||||
|
||||
// Keyboard handling
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
// Let parent handle custom keys first
|
||||
if (onKeyPress?.(key, currentItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Edit mode handling
|
||||
if (editingKey) {
|
||||
const item = items.find((i) => i.key === editingKey);
|
||||
const type = item?.type ?? 'string';
|
||||
|
||||
// Navigation within edit buffer
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
setEditCursorPos((p) => Math.max(0, p - 1));
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
setEditCursorPos((p) => Math.min(cpLen(editBuffer), p + 1));
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
setEditCursorPos(0);
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
setEditCursorPos(cpLen(editBuffer));
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
|
||||
if (editCursorPos > 0) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos - 1);
|
||||
const after = cpSlice(b, editCursorPos);
|
||||
return before + after;
|
||||
});
|
||||
setEditCursorPos((p) => p - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
if (editCursorPos < cpLen(editBuffer)) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos);
|
||||
const after = cpSlice(b, editCursorPos + 1);
|
||||
return before + after;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape in edit mode - commit (consistent with SettingsDialog)
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
commitEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter in edit mode - commit
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
commitEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down in edit mode - commit and navigate
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
commitEdit();
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === items.length - 1) {
|
||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
commitEdit();
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Character input
|
||||
let ch = key.sequence;
|
||||
let isValidChar = false;
|
||||
if (type === 'number') {
|
||||
isValidChar = /[0-9\-+.]/.test(ch);
|
||||
} else {
|
||||
isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
|
||||
// Sanitize string input to prevent unsafe characters
|
||||
ch = stripUnsafeCharacters(ch);
|
||||
}
|
||||
|
||||
if (isValidChar && ch.length > 0) {
|
||||
setEditBuffer((b) => {
|
||||
const before = cpSlice(b, 0, editCursorPos);
|
||||
const after = cpSlice(b, editCursorPos);
|
||||
return before + ch + after;
|
||||
});
|
||||
setEditCursorPos((p) => p + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in edit mode - handle navigation and actions
|
||||
if (focusSection === 'settings') {
|
||||
// Up/Down navigation with wrap-around
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === items.length - 1) {
|
||||
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (newIndex === 0) {
|
||||
setScrollOffset(0);
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter - toggle or start edit
|
||||
if (keyMatchers[Command.RETURN](key) && currentItem) {
|
||||
if (currentItem.type === 'boolean' || currentItem.type === 'enum') {
|
||||
onItemToggle(currentItem.key, currentItem);
|
||||
} else {
|
||||
// Start editing for string/number
|
||||
const rawVal = currentItem.rawValue;
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+L - clear/reset to default (using only Ctrl+L to avoid Ctrl+C exit conflict)
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key) && currentItem) {
|
||||
onItemClear(currentItem.key, currentItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys for quick edit on number fields
|
||||
if (currentItem?.type === 'number' && /^[0-9]$/.test(key.sequence)) {
|
||||
startEditing(currentItem.key, key.sequence);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab - switch focus section
|
||||
if (key.name === 'tab' && showScopeSelector) {
|
||||
setFocusSection((s) => (s === 'settings' ? 'scope' : 'settings'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - close dialog
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -305,8 +558,8 @@ export function BaseSettingsDialog({
|
||||
initialIndex={scopeItems.findIndex(
|
||||
(item) => item.value === selectedScope,
|
||||
)}
|
||||
onSelect={onScopeSelect ?? (() => {})}
|
||||
onHighlight={onScopeHighlight}
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
showNumbers={focusSection === 'scope'}
|
||||
/>
|
||||
@@ -318,7 +571,7 @@ export function BaseSettingsDialog({
|
||||
{/* Help text */}
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select
|
||||
(Use Enter to select, Ctrl+L to reset
|
||||
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user