/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Text } from 'ink'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; import type { LoadableSettingScope } from '../../../config/settings.js'; 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, stripUnsafeCharacters, cpIndexToOffset, } 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. */ export interface SettingsDialogItem { /** Unique identifier for the item */ key: string; /** Display label */ label: string; /** Optional description below label */ description?: string; /** Item type for determining interaction behavior */ type: 'boolean' | 'number' | 'string' | 'enum'; /** Pre-formatted display value (with * if modified) */ displayValue: string; /** Grey out value (at default) */ isGreyedOut?: boolean; /** Scope message e.g., "(Modified in Workspace)" */ scopeMessage?: string; /** Raw value for edit mode initialization */ rawValue?: string | number | boolean; } /** * Props for BaseSettingsDialog component. */ export interface BaseSettingsDialogProps { // Header /** Dialog title displayed at the top */ title: string; /** Optional border color for the dialog */ borderColor?: string; // Search (optional feature) /** Whether to show the search input. Default: true */ searchEnabled?: boolean; /** Placeholder text for search input. Default: "Search to filter" */ searchPlaceholder?: string; /** Text buffer for search input */ searchBuffer?: TextBuffer; // Items - parent provides the list /** List of items to display */ items: SettingsDialogItem[]; // Scope selector /** Whether to show the scope selector. Default: true */ showScopeSelector?: boolean; /** Currently selected scope */ selectedScope: LoadableSettingScope; /** 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, layout, and keyboard navigation. * Parent components handle business logic (saving, filtering, etc.) via callbacks. */ export function BaseSettingsDialog({ title, borderColor, searchEnabled = true, searchPlaceholder = 'Search to filter', searchBuffer, items, showScopeSelector = true, selectedScope, 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(null); const [editBuffer, setEditBuffer] = useState(''); const [editCursorPos, setEditCursorPos] = useState(0); const [cursorVisible, setCursorVisible] = useState(true); const prevItemsRef = useRef(items); // Preserve focus when items change (e.g., search filter) useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { const prevActiveItem = prevItems[activeIndex]; if (prevActiveItem) { const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); if (newIndex !== -1) { // Item still exists in the filtered list, keep focus on it setActiveIndex(newIndex); // Adjust scroll offset to ensure the item is visible let newScroll = scrollOffset; if (newIndex < scrollOffset) newScroll = newIndex; else if (newIndex >= scrollOffset + maxItemsToShow) newScroll = newIndex - maxItemsToShow + 1; const maxScroll = Math.max(0, items.length - maxItemsToShow); setScrollOffset(Math.min(newScroll, maxScroll)); } else { // Item was filtered out, reset to the top setActiveIndex(0); setScrollOffset(0); } } else { setActiveIndex(0); setScrollOffset(0); } prevItemsRef.current = items; } }, [items, activeIndex, scrollOffset, maxItemsToShow]); // 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, key: item.value, })); // Calculate visible items based on scroll offset const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); // Show scroll indicators if there are more items than can be displayed 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 true; } 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 true; } // 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 true; } // 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 true; } // Number keys for quick edit on number fields if (currentItem?.type === 'number' && /^[0-9]$/.test(key.sequence)) { startEditing(currentItem.key, key.sequence); return true; } } // 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; } return; }, { isActive: true }, ); return ( {/* Title */} {focusSection === 'settings' ? '> ' : ' '} {title}{' '} {/* Search input (if enabled) */} {searchEnabled && searchBuffer && ( )} {/* Items list */} {visibleItems.length === 0 ? ( No matches found. ) : ( <> {showScrollUp && ( )} {visibleItems.map((item, idx) => { const globalIndex = idx + scrollOffset; const isActive = focusSection === 'settings' && activeIndex === globalIndex; // Compute display value with edit mode cursor let displayValue: string; if (editingKey === item.key) { // Show edit buffer with cursor highlighting if (cursorVisible && editCursorPos < cpLen(editBuffer)) { // Cursor is in the middle or at start of text const beforeCursor = cpSlice(editBuffer, 0, editCursorPos); const atCursor = cpSlice( editBuffer, editCursorPos, editCursorPos + 1, ); const afterCursor = cpSlice(editBuffer, editCursorPos + 1); displayValue = beforeCursor + chalk.inverse(atCursor) + afterCursor; } else if (editCursorPos >= cpLen(editBuffer)) { // Cursor is at the end - show inverted space displayValue = editBuffer + (cursorVisible ? chalk.inverse(' ') : ' '); } else { // Cursor not visible displayValue = editBuffer; } } else { displayValue = item.displayValue; } return ( {isActive ? '●' : ''} {item.label} {item.scopeMessage && ( {' '} {item.scopeMessage} )} {item.description ?? ''} {displayValue} ); })} {showScrollDown && ( )} )} {/* Scope Selection */} {showScopeSelector && ( {focusSection === 'scope' ? '> ' : ' '}Apply To item.value === selectedScope, )} onSelect={handleScopeSelect} onHighlight={handleScopeHighlight} isFocused={focusSection === 'scope'} showNumbers={focusSection === 'scope'} priority={focusSection === 'scope'} /> )} {/* Help text */} (Use Enter to select, Ctrl+L to reset {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close) {/* Footer content (e.g., restart prompt) */} {footerContent && {footerContent}} ); }