refactor(cli): fully remove React anti patterns, improve type safety and fix UX oversights in SettingsDialog.tsx (#18963)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Pyush Sinha
2026-03-02 13:30:58 -08:00
committed by GitHub
parent 18d0375a7f
commit 8133d63ac6
14 changed files with 589 additions and 1390 deletions
+120 -366
View File
@@ -5,40 +5,35 @@
*/
import type React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
import type { Key } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import type {
LoadableSettingScope,
LoadedSettings,
Settings,
} from '../../config/settings.js';
import type { LoadableSettingScope, Settings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import {
getDialogSettingKeys,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getEffectiveDefaultValue,
setPendingSettingValueAny,
getDialogRestartRequiredSettings,
getEffectiveValue,
isInSettingsScope,
getEditValue,
parseEditedValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import {
useSettingsStore,
type SettingsState,
} from '../contexts/SettingsContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import {
type SettingsType,
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import {
@@ -55,31 +50,56 @@ interface FzfResult {
}
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
availableTerminalHeight?: number;
config?: Config;
}
const MAX_ITEMS_TO_SHOW = 8;
// Create a snapshot of the initial per-scope state of Restart Required Settings
// This creates a nested map of the form
// restartRequiredSetting -> Map { scopeName -> value }
function getActiveRestartRequiredSettings(
settings: SettingsState,
): Map<string, Map<string, string>> {
const snapshot = new Map<string, Map<string, string>>();
const scopes: Array<[string, Settings]> = [
['User', settings.user.settings],
['Workspace', settings.workspace.settings],
['System', settings.system.settings],
];
for (const key of getDialogRestartRequiredSettings()) {
const scopeMap = new Map<string, string>();
for (const [scopeName, scopeSettings] of scopes) {
// Raw per-scope value (undefined if not in file)
const value = isInSettingsScope(key, scopeSettings)
? getEffectiveValue(key, scopeSettings)
: undefined;
scopeMap.set(scopeName, JSON.stringify(value));
}
snapshot.set(key, scopeMap);
}
return snapshot;
}
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
availableTerminalHeight,
config,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Reactive settings from store (re-renders on any settings change)
const { settings, setSetting } = useSettingsStore();
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Snapshot restart-required values at mount time for diff tracking
const [activeRestartRequiredSettings] = useState(() =>
getActiveRestartRequiredSettings(settings),
);
// Search state
const [searchQuery, setSearchQuery] = useState('');
@@ -136,52 +156,34 @@ export function SettingsDialog({
};
}, [searchQuery, fzfInstance, searchMap]);
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Track whether a restart is required to apply the changes in the Settings json file
// This does not care for inheritance
// It checks whether a proposed change from this UI to a settings.json file requires a restart to take effect in the app
const pendingRestartRequiredSettings = useMemo(() => {
const changed = new Set<string>();
const scopes: Array<[string, Settings]> = [
['User', settings.user.settings],
['Workspace', settings.workspace.settings],
['System', settings.system.settings],
];
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Preserve pending changes across scope switches
type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
// Track restart-required settings across scope changes
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
// Base settings for selected scope
let updated = structuredClone(settings.forScope(selectedScope).settings);
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
const newModified = new Set<string>();
const newRestartRequired = new Set<string>();
for (const [key, value] of globalPendingChanges.entries()) {
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated);
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
updated = setPendingSettingValueAny(key, value, updated);
// Iterate through the nested map snapshot in activeRestartRequiredSettings, diff with current settings
for (const [key, initialScopeMap] of activeRestartRequiredSettings) {
for (const [scopeName, scopeSettings] of scopes) {
const currentValue = isInSettingsScope(key, scopeSettings)
? getEffectiveValue(key, scopeSettings)
: undefined;
const initialJson = initialScopeMap.get(scopeName);
if (JSON.stringify(currentValue) !== initialJson) {
changed.add(key);
break; // one scope changed is enough
}
}
newModified.add(key);
if (requiresRestart(key)) newRestartRequired.add(key);
}
setPendingSettings(updated);
setModifiedSettings(newModified);
setRestartRequiredSettings(newRestartRequired);
setShowRestartPrompt(newRestartRequired.size > 0);
}, [selectedScope, settings, globalPendingChanges]);
return changed;
}, [settings, activeRestartRequiredSettings]);
const showRestartPrompt = pendingRestartRequiredSettings.size > 0;
// Calculate max width for the left column (Label/Description) to keep values aligned or close
const maxLabelOrDescriptionWidth = useMemo(() => {
@@ -222,16 +224,10 @@ export function SettingsDialog({
return settingKeys.map((key) => {
const definition = getSettingDefinition(key);
const type = definition?.type ?? 'string';
const type: SettingsType = definition?.type ?? 'string';
// Get the display value (with * indicator if modified)
const displayValue = getDisplayValue(
key,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const displayValue = getDisplayValue(key, scopeSettings, mergedSettings);
// Get the scope message (e.g., "(Modified in Workspace)")
const scopeMessage = getScopeMessageForSetting(
@@ -240,28 +236,28 @@ export function SettingsDialog({
settings,
);
// Check if the value is at default (grey it out)
const isGreyedOut = isDefaultValue(key, scopeSettings);
// Grey out values that defer to defaults
const isGreyedOut = !isInSettingsScope(key, scopeSettings);
// Get raw value for edit mode initialization
const rawValue = getEffectiveValue(key, pendingSettings, {});
// Some settings can be edited by an inline editor
const rawValue = getEffectiveValue(key, scopeSettings);
// The inline editor needs a string but non primitive settings like Arrays and Objects exist
const editValue = getEditValue(type, rawValue);
return {
key,
label: definition?.label || key,
description: definition?.description,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
type: type as 'boolean' | 'number' | 'string' | 'enum',
type,
displayValue,
isGreyedOut,
scopeMessage,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
rawValue,
editValue,
};
});
}, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]);
}, [settingKeys, selectedScope, settings]);
// Scope selection handler
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
@@ -273,17 +269,21 @@ export function SettingsDialog({
if (!TOGGLE_TYPES.has(definition?.type)) {
return;
}
const currentValue = getEffectiveValue(key, pendingSettings, {});
const scopeSettings = settings.forScope(selectedScope).settings;
const currentValue = getEffectiveValue(key, scopeSettings);
let newValue: SettingsValue;
if (definition?.type === 'boolean') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
newValue = !(currentValue as boolean);
setPendingSettings((prev) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPendingSettingValue(key, newValue as boolean, prev),
);
if (typeof currentValue !== 'boolean') {
return;
}
newValue = !currentValue;
} else if (definition?.type === 'enum' && definition.options) {
const options = definition.options;
if (options.length === 0) {
return;
}
const currentIndex = options?.findIndex(
(opt) => opt.value === currentValue,
);
@@ -292,303 +292,58 @@ export function SettingsDialog({
} else {
newValue = options[0].value; // loop back to start.
}
setPendingSettings((prev) =>
setPendingSettingValueAny(key, newValue, prev),
);
}
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const currentScopeSettings = settings.forScope(selectedScope).settings;
const immediateSettingsObject = setPendingSettingValueAny(
key,
newValue,
currentScopeSettings,
);
debugLogger.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'general.vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
coreEvents.emitFeedback(
'error',
'Failed to toggle vim mode:',
error,
);
});
}
// Remove from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from global pending changes if present
setGlobalPendingChanges((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
} else {
// For restart-required settings, track as modified
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
debugLogger.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
// Record pending change globally
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
next.set(key, newValue as PendingValue);
return next;
});
}
},
[pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled],
);
// Edit commit handler
const handleEditCommit = useCallback(
(key: string, newValue: string, _item: SettingsDialogItem) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
if (newValue.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
return;
}
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(newValue.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = newValue;
}
// Update pending
setPendingSettings((prev) =>
setPendingSettingValueAny(key, parsed, prev),
debugLogger.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
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;
});
}
setSetting(selectedScope, key, newValue);
},
[settings, selectedScope],
[settings, selectedScope, setSetting],
);
// For inline editor
const handleEditCommit = useCallback(
(key: string, newValue: string, _item: SettingsDialogItem) => {
const definition = getSettingDefinition(key);
const type: SettingsType = definition?.type ?? 'string';
const parsed = parseEditedValue(type, newValue);
if (parsed === null) {
return;
}
setSetting(selectedScope, key, parsed);
},
[selectedScope, setSetting],
);
// 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,
);
});
}
}
}
// 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;
});
setSetting(selectedScope, key, undefined);
},
[
config,
settings,
selectedScope,
vimEnabled,
toggleVimEnabled,
modifiedSettings,
],
[selectedScope, setSetting],
);
const saveRestartRequiredSettings = useCallback(() => {
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
// Remove saved keys from global pending changes
setGlobalPendingChanges((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const key of restartRequiredSet) {
next.delete(key);
}
return next;
});
}
}, [modifiedSettings, pendingSettings, settings, selectedScope]);
// Close handler
const handleClose = useCallback(() => {
// Save any restart-required settings before closing
saveRestartRequiredSettings();
onSelect(undefined, selectedScope as SettingScope);
}, [saveRestartRequiredSettings, onSelect, selectedScope]);
}, [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') {
saveRestartRequiredSettings();
setShowRestartPrompt(false);
setModifiedSettings(new Set());
setRestartRequiredSettings(new Set());
if (onRestartRequest) onRestartRequest();
return true;
}
return false;
},
[showRestartPrompt, onRestartRequest, saveRestartRequiredSettings],
[showRestartPrompt, onRestartRequest],
);
// Calculate effective max items and scope visibility based on terminal height
@@ -673,11 +428,10 @@ export function SettingsDialog({
showRestartPrompt,
]);
// Footer content for restart prompt
const footerContent = showRestartPrompt ? (
<Text color={theme.status.warning}>
To see changes, Gemini CLI must be restarted. Press r to exit and apply
changes now.
Changes that require a restart have been modified. Press r to exit and
apply changes now.
</Text>
) : null;