mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
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:
@@ -19,6 +19,7 @@ import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Configuration field definition for agent settings
|
||||
@@ -111,32 +112,12 @@ interface AgentConfigDialogProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using a path array
|
||||
*/
|
||||
function getNestedValue(
|
||||
obj: Record<string, unknown> | undefined,
|
||||
path: string[],
|
||||
): unknown {
|
||||
if (!obj) return undefined;
|
||||
let current: unknown = obj;
|
||||
for (const key of path) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using a path array, creating intermediate objects as needed
|
||||
*/
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
|
||||
if (!isRecord(obj)) return obj;
|
||||
|
||||
const result = { ...obj };
|
||||
let current = result;
|
||||
|
||||
@@ -144,12 +125,17 @@ function setNestedValue(
|
||||
const key = path[i];
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
} else if (isRecord(current[key])) {
|
||||
current[key] = { ...current[key] };
|
||||
}
|
||||
|
||||
const next = current[key];
|
||||
if (isRecord(next)) {
|
||||
current = next;
|
||||
} else {
|
||||
// Cannot traverse further through non-objects
|
||||
return result;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const finalKey = path[path.length - 1];
|
||||
@@ -267,11 +253,7 @@ export function AgentConfigDialog({
|
||||
const items: SettingsDialogItem[] = useMemo(
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, field.path);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
@@ -324,23 +306,18 @@ export function AgentConfigDialog({
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const currentValue = getNestedValue(pendingOverride, field.path);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
const newValue = !effectiveValue;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
newValue,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => new Set(prev).add(key));
|
||||
|
||||
@@ -375,9 +352,9 @@ export function AgentConfigDialog({
|
||||
}
|
||||
|
||||
// Update pending override locally
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
parsed,
|
||||
) as AgentOverride;
|
||||
@@ -398,9 +375,9 @@ export function AgentConfigDialog({
|
||||
if (!field) return;
|
||||
|
||||
// Remove the override (set to undefined)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
pendingOverride,
|
||||
field.path,
|
||||
undefined,
|
||||
) as AgentOverride;
|
||||
|
||||
@@ -281,14 +281,12 @@ export const DialogManager = ({
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* - Focus section switching between settings and scope selector
|
||||
* - Scope selection and settings persistence across scopes
|
||||
* - Restart-required vs immediate settings behavior
|
||||
* - VimModeContext integration
|
||||
* - Complex user interaction workflows
|
||||
* - Error handling and edge cases
|
||||
* - Display values for inherited and overridden settings
|
||||
@@ -25,12 +24,12 @@ import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
@@ -38,10 +37,6 @@ import {
|
||||
} from '../../config/settingsSchema.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSetVimMode = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
terminalWidth: 100, // Fixed width for consistent snapshots
|
||||
@@ -68,27 +63,6 @@ vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/VimModeContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/VimModeContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useVimMode: () => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT' as const,
|
||||
toggleVimEnabled: mockToggleVimEnabled,
|
||||
setVimMode: mockSetVimMode,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/settingsUtils.js', async () => {
|
||||
const actual = await vi.importActual('../../utils/settingsUtils.js');
|
||||
return {
|
||||
...actual,
|
||||
saveModifiedSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Shared test schemas
|
||||
enum StringEnum {
|
||||
FOO = 'foo',
|
||||
@@ -131,6 +105,62 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
context: {
|
||||
type: 'object',
|
||||
label: 'Context',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Context settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
fileFiltering: {
|
||||
type: 'object',
|
||||
label: 'File Filtering',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'File filtering settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
customIgnoreFilePaths: {
|
||||
type: 'array',
|
||||
label: 'Custom Ignore File Paths',
|
||||
category: 'Context',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description: 'Additional ignore file paths.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
type: 'object',
|
||||
label: 'Security',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Security settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allowedExtensions: {
|
||||
type: 'array',
|
||||
label: 'Extension Source Regex Allowlist',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description: 'Allowed extension source regex patterns.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
tools: {
|
||||
type: 'object',
|
||||
@@ -185,7 +215,7 @@ const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
|
||||
|
||||
// Helper function to render SettingsDialog with standard wrapper
|
||||
const renderDialog = (
|
||||
settings: LoadedSettings,
|
||||
settings: ReturnType<typeof createMockSettings>,
|
||||
onSelect: ReturnType<typeof vi.fn>,
|
||||
options?: {
|
||||
onRestartRequest?: ReturnType<typeof vi.fn>;
|
||||
@@ -193,14 +223,15 @@ const renderDialog = (
|
||||
},
|
||||
) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog
|
||||
onSelect={onSelect}
|
||||
onRestartRequest={options?.onRestartRequest}
|
||||
availableTerminalHeight={options?.availableTerminalHeight}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
describe('SettingsDialog', () => {
|
||||
@@ -210,7 +241,6 @@ describe('SettingsDialog', () => {
|
||||
terminalCapabilityManager,
|
||||
'isKittyProtocolEnabled',
|
||||
).mockReturnValue(true);
|
||||
mockToggleVimEnabled.mockRejectedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -394,9 +424,8 @@ describe('SettingsDialog', () => {
|
||||
|
||||
describe('Settings Toggling', () => {
|
||||
it('should toggle setting with Enter key', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog(
|
||||
@@ -414,29 +443,16 @@ describe('SettingsDialog', () => {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Wait for the setting change to be processed
|
||||
// Wait for setValue to be called
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(saveModifiedSettings).mock.calls.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for the mock to be called
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.vimMode']),
|
||||
expect.objectContaining({
|
||||
general: expect.objectContaining({
|
||||
vimMode: true,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.vimMode',
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -455,13 +471,13 @@ describe('SettingsDialog', () => {
|
||||
expectedValue: StringEnum.FOO,
|
||||
},
|
||||
])('$name', async ({ initialValue, expectedValue }) => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA);
|
||||
|
||||
const settings = createMockSettings();
|
||||
if (initialValue !== undefined) {
|
||||
settings.setValue(SettingScope.User, 'ui.theme', initialValue);
|
||||
}
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const onSelect = vi.fn();
|
||||
|
||||
@@ -482,20 +498,13 @@ describe('SettingsDialog', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui.theme',
|
||||
expectedValue,
|
||||
);
|
||||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
expect.objectContaining({
|
||||
ui: expect.objectContaining({
|
||||
theme: expectedValue,
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -692,30 +701,6 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle vim mode toggle errors gracefully', async () => {
|
||||
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
|
||||
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Try to toggle a setting (this might trigger vim mode toggle)
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
// Should not crash
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex State Management', () => {
|
||||
it('should track modified settings correctly', async () => {
|
||||
const settings = createMockSettings();
|
||||
@@ -767,31 +752,6 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('VimMode Integration', () => {
|
||||
it('should sync with VimModeContext when vim mode is toggled', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = render(
|
||||
<VimModeProvider settings={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</VimModeProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Navigate to and toggle vim mode setting
|
||||
// This would require knowing the exact position of vim mode setting
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Specific Settings Behavior', () => {
|
||||
it('should show correct display values for settings with different states', async () => {
|
||||
const settings = createMockSettings({
|
||||
@@ -861,7 +821,7 @@ describe('SettingsDialog', () => {
|
||||
// Should not show restart prompt initially
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -957,63 +917,41 @@ describe('SettingsDialog', () => {
|
||||
pager: 'less',
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should $name',
|
||||
async ({ toggleCount, shellSettings, expectedSiblings }) => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
])('should $name', async ({ toggleCount, shellSettings }) => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA);
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
});
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const settings = createMockSettings({
|
||||
tools: {
|
||||
shell: shellSettings,
|
||||
},
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, onSelect);
|
||||
|
||||
for (let i = 0; i < toggleCount; i++) {
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
}
|
||||
|
||||
const onSelect = vi.fn();
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { stdin, unmount, waitUntilReady } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// With the store pattern, setValue is called atomically per key.
|
||||
// Sibling preservation is handled by LoadedSettings internally.
|
||||
const calls = setValueSpy.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
calls.forEach((call) => {
|
||||
// Each call should target only 'tools.shell.showColor'
|
||||
expect(call[1]).toBe('tools.shell.showColor');
|
||||
});
|
||||
|
||||
for (let i = 0; i < toggleCount; i++) {
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
vi.mocked(saveModifiedSettings).mock.calls.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(saveModifiedSettings).mock.calls;
|
||||
calls.forEach((call) => {
|
||||
const [modifiedKeys, pendingSettings] = call;
|
||||
|
||||
if (modifiedKeys.has('tools.shell.showColor')) {
|
||||
const shellSettings = pendingSettings.tools?.shell as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
Object.entries(expectedSiblings).forEach(([key, value]) => {
|
||||
expect(shellSettings?.[key]).toBe(value);
|
||||
expect(modifiedKeys.has(`tools.shell.${key}`)).toBe(false);
|
||||
});
|
||||
|
||||
expect(modifiedKeys.size).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||
@@ -1319,7 +1257,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1366,7 +1304,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'Changes that require a restart have been modified',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1385,9 +1323,11 @@ describe('SettingsDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, rerender, waitUntilReady } = render(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -1424,14 +1364,13 @@ describe('SettingsDialog', () => {
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
rerender(
|
||||
rerender(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
<SettingsDialog onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Press Escape to exit
|
||||
await act(async () => {
|
||||
@@ -1447,6 +1386,74 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array Settings Editing', () => {
|
||||
const typeInput = async (
|
||||
stdin: { write: (data: string) => void },
|
||||
input: string,
|
||||
) => {
|
||||
for (const ch of input) {
|
||||
await act(async () => {
|
||||
stdin.write(ch);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
it('should parse comma-separated input as string arrays', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, vi.fn());
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Start editing first array setting
|
||||
});
|
||||
await typeInput(stdin, 'first/path, second/path,third/path');
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Commit
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'context.fileFiltering.customIgnoreFilePaths',
|
||||
['first/path', 'second/path', 'third/path'],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should parse JSON array input for allowedExtensions', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const setValueSpy = vi.spyOn(settings, 'setValue');
|
||||
|
||||
const { stdin, unmount } = renderDialog(settings, vi.fn());
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Move to second array setting
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Start editing
|
||||
});
|
||||
await typeInput(stdin, '["^github\\\\.com/.*$", "^gitlab\\\\.com/.*$"]');
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER as string); // Commit
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.allowedExtensions',
|
||||
['^github\\.com/.*$', '^gitlab\\.com/.*$'],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should display text entered in search', async () => {
|
||||
const settings = createMockSettings();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -531,6 +531,37 @@ describe('BaseSettingsDialog', () => {
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('should prioritize editValue over rawValue stringification', async () => {
|
||||
const objectItem: SettingsDialogItem = {
|
||||
key: 'object-setting',
|
||||
label: 'Object Setting',
|
||||
description: 'A complex object setting',
|
||||
displayValue: '{"foo":"bar"}',
|
||||
type: 'object',
|
||||
rawValue: { foo: 'bar' },
|
||||
editValue: '{"foo":"bar"}',
|
||||
};
|
||||
const { stdin } = await renderDialog({
|
||||
items: [objectItem],
|
||||
});
|
||||
|
||||
// Enter edit mode and immediately commit
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||||
'object-setting',
|
||||
'{"foo":"bar"}',
|
||||
expect.objectContaining({ type: 'object' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit edit on Enter', async () => {
|
||||
const items = createMockItems(4);
|
||||
const stringItem = items.find((i) => i.type === 'string')!;
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { LoadableSettingScope } from '../../../config/settings.js';
|
||||
import type {
|
||||
SettingsType,
|
||||
SettingsValue,
|
||||
} from '../../../config/settingsSchema.js';
|
||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
@@ -33,7 +37,7 @@ export interface SettingsDialogItem {
|
||||
/** Optional description below label */
|
||||
description?: string;
|
||||
/** Item type for determining interaction behavior */
|
||||
type: 'boolean' | 'number' | 'string' | 'enum';
|
||||
type: SettingsType;
|
||||
/** Pre-formatted display value (with * if modified) */
|
||||
displayValue: string;
|
||||
/** Grey out value (at default) */
|
||||
@@ -41,7 +45,9 @@ export interface SettingsDialogItem {
|
||||
/** Scope message e.g., "(Modified in Workspace)" */
|
||||
scopeMessage?: string;
|
||||
/** Raw value for edit mode initialization */
|
||||
rawValue?: string | number | boolean;
|
||||
rawValue?: SettingsValue;
|
||||
/** Optional pre-formatted edit buffer value for complex types */
|
||||
editValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,9 +387,11 @@ export function BaseSettingsDialog({
|
||||
if (currentItem.type === 'boolean' || currentItem.type === 'enum') {
|
||||
onItemToggle(currentItem.key, currentItem);
|
||||
} else {
|
||||
// Start editing for string/number
|
||||
// Start editing for string/number/array/object
|
||||
const rawVal = currentItem.rawValue;
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
const initialValue =
|
||||
currentItem.editValue ??
|
||||
(rawVal !== undefined ? String(rawVal) : '');
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user