mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(ui): Move keyboard handling into BaseSettingsDialog (#17404)
This commit is contained in:
@@ -4,9 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import type React from 'react';
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { AsyncFzf } from 'fzf';
|
import { AsyncFzf } from 'fzf';
|
||||||
|
import type { Key } from '../hooks/useKeypress.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import type {
|
import type {
|
||||||
LoadableSettingScope,
|
LoadableSettingScope,
|
||||||
@@ -27,23 +29,15 @@ import {
|
|||||||
getRestartRequiredFromModified,
|
getRestartRequiredFromModified,
|
||||||
getEffectiveDefaultValue,
|
getEffectiveDefaultValue,
|
||||||
setPendingSettingValueAny,
|
setPendingSettingValueAny,
|
||||||
getNestedValue,
|
|
||||||
getEffectiveValue,
|
getEffectiveValue,
|
||||||
} from '../../utils/settingsUtils.js';
|
} from '../../utils/settingsUtils.js';
|
||||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
import {
|
|
||||||
cpSlice,
|
|
||||||
cpLen,
|
|
||||||
stripUnsafeCharacters,
|
|
||||||
getCachedStringWidth,
|
|
||||||
} from '../utils/textUtils.js';
|
|
||||||
import {
|
import {
|
||||||
type SettingsValue,
|
type SettingsValue,
|
||||||
TOGGLE_TYPES,
|
TOGGLE_TYPES,
|
||||||
} from '../../config/settingsSchema.js';
|
} from '../../config/settingsSchema.js';
|
||||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useTextBuffer } from './shared/text-buffer.js';
|
import { useTextBuffer } from './shared/text-buffer.js';
|
||||||
@@ -80,28 +74,11 @@ export function SettingsDialog({
|
|||||||
// Get vim mode context to sync vim mode changes
|
// Get vim mode context to sync vim mode changes
|
||||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||||
|
|
||||||
// Focus state: 'settings' or 'scope'
|
|
||||||
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
|
||||||
'settings',
|
|
||||||
);
|
|
||||||
// Scope selector state (User by default)
|
// Scope selector state (User by default)
|
||||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||||
SettingScope.User,
|
SettingScope.User,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scope selection handlers
|
|
||||||
const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => {
|
|
||||||
setSelectedScope(scope);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleScopeSelect = useCallback((scope: LoadableSettingScope) => {
|
|
||||||
setSelectedScope(scope);
|
|
||||||
setFocusSection('settings');
|
|
||||||
}, []);
|
|
||||||
// Active indices
|
|
||||||
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
|
||||||
// Scroll offset for settings
|
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
|
||||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
@@ -148,8 +125,6 @@ export function SettingsDialog({
|
|||||||
if (key) matchedKeys.add(key);
|
if (key) matchedKeys.add(key);
|
||||||
});
|
});
|
||||||
setFilteredKeys(Array.from(matchedKeys));
|
setFilteredKeys(Array.from(matchedKeys));
|
||||||
setActiveSettingIndex(0); // Reset cursor
|
|
||||||
setScrollOffset(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -232,128 +207,76 @@ export function SettingsDialog({
|
|||||||
return max;
|
return max;
|
||||||
}, [selectedScope, settings]);
|
}, [selectedScope, settings]);
|
||||||
|
|
||||||
// Generic edit state
|
// Get mainAreaWidth for search buffer viewport
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
const { mainAreaWidth } = useUIState();
|
||||||
const [editBuffer, setEditBuffer] = useState<string>('');
|
const viewportWidth = mainAreaWidth - 8;
|
||||||
const [editCursorPos, setEditCursorPos] = useState<number>(0);
|
|
||||||
const [cursorVisible, setCursorVisible] = useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Search input buffer
|
||||||
if (!editingKey) {
|
const searchBuffer = useTextBuffer({
|
||||||
setCursorVisible(true);
|
initialText: '',
|
||||||
return;
|
initialCursorOffset: 0,
|
||||||
}
|
viewport: {
|
||||||
const id = setInterval(() => setCursorVisible((v) => !v), 500);
|
width: viewportWidth,
|
||||||
return () => clearInterval(id);
|
height: 1,
|
||||||
}, [editingKey]);
|
},
|
||||||
|
isValidPath: () => false,
|
||||||
|
singleLine: true,
|
||||||
|
onChange: (text) => setSearchQuery(text),
|
||||||
|
});
|
||||||
|
|
||||||
const startEditing = useCallback((key: string, initial?: string) => {
|
// Generate items for BaseSettingsDialog
|
||||||
setEditingKey(key);
|
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
||||||
const initialValue = initial ?? '';
|
const items: SettingsDialogItem[] = useMemo(() => {
|
||||||
setEditBuffer(initialValue);
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||||
setEditCursorPos(cpLen(initialValue));
|
const mergedSettings = settings.merged;
|
||||||
|
|
||||||
|
return settingKeys.map((key) => {
|
||||||
|
const definition = getSettingDefinition(key);
|
||||||
|
const type = definition?.type ?? 'string';
|
||||||
|
|
||||||
|
// Get the display value (with * indicator if modified)
|
||||||
|
const displayValue = getDisplayValue(
|
||||||
|
key,
|
||||||
|
scopeSettings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the scope message (e.g., "(Modified in Workspace)")
|
||||||
|
const scopeMessage = getScopeMessageForSetting(
|
||||||
|
key,
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the value is at default (grey it out)
|
||||||
|
const isGreyedOut = isDefaultValue(key, scopeSettings);
|
||||||
|
|
||||||
|
// Get raw value for edit mode initialization
|
||||||
|
const rawValue = getEffectiveValue(key, pendingSettings, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: definition?.label || key,
|
||||||
|
description: definition?.description,
|
||||||
|
type: type as 'boolean' | 'number' | 'string' | 'enum',
|
||||||
|
displayValue,
|
||||||
|
isGreyedOut,
|
||||||
|
scopeMessage,
|
||||||
|
rawValue: rawValue as string | number | boolean | undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
|
||||||
|
|
||||||
|
// Scope selection handler
|
||||||
|
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||||
|
setSelectedScope(scope);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commitEdit = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
const definition = getSettingDefinition(key);
|
|
||||||
const type = definition?.type;
|
|
||||||
|
|
||||||
if (editBuffer.trim() === '' && type === 'number') {
|
|
||||||
// Nothing entered for a number; cancel edit
|
|
||||||
setEditingKey(null);
|
|
||||||
setEditBuffer('');
|
|
||||||
setEditCursorPos(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: string | number;
|
|
||||||
if (type === 'number') {
|
|
||||||
const numParsed = Number(editBuffer.trim());
|
|
||||||
if (Number.isNaN(numParsed)) {
|
|
||||||
// Invalid number; cancel edit
|
|
||||||
setEditingKey(null);
|
|
||||||
setEditBuffer('');
|
|
||||||
setEditCursorPos(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parsed = numParsed;
|
|
||||||
} else {
|
|
||||||
// For strings, use the buffer as is.
|
|
||||||
parsed = editBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update pending
|
|
||||||
setPendingSettings((prev) =>
|
|
||||||
setPendingSettingValueAny(key, parsed, prev),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!requiresRestart(key)) {
|
|
||||||
const immediateSettings = new Set([key]);
|
|
||||||
const currentScopeSettings = settings.forScope(selectedScope).settings;
|
|
||||||
const immediateSettingsObject = setPendingSettingValueAny(
|
|
||||||
key,
|
|
||||||
parsed,
|
|
||||||
currentScopeSettings,
|
|
||||||
);
|
|
||||||
saveModifiedSettings(
|
|
||||||
immediateSettings,
|
|
||||||
immediateSettingsObject,
|
|
||||||
settings,
|
|
||||||
selectedScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove from modified sets if present
|
|
||||||
setModifiedSettings((prev) => {
|
|
||||||
const updated = new Set(prev);
|
|
||||||
updated.delete(key);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setRestartRequiredSettings((prev) => {
|
|
||||||
const updated = new Set(prev);
|
|
||||||
updated.delete(key);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove from global pending since it's immediately saved
|
|
||||||
setGlobalPendingChanges((prev) => {
|
|
||||||
if (!prev.has(key)) return prev;
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Mark as modified and needing restart
|
|
||||||
setModifiedSettings((prev) => {
|
|
||||||
const updated = new Set(prev).add(key);
|
|
||||||
const needsRestart = hasRestartRequiredSettings(updated);
|
|
||||||
if (needsRestart) {
|
|
||||||
setShowRestartPrompt(true);
|
|
||||||
setRestartRequiredSettings((prevRestart) =>
|
|
||||||
new Set(prevRestart).add(key),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Record pending change globally for persistence across scopes
|
|
||||||
setGlobalPendingChanges((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(key, parsed as PendingValue);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingKey(null);
|
|
||||||
setEditBuffer('');
|
|
||||||
setEditCursorPos(0);
|
|
||||||
},
|
|
||||||
[editBuffer, settings, selectedScope],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle handler for boolean/enum settings
|
// Toggle handler for boolean/enum settings
|
||||||
const toggleSetting = useCallback(
|
const handleItemToggle = useCallback(
|
||||||
(key: string) => {
|
(key: string, _item: SettingsDialogItem) => {
|
||||||
const definition = getSettingDefinition(key);
|
const definition = getSettingDefinition(key);
|
||||||
if (!TOGGLE_TYPES.has(definition?.type)) {
|
if (!TOGGLE_TYPES.has(definition?.type)) {
|
||||||
return;
|
return;
|
||||||
@@ -456,7 +379,7 @@ export function SettingsDialog({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add/update pending change globally so it persists across scopes
|
// Record pending change globally
|
||||||
setGlobalPendingChanges((prev) => {
|
setGlobalPendingChanges((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(key, newValue as PendingValue);
|
next.set(key, newValue as PendingValue);
|
||||||
@@ -474,141 +397,173 @@ export function SettingsDialog({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate items for BaseSettingsDialog
|
// Edit commit handler
|
||||||
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
const handleEditCommit = useCallback(
|
||||||
const items: SettingsDialogItem[] = useMemo(() => {
|
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
|
||||||
const mergedSettings = settings.merged;
|
|
||||||
|
|
||||||
return settingKeys.map((key) => {
|
|
||||||
const definition = getSettingDefinition(key);
|
const definition = getSettingDefinition(key);
|
||||||
const type = definition?.type ?? 'string';
|
const type = definition?.type;
|
||||||
|
|
||||||
// Compute display value
|
if (newValue.trim() === '' && type === 'number') {
|
||||||
let displayValue: string;
|
// Nothing entered for a number; cancel edit
|
||||||
if (type === 'number' || type === 'string') {
|
return;
|
||||||
const path = key.split('.');
|
|
||||||
const currentValue = getNestedValue(pendingSettings, path);
|
|
||||||
const defaultValue = getEffectiveDefaultValue(key, config);
|
|
||||||
|
|
||||||
if (currentValue !== undefined && currentValue !== null) {
|
|
||||||
displayValue = String(currentValue);
|
|
||||||
} else {
|
|
||||||
displayValue =
|
|
||||||
defaultValue !== undefined && defaultValue !== null
|
|
||||||
? String(defaultValue)
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add * if value differs from default OR if currently being modified
|
let parsed: string | number;
|
||||||
const isModified = modifiedSettings.has(key);
|
if (type === 'number') {
|
||||||
const effectiveCurrentValue =
|
const numParsed = Number(newValue.trim());
|
||||||
currentValue !== undefined && currentValue !== null
|
if (Number.isNaN(numParsed)) {
|
||||||
? currentValue
|
// Invalid number; cancel edit
|
||||||
: defaultValue;
|
return;
|
||||||
const isDifferentFromDefault = effectiveCurrentValue !== defaultValue;
|
|
||||||
|
|
||||||
if (isDifferentFromDefault || isModified) {
|
|
||||||
displayValue += '*';
|
|
||||||
}
|
}
|
||||||
|
parsed = numParsed;
|
||||||
} else {
|
} else {
|
||||||
// For booleans and enums, use existing logic
|
// For strings, use the buffer as is.
|
||||||
displayValue = getDisplayValue(
|
parsed = newValue;
|
||||||
key,
|
}
|
||||||
scopeSettings,
|
|
||||||
mergedSettings,
|
// Update pending
|
||||||
modifiedSettings,
|
setPendingSettings((prev) =>
|
||||||
pendingSettings,
|
setPendingSettingValueAny(key, parsed, prev),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (!requiresRestart(key)) {
|
||||||
|
const immediateSettings = new Set([key]);
|
||||||
|
const currentScopeSettings = settings.forScope(selectedScope).settings;
|
||||||
|
const immediateSettingsObject = setPendingSettingValueAny(
|
||||||
key,
|
key,
|
||||||
label: definition?.label || key,
|
parsed,
|
||||||
description: definition?.description,
|
currentScopeSettings,
|
||||||
type: type as 'boolean' | 'number' | 'string' | 'enum',
|
);
|
||||||
displayValue,
|
saveModifiedSettings(
|
||||||
isGreyedOut: isDefaultValue(key, scopeSettings),
|
immediateSettings,
|
||||||
scopeMessage: getScopeMessageForSetting(key, selectedScope, settings),
|
immediateSettingsObject,
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
settingKeys,
|
|
||||||
settings,
|
settings,
|
||||||
selectedScope,
|
selectedScope,
|
||||||
pendingSettings,
|
);
|
||||||
modifiedSettings,
|
|
||||||
|
// Remove from modified sets if present
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setRestartRequiredSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from global pending since it's immediately saved
|
||||||
|
setGlobalPendingChanges((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Mark as modified and needing restart
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev).add(key);
|
||||||
|
const needsRestart = hasRestartRequiredSettings(updated);
|
||||||
|
if (needsRestart) {
|
||||||
|
setShowRestartPrompt(true);
|
||||||
|
setRestartRequiredSettings((prevRestart) =>
|
||||||
|
new Set(prevRestart).add(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record pending change globally for persistence across scopes
|
||||||
|
setGlobalPendingChanges((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, parsed as PendingValue);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings, selectedScope],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear/reset handler - removes the value from settings.json so it falls back to default
|
||||||
|
const handleItemClear = useCallback(
|
||||||
|
(key: string, _item: SettingsDialogItem) => {
|
||||||
|
const defaultValue = getEffectiveDefaultValue(key, config);
|
||||||
|
|
||||||
|
// Update local pending state to show the default value
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValue(key, defaultValue, prev),
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
typeof defaultValue === 'number' ||
|
||||||
|
typeof defaultValue === 'string'
|
||||||
|
) {
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValueAny(key, defaultValue, prev),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the value from settings.json (set to undefined to remove the key)
|
||||||
|
if (!requiresRestart(key)) {
|
||||||
|
settings.setValue(selectedScope, key, undefined);
|
||||||
|
|
||||||
|
// Special handling for vim mode
|
||||||
|
if (key === 'general.vimMode') {
|
||||||
|
const booleanDefaultValue =
|
||||||
|
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||||
|
if (booleanDefaultValue !== vimEnabled) {
|
||||||
|
toggleVimEnabled().catch((error) => {
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'error',
|
||||||
|
'Failed to toggle vim mode:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'general.previewFeatures') {
|
||||||
|
const booleanDefaultValue =
|
||||||
|
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||||
|
config?.setPreviewFeatures(booleanDefaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from modified sets
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setRestartRequiredSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setGlobalPendingChanges((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update restart prompt
|
||||||
|
setShowRestartPrompt((_prev) => {
|
||||||
|
const remaining = getRestartRequiredFromModified(modifiedSettings);
|
||||||
|
return remaining.filter((k) => k !== key).length > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
config,
|
config,
|
||||||
]);
|
settings,
|
||||||
|
selectedScope,
|
||||||
// Height constraint calculations
|
vimEnabled,
|
||||||
const DIALOG_PADDING = 5;
|
toggleVimEnabled,
|
||||||
const SETTINGS_TITLE_HEIGHT = 2;
|
modifiedSettings,
|
||||||
const SCROLL_ARROWS_HEIGHT = 2;
|
],
|
||||||
const SPACING_HEIGHT = 1;
|
|
||||||
const SCOPE_SELECTION_HEIGHT = 4;
|
|
||||||
const BOTTOM_HELP_TEXT_HEIGHT = 1;
|
|
||||||
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
|
||||||
|
|
||||||
let currentAvailableTerminalHeight =
|
|
||||||
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
currentAvailableTerminalHeight -= 2; // Top and bottom borders
|
|
||||||
|
|
||||||
let totalFixedHeight =
|
|
||||||
DIALOG_PADDING +
|
|
||||||
SETTINGS_TITLE_HEIGHT +
|
|
||||||
SCROLL_ARROWS_HEIGHT +
|
|
||||||
SPACING_HEIGHT +
|
|
||||||
BOTTOM_HELP_TEXT_HEIGHT +
|
|
||||||
RESTART_PROMPT_HEIGHT;
|
|
||||||
|
|
||||||
let availableHeightForSettings = Math.max(
|
|
||||||
1,
|
|
||||||
currentAvailableTerminalHeight - totalFixedHeight,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
|
|
||||||
|
|
||||||
let showScopeSelection = true;
|
|
||||||
|
|
||||||
if (availableTerminalHeight && availableTerminalHeight < 25) {
|
|
||||||
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
|
|
||||||
const availableWithScope = Math.max(
|
|
||||||
1,
|
|
||||||
currentAvailableTerminalHeight - totalWithScope,
|
|
||||||
);
|
|
||||||
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 3));
|
|
||||||
|
|
||||||
if (maxVisibleItems > maxItemsWithScope + 1) {
|
|
||||||
showScopeSelection = false;
|
|
||||||
} else {
|
|
||||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
|
||||||
availableHeightForSettings = Math.max(
|
|
||||||
1,
|
|
||||||
currentAvailableTerminalHeight - totalFixedHeight,
|
|
||||||
);
|
|
||||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
|
||||||
availableHeightForSettings = Math.max(
|
|
||||||
1,
|
|
||||||
currentAvailableTerminalHeight - totalFixedHeight,
|
|
||||||
);
|
|
||||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveMaxItemsToShow = availableTerminalHeight
|
|
||||||
? Math.min(maxVisibleItems, items.length)
|
|
||||||
: MAX_ITEMS_TO_SHOW;
|
|
||||||
|
|
||||||
// Ensure focus stays on settings when scope selection is hidden
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!showScopeSelection && focusSection === 'scope') {
|
|
||||||
setFocusSection('settings');
|
|
||||||
}
|
|
||||||
}, [showScopeSelection, focusSection]);
|
|
||||||
|
|
||||||
const saveRestartRequiredSettings = useCallback(() => {
|
const saveRestartRequiredSettings = useCallback(() => {
|
||||||
const restartRequiredSettings =
|
const restartRequiredSettings =
|
||||||
getRestartRequiredFromModified(modifiedSettings);
|
getRestartRequiredFromModified(modifiedSettings);
|
||||||
@@ -634,287 +589,102 @@ export function SettingsDialog({
|
|||||||
}
|
}
|
||||||
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
|
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
|
||||||
|
|
||||||
// Keyboard handling
|
// Close handler
|
||||||
useKeypress(
|
const handleClose = useCallback(() => {
|
||||||
(key) => {
|
|
||||||
const { name } = key;
|
|
||||||
|
|
||||||
if (name === 'tab' && showScopeSelection) {
|
|
||||||
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
|
||||||
}
|
|
||||||
if (focusSection === 'settings') {
|
|
||||||
// If editing, capture input and control keys
|
|
||||||
if (editingKey) {
|
|
||||||
const definition = getSettingDefinition(editingKey);
|
|
||||||
const type = definition?.type;
|
|
||||||
|
|
||||||
if (key.name === 'paste' && key.sequence) {
|
|
||||||
let pasted = key.sequence;
|
|
||||||
if (type === 'number') {
|
|
||||||
pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
|
|
||||||
}
|
|
||||||
if (pasted) {
|
|
||||||
setEditBuffer((b) => {
|
|
||||||
const before = cpSlice(b, 0, editCursorPos);
|
|
||||||
const after = cpSlice(b, editCursorPos);
|
|
||||||
return before + pasted + after;
|
|
||||||
});
|
|
||||||
setEditCursorPos((pos) => pos + cpLen(pasted));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name === 'backspace' || name === 'delete') {
|
|
||||||
if (name === 'backspace' && editCursorPos > 0) {
|
|
||||||
setEditBuffer((b) => {
|
|
||||||
const before = cpSlice(b, 0, editCursorPos - 1);
|
|
||||||
const after = cpSlice(b, editCursorPos);
|
|
||||||
return before + after;
|
|
||||||
});
|
|
||||||
setEditCursorPos((pos) => pos - 1);
|
|
||||||
} else if (name === 'delete' && editCursorPos < cpLen(editBuffer)) {
|
|
||||||
setEditBuffer((b) => {
|
|
||||||
const before = cpSlice(b, 0, editCursorPos);
|
|
||||||
const after = cpSlice(b, editCursorPos + 1);
|
|
||||||
return before + after;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
|
||||||
commitEdit(editingKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.RETURN](key)) {
|
|
||||||
commitEdit(editingKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ch = key.sequence;
|
|
||||||
let isValidChar = false;
|
|
||||||
if (type === 'number') {
|
|
||||||
isValidChar = /[0-9\-+.]/.test(ch);
|
|
||||||
} else {
|
|
||||||
ch = stripUnsafeCharacters(ch);
|
|
||||||
isValidChar = ch.length === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidChar) {
|
|
||||||
setEditBuffer((currentBuffer) => {
|
|
||||||
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
|
|
||||||
const afterCursor = cpSlice(currentBuffer, editCursorPos);
|
|
||||||
return beforeCursor + ch + afterCursor;
|
|
||||||
});
|
|
||||||
setEditCursorPos((pos) => pos + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow key navigation
|
|
||||||
if (name === 'left') {
|
|
||||||
setEditCursorPos((pos) => Math.max(0, pos - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (name === 'right') {
|
|
||||||
setEditCursorPos((pos) => Math.min(cpLen(editBuffer), pos + 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Home and End keys
|
|
||||||
if (keyMatchers[Command.HOME](key)) {
|
|
||||||
setEditCursorPos(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.END](key)) {
|
|
||||||
setEditCursorPos(cpLen(editBuffer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Block other keys while editing
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
|
|
||||||
if (editingKey) {
|
|
||||||
commitEdit(editingKey);
|
|
||||||
}
|
|
||||||
const newIndex =
|
|
||||||
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
|
|
||||||
setActiveSettingIndex(newIndex);
|
|
||||||
if (newIndex === items.length - 1) {
|
|
||||||
setScrollOffset(
|
|
||||||
Math.max(0, items.length - effectiveMaxItemsToShow),
|
|
||||||
);
|
|
||||||
} else if (newIndex < scrollOffset) {
|
|
||||||
setScrollOffset(newIndex);
|
|
||||||
}
|
|
||||||
} else if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
|
||||||
if (editingKey) {
|
|
||||||
commitEdit(editingKey);
|
|
||||||
}
|
|
||||||
const newIndex =
|
|
||||||
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
|
|
||||||
setActiveSettingIndex(newIndex);
|
|
||||||
if (newIndex === 0) {
|
|
||||||
setScrollOffset(0);
|
|
||||||
} else if (newIndex >= scrollOffset + effectiveMaxItemsToShow) {
|
|
||||||
setScrollOffset(newIndex - effectiveMaxItemsToShow + 1);
|
|
||||||
}
|
|
||||||
} else if (keyMatchers[Command.RETURN](key)) {
|
|
||||||
const currentItem = items[activeSettingIndex];
|
|
||||||
if (
|
|
||||||
currentItem?.type === 'number' ||
|
|
||||||
currentItem?.type === 'string'
|
|
||||||
) {
|
|
||||||
startEditing(currentItem.key);
|
|
||||||
} else {
|
|
||||||
toggleSetting(currentItem.key);
|
|
||||||
}
|
|
||||||
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
|
|
||||||
const currentItem = items[activeSettingIndex];
|
|
||||||
if (currentItem?.type === 'number') {
|
|
||||||
startEditing(currentItem.key, key.sequence);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
keyMatchers[Command.CLEAR_INPUT](key) ||
|
|
||||||
keyMatchers[Command.CLEAR_SCREEN](key)
|
|
||||||
) {
|
|
||||||
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
|
|
||||||
const currentSetting = items[activeSettingIndex];
|
|
||||||
if (currentSetting) {
|
|
||||||
const defaultValue = getEffectiveDefaultValue(
|
|
||||||
currentSetting.key,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
const defType = currentSetting.type;
|
|
||||||
if (defType === 'boolean') {
|
|
||||||
const booleanDefaultValue =
|
|
||||||
typeof defaultValue === 'boolean' ? defaultValue : false;
|
|
||||||
setPendingSettings((prev) =>
|
|
||||||
setPendingSettingValue(
|
|
||||||
currentSetting.key,
|
|
||||||
booleanDefaultValue,
|
|
||||||
prev,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (defType === 'number' || defType === 'string') {
|
|
||||||
if (
|
|
||||||
typeof defaultValue === 'number' ||
|
|
||||||
typeof defaultValue === 'string'
|
|
||||||
) {
|
|
||||||
setPendingSettings((prev) =>
|
|
||||||
setPendingSettingValueAny(
|
|
||||||
currentSetting.key,
|
|
||||||
defaultValue,
|
|
||||||
prev,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from modified settings since it's now at default
|
|
||||||
setModifiedSettings((prev) => {
|
|
||||||
const updated = new Set(prev);
|
|
||||||
updated.delete(currentSetting.key);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove from restart-required settings if it was there
|
|
||||||
setRestartRequiredSettings((prev) => {
|
|
||||||
const updated = new Set(prev);
|
|
||||||
updated.delete(currentSetting.key);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If this setting doesn't require restart, save it immediately
|
|
||||||
if (!requiresRestart(currentSetting.key)) {
|
|
||||||
const immediateSettings = new Set([currentSetting.key]);
|
|
||||||
const toSaveValue =
|
|
||||||
currentSetting.type === 'boolean'
|
|
||||||
? typeof defaultValue === 'boolean'
|
|
||||||
? defaultValue
|
|
||||||
: false
|
|
||||||
: typeof defaultValue === 'number' ||
|
|
||||||
typeof defaultValue === 'string'
|
|
||||||
? defaultValue
|
|
||||||
: undefined;
|
|
||||||
const currentScopeSettings =
|
|
||||||
settings.forScope(selectedScope).settings;
|
|
||||||
const immediateSettingsObject =
|
|
||||||
toSaveValue !== undefined
|
|
||||||
? setPendingSettingValueAny(
|
|
||||||
currentSetting.key,
|
|
||||||
toSaveValue,
|
|
||||||
currentScopeSettings,
|
|
||||||
)
|
|
||||||
: currentScopeSettings;
|
|
||||||
|
|
||||||
saveModifiedSettings(
|
|
||||||
immediateSettings,
|
|
||||||
immediateSettingsObject,
|
|
||||||
settings,
|
|
||||||
selectedScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove from global pending changes if present
|
|
||||||
setGlobalPendingChanges((prev) => {
|
|
||||||
if (!prev.has(currentSetting.key)) return prev;
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(currentSetting.key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Track default reset as a pending change if restart required
|
|
||||||
if (
|
|
||||||
(currentSetting.type === 'boolean' &&
|
|
||||||
typeof defaultValue === 'boolean') ||
|
|
||||||
(currentSetting.type === 'number' &&
|
|
||||||
typeof defaultValue === 'number') ||
|
|
||||||
(currentSetting.type === 'string' &&
|
|
||||||
typeof defaultValue === 'string')
|
|
||||||
) {
|
|
||||||
setGlobalPendingChanges((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(currentSetting.key, defaultValue as PendingValue);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showRestartPrompt && name === 'r') {
|
|
||||||
// Only save settings that require restart (non-restart settings were already saved immediately)
|
|
||||||
saveRestartRequiredSettings();
|
|
||||||
|
|
||||||
setShowRestartPrompt(false);
|
|
||||||
setRestartRequiredSettings(new Set()); // Clear restart-required settings
|
|
||||||
if (onRestartRequest) onRestartRequest();
|
|
||||||
}
|
|
||||||
if (keyMatchers[Command.ESCAPE](key)) {
|
|
||||||
if (editingKey) {
|
|
||||||
commitEdit(editingKey);
|
|
||||||
} else {
|
|
||||||
// Save any restart-required settings before closing
|
// Save any restart-required settings before closing
|
||||||
saveRestartRequiredSettings();
|
saveRestartRequiredSettings();
|
||||||
onSelect(undefined, selectedScope);
|
onSelect(undefined, selectedScope as SettingScope);
|
||||||
}
|
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
|
||||||
|
|
||||||
|
// Custom key handler for restart key
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(key: Key, _currentItem: SettingsDialogItem | undefined): boolean => {
|
||||||
|
// 'r' key for restart
|
||||||
|
if (showRestartPrompt && key.sequence === 'r') {
|
||||||
|
setShowRestartPrompt(false);
|
||||||
|
setModifiedSettings(new Set());
|
||||||
|
setRestartRequiredSettings(new Set());
|
||||||
|
if (onRestartRequest) onRestartRequest();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
[showRestartPrompt, onRestartRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mainAreaWidth } = useUIState();
|
// Calculate effective max items and scope visibility based on terminal height
|
||||||
const viewportWidth = mainAreaWidth - 8;
|
const { effectiveMaxItemsToShow, showScopeSelection } = useMemo(() => {
|
||||||
|
// Only show scope selector if we have a workspace
|
||||||
|
const hasWorkspace = settings.workspace.path !== undefined;
|
||||||
|
|
||||||
const searchBuffer = useTextBuffer({
|
if (!availableTerminalHeight) {
|
||||||
initialText: '',
|
return {
|
||||||
initialCursorOffset: 0,
|
effectiveMaxItemsToShow: Math.min(MAX_ITEMS_TO_SHOW, items.length),
|
||||||
viewport: {
|
showScopeSelection: hasWorkspace,
|
||||||
width: viewportWidth,
|
};
|
||||||
height: 1,
|
}
|
||||||
},
|
|
||||||
isValidPath: () => false,
|
|
||||||
singleLine: true,
|
|
||||||
onChange: (text) => setSearchQuery(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restart prompt as footer content
|
// Layout constants
|
||||||
|
const DIALOG_PADDING = 2; // Top and bottom borders
|
||||||
|
const SETTINGS_TITLE_HEIGHT = 1;
|
||||||
|
const SEARCH_BOX_HEIGHT = 3;
|
||||||
|
const SCROLL_ARROWS_HEIGHT = 2;
|
||||||
|
const SPACING_HEIGHT = 2;
|
||||||
|
const SCOPE_SELECTION_HEIGHT = 4;
|
||||||
|
const BOTTOM_HELP_TEXT_HEIGHT = 1;
|
||||||
|
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
||||||
|
const ITEM_HEIGHT = 3; // Label + description + spacing
|
||||||
|
|
||||||
|
const currentAvailableHeight = availableTerminalHeight - DIALOG_PADDING;
|
||||||
|
|
||||||
|
const baseFixedHeight =
|
||||||
|
SETTINGS_TITLE_HEIGHT +
|
||||||
|
SEARCH_BOX_HEIGHT +
|
||||||
|
SCROLL_ARROWS_HEIGHT +
|
||||||
|
SPACING_HEIGHT +
|
||||||
|
BOTTOM_HELP_TEXT_HEIGHT +
|
||||||
|
RESTART_PROMPT_HEIGHT;
|
||||||
|
|
||||||
|
// Calculate max items with scope selector
|
||||||
|
const heightWithScope = baseFixedHeight + SCOPE_SELECTION_HEIGHT;
|
||||||
|
const availableForItemsWithScope = currentAvailableHeight - heightWithScope;
|
||||||
|
const maxItemsWithScope = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate max items without scope selector
|
||||||
|
const availableForItemsWithoutScope =
|
||||||
|
currentAvailableHeight - baseFixedHeight;
|
||||||
|
const maxItemsWithoutScope = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT),
|
||||||
|
);
|
||||||
|
|
||||||
|
// In small terminals, hide scope selector if it would allow more items to show
|
||||||
|
let shouldShowScope = hasWorkspace;
|
||||||
|
let maxItems = maxItemsWithScope;
|
||||||
|
|
||||||
|
if (hasWorkspace && availableTerminalHeight < 25) {
|
||||||
|
// Hide scope selector if it gains us more than 1 extra item
|
||||||
|
if (maxItemsWithoutScope > maxItemsWithScope + 1) {
|
||||||
|
shouldShowScope = false;
|
||||||
|
maxItems = maxItemsWithoutScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveMaxItemsToShow: Math.min(maxItems, items.length),
|
||||||
|
showScopeSelection: shouldShowScope,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
availableTerminalHeight,
|
||||||
|
items.length,
|
||||||
|
settings.workspace.path,
|
||||||
|
showRestartPrompt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Footer content for restart prompt
|
||||||
const footerContent = showRestartPrompt ? (
|
const footerContent = showRestartPrompt ? (
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
To see changes, Gemini CLI must be restarted. Press r to exit and apply
|
To see changes, Gemini CLI must be restarted. Press r to exit and apply
|
||||||
@@ -928,19 +698,16 @@ export function SettingsDialog({
|
|||||||
searchEnabled={true}
|
searchEnabled={true}
|
||||||
searchBuffer={searchBuffer}
|
searchBuffer={searchBuffer}
|
||||||
items={items}
|
items={items}
|
||||||
activeIndex={activeSettingIndex}
|
|
||||||
editingKey={editingKey}
|
|
||||||
editBuffer={editBuffer}
|
|
||||||
editCursorPos={editCursorPos}
|
|
||||||
cursorVisible={cursorVisible}
|
|
||||||
showScopeSelector={showScopeSelection}
|
showScopeSelector={showScopeSelection}
|
||||||
selectedScope={selectedScope}
|
selectedScope={selectedScope}
|
||||||
onScopeHighlight={handleScopeHighlight}
|
onScopeChange={handleScopeChange}
|
||||||
onScopeSelect={handleScopeSelect}
|
|
||||||
focusSection={focusSection}
|
|
||||||
scrollOffset={scrollOffset}
|
|
||||||
maxItemsToShow={effectiveMaxItemsToShow}
|
maxItemsToShow={effectiveMaxItemsToShow}
|
||||||
maxLabelWidth={maxLabelOrDescriptionWidth}
|
maxLabelWidth={maxLabelOrDescriptionWidth}
|
||||||
|
onItemToggle={handleItemToggle}
|
||||||
|
onEditCommit={handleEditCommit}
|
||||||
|
onItemClear={handleItemClear}
|
||||||
|
onClose={handleClose}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -87,7 +87,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -133,7 +133,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -225,7 +225,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -271,7 +271,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
|||||||
│ 2. Workspace Settings │
|
│ 2. Workspace Settings │
|
||||||
│ 3. System Settings │
|
│ 3. System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -317,7 +317,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -363,7 +363,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
@@ -409,7 +409,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
|||||||
│ Workspace Settings │
|
│ Workspace Settings │
|
||||||
│ System Settings │
|
│ System Settings │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
@@ -13,7 +13,13 @@ import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
|||||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||||
import { TextInput } from './TextInput.js';
|
import { TextInput } from './TextInput.js';
|
||||||
import type { TextBuffer } from './text-buffer.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.
|
* Represents a single item in the settings dialog.
|
||||||
@@ -33,6 +39,8 @@ export interface SettingsDialogItem {
|
|||||||
isGreyedOut?: boolean;
|
isGreyedOut?: boolean;
|
||||||
/** Scope message e.g., "(Modified in Workspace)" */
|
/** Scope message e.g., "(Modified in Workspace)" */
|
||||||
scopeMessage?: string;
|
scopeMessage?: string;
|
||||||
|
/** Raw value for edit mode initialization */
|
||||||
|
rawValue?: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,51 +62,48 @@ export interface BaseSettingsDialogProps {
|
|||||||
// Items - parent provides the list
|
// Items - parent provides the list
|
||||||
/** List of items to display */
|
/** List of items to display */
|
||||||
items: SettingsDialogItem[];
|
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
|
// Scope selector
|
||||||
/** Whether to show the scope selector. Default: true */
|
/** Whether to show the scope selector. Default: true */
|
||||||
showScopeSelector?: boolean;
|
showScopeSelector?: boolean;
|
||||||
/** Currently selected scope */
|
/** Currently selected scope */
|
||||||
selectedScope: LoadableSettingScope;
|
selectedScope: LoadableSettingScope;
|
||||||
/** Callback when scope is highlighted (hovered/navigated to) */
|
/** Callback when scope changes */
|
||||||
onScopeHighlight?: (scope: LoadableSettingScope) => void;
|
onScopeChange?: (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;
|
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
|
/** Maximum number of items to show at once */
|
||||||
|
maxItemsToShow: number;
|
||||||
/** Maximum label width for alignment */
|
/** Maximum label width for alignment */
|
||||||
maxLabelWidth?: number;
|
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 extra content below help text (for restart prompt, etc.)
|
||||||
/** Optional footer content (e.g., restart prompt) */
|
/** Optional footer content (e.g., restart prompt) */
|
||||||
footerContent?: React.ReactNode;
|
footerContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A base settings dialog component that handles rendering and layout.
|
* A base settings dialog component that handles rendering, layout, and keyboard navigation.
|
||||||
* Parent components handle business logic (saving, filtering, etc.).
|
* Parent components handle business logic (saving, filtering, etc.) via callbacks.
|
||||||
*/
|
*/
|
||||||
export function BaseSettingsDialog({
|
export function BaseSettingsDialog({
|
||||||
title,
|
title,
|
||||||
@@ -106,21 +111,53 @@ export function BaseSettingsDialog({
|
|||||||
searchPlaceholder = 'Search to filter',
|
searchPlaceholder = 'Search to filter',
|
||||||
searchBuffer,
|
searchBuffer,
|
||||||
items,
|
items,
|
||||||
activeIndex,
|
|
||||||
editingKey,
|
|
||||||
editBuffer,
|
|
||||||
editCursorPos,
|
|
||||||
cursorVisible,
|
|
||||||
showScopeSelector = true,
|
showScopeSelector = true,
|
||||||
selectedScope,
|
selectedScope,
|
||||||
onScopeHighlight,
|
onScopeChange,
|
||||||
onScopeSelect,
|
|
||||||
focusSection,
|
|
||||||
scrollOffset,
|
|
||||||
maxItemsToShow,
|
maxItemsToShow,
|
||||||
maxLabelWidth,
|
maxLabelWidth,
|
||||||
|
onItemToggle,
|
||||||
|
onEditCommit,
|
||||||
|
onItemClear,
|
||||||
|
onClose,
|
||||||
|
onKeyPress,
|
||||||
footerContent,
|
footerContent,
|
||||||
}: BaseSettingsDialogProps): React.JSX.Element {
|
}: 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
|
// Scope selector items
|
||||||
const scopeItems = getScopeItems().map((item) => ({
|
const scopeItems = getScopeItems().map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -134,6 +171,222 @@ export function BaseSettingsDialog({
|
|||||||
const showScrollUp = items.length > maxItemsToShow;
|
const showScrollUp = items.length > maxItemsToShow;
|
||||||
const showScrollDown = 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
@@ -305,8 +558,8 @@ export function BaseSettingsDialog({
|
|||||||
initialIndex={scopeItems.findIndex(
|
initialIndex={scopeItems.findIndex(
|
||||||
(item) => item.value === selectedScope,
|
(item) => item.value === selectedScope,
|
||||||
)}
|
)}
|
||||||
onSelect={onScopeSelect ?? (() => {})}
|
onSelect={handleScopeSelect}
|
||||||
onHighlight={onScopeHighlight}
|
onHighlight={handleScopeHighlight}
|
||||||
isFocused={focusSection === 'scope'}
|
isFocused={focusSection === 'scope'}
|
||||||
showNumbers={focusSection === 'scope'}
|
showNumbers={focusSection === 'scope'}
|
||||||
/>
|
/>
|
||||||
@@ -318,7 +571,7 @@ export function BaseSettingsDialog({
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginX={1}>
|
<Box marginX={1}>
|
||||||
<Text color={theme.text.secondary}>
|
<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)
|
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user