mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-24 13:01:29 -07:00
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React 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, Settings } from '../../config/settings.js';
|
|
import { SettingScope } from '../../config/settings.js';
|
|
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
|
import {
|
|
getDialogSettingKeys,
|
|
getDisplayValue,
|
|
getSettingDefinition,
|
|
getDialogRestartRequiredSettings,
|
|
getEffectiveValue,
|
|
isInSettingsScope,
|
|
getEditValue,
|
|
parseEditedValue,
|
|
} from '../../utils/settingsUtils.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 { debugLogger } from '@google/gemini-cli-core';
|
|
|
|
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
|
|
import {
|
|
BaseSettingsDialog,
|
|
type SettingsDialogItem,
|
|
} from './shared/BaseSettingsDialog.js';
|
|
|
|
interface FzfResult {
|
|
item: string;
|
|
start: number;
|
|
end: number;
|
|
score: number;
|
|
positions?: number[];
|
|
}
|
|
|
|
interface SettingsDialogProps {
|
|
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
|
onRestartRequest?: () => void;
|
|
availableTerminalHeight?: number;
|
|
}
|
|
|
|
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({
|
|
onSelect,
|
|
onRestartRequest,
|
|
availableTerminalHeight,
|
|
}: SettingsDialogProps): React.JSX.Element {
|
|
// Reactive settings from store (re-renders on any settings change)
|
|
const { settings, setSetting } = useSettingsStore();
|
|
|
|
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
|
SettingScope.User,
|
|
);
|
|
|
|
// Snapshot restart-required values at mount time for diff tracking
|
|
const [activeRestartRequiredSettings] = useState(() =>
|
|
getActiveRestartRequiredSettings(settings),
|
|
);
|
|
|
|
// Search state
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filteredKeys, setFilteredKeys] = useState<string[]>(() =>
|
|
getDialogSettingKeys(),
|
|
);
|
|
const { fzfInstance, searchMap } = useMemo(() => {
|
|
const keys = getDialogSettingKeys();
|
|
const map = new Map<string, string>();
|
|
const searchItems: string[] = [];
|
|
|
|
keys.forEach((key) => {
|
|
const def = getSettingDefinition(key);
|
|
if (def?.label) {
|
|
searchItems.push(def.label);
|
|
map.set(def.label.toLowerCase(), key);
|
|
}
|
|
});
|
|
|
|
const fzf = new AsyncFzf(searchItems, {
|
|
fuzzy: 'v2',
|
|
casing: 'case-insensitive',
|
|
});
|
|
return { fzfInstance: fzf, searchMap: map };
|
|
}, []);
|
|
|
|
// Perform search
|
|
useEffect(() => {
|
|
let active = true;
|
|
if (!searchQuery.trim() || !fzfInstance) {
|
|
setFilteredKeys(getDialogSettingKeys());
|
|
return;
|
|
}
|
|
|
|
const doSearch = async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const results = await fzfInstance.find(searchQuery);
|
|
|
|
if (!active) return;
|
|
|
|
const matchedKeys = new Set<string>();
|
|
results.forEach((res: FzfResult) => {
|
|
const key = searchMap.get(res.item.toLowerCase());
|
|
if (key) matchedKeys.add(key);
|
|
});
|
|
setFilteredKeys(Array.from(matchedKeys));
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
doSearch();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [searchQuery, fzfInstance, searchMap]);
|
|
|
|
// 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],
|
|
];
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
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(() => {
|
|
const allKeys = getDialogSettingKeys();
|
|
let max = 0;
|
|
for (const key of allKeys) {
|
|
const def = getSettingDefinition(key);
|
|
if (!def) continue;
|
|
|
|
const scopeMessage = getScopeMessageForSetting(
|
|
key,
|
|
selectedScope,
|
|
settings,
|
|
);
|
|
const label = def.label || key;
|
|
const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : '');
|
|
const lWidth = getCachedStringWidth(labelFull);
|
|
const dWidth = def.description
|
|
? getCachedStringWidth(def.description)
|
|
: 0;
|
|
|
|
max = Math.max(max, lWidth, dWidth);
|
|
}
|
|
return max;
|
|
}, [selectedScope, settings]);
|
|
|
|
// Search input buffer
|
|
const searchBuffer = useSearchBuffer({
|
|
initialText: '',
|
|
onChange: setSearchQuery,
|
|
});
|
|
|
|
// Generate items for BaseSettingsDialog
|
|
const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys();
|
|
const items: SettingsDialogItem[] = useMemo(() => {
|
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
|
const mergedSettings = settings.merged;
|
|
|
|
return settingKeys.map((key) => {
|
|
const definition = getSettingDefinition(key);
|
|
const type: SettingsType = definition?.type ?? 'string';
|
|
|
|
// Get the display value (with * indicator if modified)
|
|
const displayValue = getDisplayValue(key, scopeSettings, mergedSettings);
|
|
|
|
// Get the scope message (e.g., "(Modified in Workspace)")
|
|
const scopeMessage = getScopeMessageForSetting(
|
|
key,
|
|
selectedScope,
|
|
settings,
|
|
);
|
|
|
|
// Grey out values that defer to defaults
|
|
const isGreyedOut = !isInSettingsScope(key, scopeSettings);
|
|
|
|
// 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,
|
|
type,
|
|
displayValue,
|
|
isGreyedOut,
|
|
scopeMessage,
|
|
rawValue,
|
|
editValue,
|
|
};
|
|
});
|
|
}, [settingKeys, selectedScope, settings]);
|
|
|
|
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
|
setSelectedScope(scope);
|
|
}, []);
|
|
|
|
// Toggle handler for boolean/enum settings
|
|
const handleItemToggle = useCallback(
|
|
(key: string, _item: SettingsDialogItem) => {
|
|
const definition = getSettingDefinition(key);
|
|
if (!TOGGLE_TYPES.has(definition?.type)) {
|
|
return;
|
|
}
|
|
|
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
|
const currentValue = getEffectiveValue(key, scopeSettings);
|
|
let newValue: SettingsValue;
|
|
|
|
if (definition?.type === 'boolean') {
|
|
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,
|
|
);
|
|
if (currentIndex !== -1 && currentIndex < options.length - 1) {
|
|
newValue = options[currentIndex + 1].value;
|
|
} else {
|
|
newValue = options[0].value; // loop back to start.
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
debugLogger.log(
|
|
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
|
|
newValue,
|
|
);
|
|
setSetting(selectedScope, key, newValue);
|
|
},
|
|
[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) => {
|
|
setSetting(selectedScope, key, undefined);
|
|
},
|
|
[selectedScope, setSetting],
|
|
);
|
|
|
|
const handleClose = useCallback(() => {
|
|
onSelect(undefined, selectedScope as SettingScope);
|
|
}, [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') {
|
|
if (onRestartRequest) onRestartRequest();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
[showRestartPrompt, onRestartRequest],
|
|
);
|
|
|
|
// Decisions on what features to enable
|
|
const hasWorkspace = settings.workspace.path !== undefined;
|
|
const showSearch = !showRestartPrompt;
|
|
|
|
return (
|
|
<BaseSettingsDialog
|
|
title="Settings"
|
|
borderColor={showRestartPrompt ? theme.status.warning : undefined}
|
|
searchEnabled={showSearch}
|
|
searchBuffer={searchBuffer}
|
|
items={items}
|
|
showScopeSelector={hasWorkspace}
|
|
selectedScope={selectedScope}
|
|
onScopeChange={handleScopeChange}
|
|
maxItemsToShow={MAX_ITEMS_TO_SHOW}
|
|
availableHeight={availableTerminalHeight}
|
|
maxLabelWidth={maxLabelOrDescriptionWidth}
|
|
onItemToggle={handleItemToggle}
|
|
onEditCommit={handleEditCommit}
|
|
onItemClear={handleItemClear}
|
|
onClose={handleClose}
|
|
onKeyPress={handleKeyPress}
|
|
footer={
|
|
showRestartPrompt
|
|
? {
|
|
content: (
|
|
<Text color={theme.status.warning}>
|
|
Changes that require a restart have been modified. Press r to
|
|
exit and apply changes now.
|
|
</Text>
|
|
),
|
|
height: 1,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|