feat(ui): add /settings command and UI panel (#4738)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
c632ec8b03
commit
8a9a927544
|
@ -9,18 +9,15 @@ import * as path from 'path';
|
||||||
import { homedir, platform } from 'os';
|
import { homedir, platform } from 'os';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import {
|
import {
|
||||||
MCPServerConfig,
|
|
||||||
GEMINI_CONFIG_DIR as GEMINI_DIR,
|
GEMINI_CONFIG_DIR as GEMINI_DIR,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
BugCommandSettings,
|
|
||||||
ChatCompressionSettings,
|
|
||||||
TelemetrySettings,
|
|
||||||
AuthType,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import stripJsonComments from 'strip-json-comments';
|
import stripJsonComments from 'strip-json-comments';
|
||||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||||
import { DefaultDark } from '../ui/themes/default.js';
|
import { DefaultDark } from '../ui/themes/default.js';
|
||||||
import { CustomTheme } from '../ui/themes/theme.js';
|
import { Settings, MemoryImportFormat } from './settingsSchema.js';
|
||||||
|
|
||||||
|
export type { Settings, MemoryImportFormat };
|
||||||
|
|
||||||
export const SETTINGS_DIRECTORY_NAME = '.gemini';
|
export const SETTINGS_DIRECTORY_NAME = '.gemini';
|
||||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||||
|
@ -44,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
|
||||||
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
export type { DnsResolutionOrder } from './settingsSchema.js';
|
||||||
|
|
||||||
export enum SettingScope {
|
export enum SettingScope {
|
||||||
User = 'User',
|
User = 'User',
|
||||||
|
@ -64,86 +61,6 @@ export interface AccessibilitySettings {
|
||||||
disableLoadingPhrases?: boolean;
|
disableLoadingPhrases?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
theme?: string;
|
|
||||||
customThemes?: Record<string, CustomTheme>;
|
|
||||||
selectedAuthType?: AuthType;
|
|
||||||
useExternalAuth?: boolean;
|
|
||||||
sandbox?: boolean | string;
|
|
||||||
coreTools?: string[];
|
|
||||||
excludeTools?: string[];
|
|
||||||
toolDiscoveryCommand?: string;
|
|
||||||
toolCallCommand?: string;
|
|
||||||
mcpServerCommand?: string;
|
|
||||||
mcpServers?: Record<string, MCPServerConfig>;
|
|
||||||
allowMCPServers?: string[];
|
|
||||||
excludeMCPServers?: string[];
|
|
||||||
showMemoryUsage?: boolean;
|
|
||||||
contextFileName?: string | string[];
|
|
||||||
accessibility?: AccessibilitySettings;
|
|
||||||
telemetry?: TelemetrySettings;
|
|
||||||
usageStatisticsEnabled?: boolean;
|
|
||||||
preferredEditor?: string;
|
|
||||||
bugCommand?: BugCommandSettings;
|
|
||||||
checkpointing?: CheckpointingSettings;
|
|
||||||
autoConfigureMaxOldSpaceSize?: boolean;
|
|
||||||
/** The model name to use (e.g 'gemini-9.0-pro') */
|
|
||||||
model?: string;
|
|
||||||
|
|
||||||
// Git-aware file filtering settings
|
|
||||||
fileFiltering?: {
|
|
||||||
respectGitIgnore?: boolean;
|
|
||||||
respectGeminiIgnore?: boolean;
|
|
||||||
enableRecursiveFileSearch?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
hideWindowTitle?: boolean;
|
|
||||||
|
|
||||||
hideTips?: boolean;
|
|
||||||
hideBanner?: boolean;
|
|
||||||
|
|
||||||
// Setting for setting maximum number of user/model/tool turns in a session.
|
|
||||||
maxSessionTurns?: number;
|
|
||||||
|
|
||||||
// A map of tool names to their summarization settings.
|
|
||||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
|
||||||
|
|
||||||
vimMode?: boolean;
|
|
||||||
memoryImportFormat?: 'tree' | 'flat';
|
|
||||||
|
|
||||||
// Flag to be removed post-launch.
|
|
||||||
ideModeFeature?: boolean;
|
|
||||||
/// IDE mode setting configured via slash command toggle.
|
|
||||||
ideMode?: boolean;
|
|
||||||
|
|
||||||
// Flag to be removed post-launch.
|
|
||||||
folderTrustFeature?: boolean;
|
|
||||||
// Setting to track whether Folder trust is enabled.
|
|
||||||
folderTrust?: boolean;
|
|
||||||
|
|
||||||
// Setting to track if the user has seen the IDE integration nudge.
|
|
||||||
hasSeenIdeIntegrationNudge?: boolean;
|
|
||||||
|
|
||||||
// Setting for disabling auto-update.
|
|
||||||
disableAutoUpdate?: boolean;
|
|
||||||
|
|
||||||
// Setting for disabling the update nag message.
|
|
||||||
disableUpdateNag?: boolean;
|
|
||||||
|
|
||||||
memoryDiscoveryMaxDirs?: number;
|
|
||||||
|
|
||||||
// Environment variables to exclude from project .env files
|
|
||||||
excludedProjectEnvVars?: string[];
|
|
||||||
dnsResolutionOrder?: DnsResolutionOrder;
|
|
||||||
|
|
||||||
includeDirectories?: string[];
|
|
||||||
|
|
||||||
loadMemoryFromIncludeDirectories?: boolean;
|
|
||||||
|
|
||||||
chatCompression?: ChatCompressionSettings;
|
|
||||||
showLineNumbers?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsError {
|
export interface SettingsError {
|
||||||
message: string;
|
message: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
|
@ -0,0 +1,253 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
|
||||||
|
|
||||||
|
describe('SettingsSchema', () => {
|
||||||
|
describe('SETTINGS_SCHEMA', () => {
|
||||||
|
it('should contain all expected top-level settings', () => {
|
||||||
|
const expectedSettings = [
|
||||||
|
'theme',
|
||||||
|
'customThemes',
|
||||||
|
'showMemoryUsage',
|
||||||
|
'usageStatisticsEnabled',
|
||||||
|
'autoConfigureMaxOldSpaceSize',
|
||||||
|
'preferredEditor',
|
||||||
|
'maxSessionTurns',
|
||||||
|
'memoryImportFormat',
|
||||||
|
'memoryDiscoveryMaxDirs',
|
||||||
|
'contextFileName',
|
||||||
|
'vimMode',
|
||||||
|
'ideMode',
|
||||||
|
'accessibility',
|
||||||
|
'checkpointing',
|
||||||
|
'fileFiltering',
|
||||||
|
'disableAutoUpdate',
|
||||||
|
'hideWindowTitle',
|
||||||
|
'hideTips',
|
||||||
|
'hideBanner',
|
||||||
|
'selectedAuthType',
|
||||||
|
'useExternalAuth',
|
||||||
|
'sandbox',
|
||||||
|
'coreTools',
|
||||||
|
'excludeTools',
|
||||||
|
'toolDiscoveryCommand',
|
||||||
|
'toolCallCommand',
|
||||||
|
'mcpServerCommand',
|
||||||
|
'mcpServers',
|
||||||
|
'allowMCPServers',
|
||||||
|
'excludeMCPServers',
|
||||||
|
'telemetry',
|
||||||
|
'bugCommand',
|
||||||
|
'summarizeToolOutput',
|
||||||
|
'ideModeFeature',
|
||||||
|
'dnsResolutionOrder',
|
||||||
|
'excludedProjectEnvVars',
|
||||||
|
'disableUpdateNag',
|
||||||
|
'includeDirectories',
|
||||||
|
'loadMemoryFromIncludeDirectories',
|
||||||
|
'model',
|
||||||
|
'hasSeenIdeIntegrationNudge',
|
||||||
|
'folderTrustFeature',
|
||||||
|
];
|
||||||
|
|
||||||
|
expectedSettings.forEach((setting) => {
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct structure for each setting', () => {
|
||||||
|
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
|
||||||
|
expect(definition).toHaveProperty('type');
|
||||||
|
expect(definition).toHaveProperty('label');
|
||||||
|
expect(definition).toHaveProperty('category');
|
||||||
|
expect(definition).toHaveProperty('requiresRestart');
|
||||||
|
expect(definition).toHaveProperty('default');
|
||||||
|
expect(typeof definition.type).toBe('string');
|
||||||
|
expect(typeof definition.label).toBe('string');
|
||||||
|
expect(typeof definition.category).toBe('string');
|
||||||
|
expect(typeof definition.requiresRestart).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct nested setting structure', () => {
|
||||||
|
const nestedSettings = [
|
||||||
|
'accessibility',
|
||||||
|
'checkpointing',
|
||||||
|
'fileFiltering',
|
||||||
|
];
|
||||||
|
|
||||||
|
nestedSettings.forEach((setting) => {
|
||||||
|
const definition = SETTINGS_SCHEMA[
|
||||||
|
setting as keyof typeof SETTINGS_SCHEMA
|
||||||
|
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
|
||||||
|
properties: unknown;
|
||||||
|
};
|
||||||
|
expect(definition.type).toBe('object');
|
||||||
|
expect(definition.properties).toBeDefined();
|
||||||
|
expect(typeof definition.properties).toBe('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have accessibility nested properties', () => {
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
|
||||||
|
).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have checkpointing nested properties', () => {
|
||||||
|
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
|
||||||
|
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
|
||||||
|
'boolean',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have fileFiltering nested properties', () => {
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique categories', () => {
|
||||||
|
const categories = new Set();
|
||||||
|
|
||||||
|
// Collect categories from top-level settings
|
||||||
|
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
|
||||||
|
categories.add(definition.category);
|
||||||
|
// Also collect from nested properties
|
||||||
|
const defWithProps = definition as typeof definition & {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (defWithProps.properties) {
|
||||||
|
Object.values(defWithProps.properties).forEach(
|
||||||
|
(nestedDef: unknown) => {
|
||||||
|
const nestedDefTyped = nestedDef as { category?: string };
|
||||||
|
if (nestedDefTyped.category) {
|
||||||
|
categories.add(nestedDefTyped.category);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(categories.size).toBeGreaterThan(0);
|
||||||
|
expect(categories).toContain('General');
|
||||||
|
expect(categories).toContain('UI');
|
||||||
|
expect(categories).toContain('Mode');
|
||||||
|
expect(categories).toContain('Updates');
|
||||||
|
expect(categories).toContain('Accessibility');
|
||||||
|
expect(categories).toContain('Checkpointing');
|
||||||
|
expect(categories).toContain('File Filtering');
|
||||||
|
expect(categories).toContain('Advanced');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have consistent default values for boolean settings', () => {
|
||||||
|
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
|
||||||
|
Object.entries(schema).forEach(
|
||||||
|
([_key, definition]: [string, unknown]) => {
|
||||||
|
const def = definition as {
|
||||||
|
type?: string;
|
||||||
|
default?: unknown;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (def.type === 'boolean') {
|
||||||
|
// Boolean settings can have boolean or undefined defaults (for optional settings)
|
||||||
|
expect(['boolean', 'undefined']).toContain(typeof def.default);
|
||||||
|
}
|
||||||
|
if (def.properties) {
|
||||||
|
checkBooleanDefaults(def.properties);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have showInDialog property configured', () => {
|
||||||
|
// Check that user-facing settings are marked for dialog display
|
||||||
|
expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
|
||||||
|
expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(true);
|
||||||
|
|
||||||
|
// Check that advanced settings are hidden from dialog
|
||||||
|
expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
|
||||||
|
expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
|
||||||
|
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
|
||||||
|
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
|
||||||
|
|
||||||
|
// Check that some settings are appropriately hidden
|
||||||
|
expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
|
||||||
|
expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
|
||||||
|
expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
|
||||||
|
expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
|
||||||
|
expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
|
||||||
|
expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
|
||||||
|
expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should infer Settings type correctly', () => {
|
||||||
|
// This test ensures that the Settings type is properly inferred from the schema
|
||||||
|
const settings: Settings = {
|
||||||
|
theme: 'dark',
|
||||||
|
includeDirectories: ['/path/to/dir'],
|
||||||
|
loadMemoryFromIncludeDirectories: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TypeScript should not complain about these properties
|
||||||
|
expect(settings.theme).toBe('dark');
|
||||||
|
expect(settings.includeDirectories).toEqual(['/path/to/dir']);
|
||||||
|
expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have includeDirectories setting in schema', () => {
|
||||||
|
expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
|
||||||
|
expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
|
||||||
|
expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
|
||||||
|
expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
|
||||||
|
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
|
||||||
|
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
|
||||||
|
'boolean',
|
||||||
|
);
|
||||||
|
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
|
||||||
|
'General',
|
||||||
|
);
|
||||||
|
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have folderTrustFeature setting in schema', () => {
|
||||||
|
expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
|
||||||
|
expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
|
||||||
|
expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
|
||||||
|
expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
|
||||||
|
expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,516 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
MCPServerConfig,
|
||||||
|
BugCommandSettings,
|
||||||
|
TelemetrySettings,
|
||||||
|
AuthType,
|
||||||
|
ChatCompressionSettings,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { CustomTheme } from '../ui/themes/theme.js';
|
||||||
|
|
||||||
|
export interface SettingDefinition {
|
||||||
|
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
requiresRestart: boolean;
|
||||||
|
default: boolean | string | number | string[] | object | undefined;
|
||||||
|
description?: string;
|
||||||
|
parentKey?: string;
|
||||||
|
childKey?: string;
|
||||||
|
key?: string;
|
||||||
|
properties?: SettingsSchema;
|
||||||
|
showInDialog?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSchema {
|
||||||
|
[key: string]: SettingDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemoryImportFormat = 'tree' | 'flat';
|
||||||
|
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical schema for all settings.
|
||||||
|
* The structure of this object defines the structure of the `Settings` type.
|
||||||
|
* `as const` is crucial for TypeScript to infer the most specific types possible.
|
||||||
|
*/
|
||||||
|
export const SETTINGS_SCHEMA = {
|
||||||
|
// UI Settings
|
||||||
|
theme: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Theme',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'The color theme for the UI.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
customThemes: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Custom Themes',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: {} as Record<string, CustomTheme>,
|
||||||
|
description: 'Custom theme definitions.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
hideWindowTitle: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Hide Window Title',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Hide the window title bar',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
hideTips: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Hide Tips',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Hide helpful tips in the UI',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
hideBanner: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Hide Banner',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Hide the application banner',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
showMemoryUsage: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Memory Usage',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Display memory usage information in the UI',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
usageStatisticsEnabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Enable Usage Statistics',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable collection of usage statistics',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
autoConfigureMaxOldSpaceSize: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Auto Configure Max Old Space Size',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Automatically configure Node.js memory limits',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
preferredEditor: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Preferred Editor',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'The preferred editor to open files in.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
maxSessionTurns: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Max Session Turns',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as number | undefined,
|
||||||
|
description:
|
||||||
|
'Maximum number of user/model/tool turns to keep in a session.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
memoryImportFormat: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Memory Import Format',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as MemoryImportFormat | undefined,
|
||||||
|
description: 'The format to use when importing memory.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
memoryDiscoveryMaxDirs: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Memory Discovery Max Dirs',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as number | undefined,
|
||||||
|
description: 'Maximum number of directories to search for memory.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
contextFileName: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Context File Name',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | string[] | undefined,
|
||||||
|
description: 'The name of the context file.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
vimMode: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Vim Mode',
|
||||||
|
category: 'Mode',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable Vim keybindings',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
ideMode: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'IDE Mode',
|
||||||
|
category: 'Mode',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable IDE integration mode',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
accessibility: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Accessibility',
|
||||||
|
category: 'Accessibility',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: {},
|
||||||
|
description: 'Accessibility settings.',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {
|
||||||
|
disableLoadingPhrases: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Disable Loading Phrases',
|
||||||
|
category: 'Accessibility',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Disable loading phrases for accessibility',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkpointing: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Checkpointing',
|
||||||
|
category: 'Checkpointing',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: {},
|
||||||
|
description: 'Session checkpointing settings.',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Enable Checkpointing',
|
||||||
|
category: 'Checkpointing',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable session checkpointing for recovery',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileFiltering: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'File Filtering',
|
||||||
|
category: 'File Filtering',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: {},
|
||||||
|
description: 'Settings for git-aware file filtering.',
|
||||||
|
showInDialog: false,
|
||||||
|
properties: {
|
||||||
|
respectGitIgnore: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Respect .gitignore',
|
||||||
|
category: 'File Filtering',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: true,
|
||||||
|
description: 'Respect .gitignore files when searching',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
respectGeminiIgnore: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Respect .geminiignore',
|
||||||
|
category: 'File Filtering',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: true,
|
||||||
|
description: 'Respect .geminiignore files when searching',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
enableRecursiveFileSearch: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Enable Recursive File Search',
|
||||||
|
category: 'File Filtering',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: true,
|
||||||
|
description: 'Enable recursive file search functionality',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
disableAutoUpdate: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Disable Auto Update',
|
||||||
|
category: 'Updates',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Disable automatic updates',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedAuthType: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Selected Auth Type',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as AuthType | undefined,
|
||||||
|
description: 'The currently selected authentication type.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
useExternalAuth: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Use External Auth',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as boolean | undefined,
|
||||||
|
description: 'Whether to use an external authentication flow.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
sandbox: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Sandbox',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as boolean | string | undefined,
|
||||||
|
description:
|
||||||
|
'Sandbox execution environment (can be a boolean or a path string).',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
coreTools: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Core Tools',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string[] | undefined,
|
||||||
|
description: 'Paths to core tool definitions.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
excludeTools: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Exclude Tools',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string[] | undefined,
|
||||||
|
description: 'Tool names to exclude from discovery.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
toolDiscoveryCommand: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Tool Discovery Command',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'Command to run for tool discovery.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
toolCallCommand: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Tool Call Command',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'Command to run for tool calls.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
mcpServerCommand: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'MCP Server Command',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'Command to start an MCP server.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'MCP Servers',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: {} as Record<string, MCPServerConfig>,
|
||||||
|
description: 'Configuration for MCP servers.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
allowMCPServers: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Allow MCP Servers',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string[] | undefined,
|
||||||
|
description: 'A whitelist of MCP servers to allow.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
excludeMCPServers: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Exclude MCP Servers',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as string[] | undefined,
|
||||||
|
description: 'A blacklist of MCP servers to exclude.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Telemetry',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as TelemetrySettings | undefined,
|
||||||
|
description: 'Telemetry configuration.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
bugCommand: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Bug Command',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as BugCommandSettings | undefined,
|
||||||
|
description: 'Configuration for the bug report command.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
summarizeToolOutput: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Summarize Tool Output',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as Record<string, { tokenBudget?: number }> | undefined,
|
||||||
|
description: 'Settings for summarizing tool output.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
ideModeFeature: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'IDE Mode Feature Flag',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as boolean | undefined,
|
||||||
|
description: 'Internal feature flag for IDE mode.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
dnsResolutionOrder: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'DNS Resolution Order',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: undefined as DnsResolutionOrder | undefined,
|
||||||
|
description: 'The DNS resolution order.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
excludedProjectEnvVars: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Excluded Project Environment Variables',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: ['DEBUG', 'DEBUG_MODE'] as string[],
|
||||||
|
description: 'Environment variables to exclude from project context.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
disableUpdateNag: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Disable Update Nag',
|
||||||
|
category: 'Updates',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Disable update notification prompts.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
includeDirectories: {
|
||||||
|
type: 'array',
|
||||||
|
label: 'Include Directories',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: [] as string[],
|
||||||
|
description: 'Additional directories to include in the workspace context.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
loadMemoryFromIncludeDirectories: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Load Memory From Include Directories',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Whether to load memory files from include directories.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Model',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description: 'The Gemini model to use for conversations.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
hasSeenIdeIntegrationNudge: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Has Seen IDE Integration Nudge',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Whether the user has seen the IDE integration nudge.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
folderTrustFeature: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Folder Trust Feature',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Enable folder trust feature for enhanced security.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
folderTrust: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Folder Trust',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Setting to track whether Folder trust is enabled.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
chatCompression: {
|
||||||
|
type: 'object',
|
||||||
|
label: 'Chat Compression',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as ChatCompressionSettings | undefined,
|
||||||
|
description: 'Chat compression settings.',
|
||||||
|
showInDialog: false,
|
||||||
|
},
|
||||||
|
showLineNumbers: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Line Numbers',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Show line numbers in the chat.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type InferSettings<T extends SettingsSchema> = {
|
||||||
|
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
|
||||||
|
? InferSettings<T[K]['properties']>
|
||||||
|
: T[K]['default'] extends boolean
|
||||||
|
? boolean
|
||||||
|
: T[K]['default'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;
|
|
@ -30,6 +30,7 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||||
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||||
|
|
||||||
|
@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||||
statsCommand,
|
statsCommand,
|
||||||
themeCommand,
|
themeCommand,
|
||||||
toolsCommand,
|
toolsCommand,
|
||||||
|
settingsCommand,
|
||||||
vimCommand,
|
vimCommand,
|
||||||
setupGithubCommand,
|
setupGithubCommand,
|
||||||
];
|
];
|
||||||
|
|
|
@ -93,6 +93,8 @@ import ansiEscapes from 'ansi-escapes';
|
||||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||||
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
|
import { SettingsDialog } from './components/SettingsDialog.js';
|
||||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||||
import { appEvents, AppEvent } from '../utils/events.js';
|
import { appEvents, AppEvent } from '../utils/events.js';
|
||||||
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
||||||
|
@ -247,6 +249,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
handleThemeHighlight,
|
handleThemeHighlight,
|
||||||
} = useThemeCommand(settings, setThemeError, addItem);
|
} = useThemeCommand(settings, setThemeError, addItem);
|
||||||
|
|
||||||
|
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||||
|
useSettingsCommand();
|
||||||
|
|
||||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
|
||||||
useFolderTrust(settings);
|
useFolderTrust(settings);
|
||||||
|
|
||||||
|
@ -510,6 +515,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
openPrivacyNotice,
|
openPrivacyNotice,
|
||||||
|
openSettingsDialog,
|
||||||
toggleVimEnabled,
|
toggleVimEnabled,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
setGeminiMdFileCount,
|
setGeminiMdFileCount,
|
||||||
|
@ -975,6 +981,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
terminalWidth={mainAreaWidth}
|
terminalWidth={mainAreaWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isSettingsDialogOpen ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => closeSettingsDialog()}
|
||||||
|
onRestartRequest={() => process.exit(0)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
) : isAuthenticating ? (
|
) : isAuthenticating ? (
|
||||||
<>
|
<>
|
||||||
<AuthInProgress
|
<AuthInProgress
|
||||||
|
@ -1164,7 +1178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
errorCount={errorCount}
|
errorCount={errorCount}
|
||||||
showErrorDetails={showErrorDetails}
|
showErrorDetails={showErrorDetails}
|
||||||
showMemoryUsage={
|
showMemoryUsage={
|
||||||
config.getDebugMode() || config.getShowMemoryUsage()
|
config.getDebugMode() || settings.merged.showMemoryUsage || false
|
||||||
}
|
}
|
||||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||||
nightly={nightly}
|
nightly={nightly}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { settingsCommand } from './settingsCommand.js';
|
||||||
|
import { type CommandContext } from './types.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
|
||||||
|
describe('settingsCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = createMockCommandContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a dialog action to open the settings dialog', () => {
|
||||||
|
if (!settingsCommand.action) {
|
||||||
|
throw new Error('The settings command must have an action.');
|
||||||
|
}
|
||||||
|
const result = settingsCommand.action(mockContext, '');
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'settings',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct name and description', () => {
|
||||||
|
expect(settingsCommand.name).toBe('settings');
|
||||||
|
expect(settingsCommand.description).toBe(
|
||||||
|
'View and edit Gemini CLI settings',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
|
|
||||||
|
export const settingsCommand: SlashCommand = {
|
||||||
|
name: 'settings',
|
||||||
|
description: 'View and edit Gemini CLI settings',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: (_context, _args): OpenDialogActionReturn => ({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'settings',
|
||||||
|
}),
|
||||||
|
};
|
|
@ -102,7 +102,8 @@ export interface MessageActionReturn {
|
||||||
*/
|
*/
|
||||||
export interface OpenDialogActionReturn {
|
export interface OpenDialogActionReturn {
|
||||||
type: 'dialog';
|
type: 'dialog';
|
||||||
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
|
|
||||||
|
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,831 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This test suite covers:
|
||||||
|
* - Initial rendering and display state
|
||||||
|
* - Keyboard navigation (arrows, vim keys, Tab)
|
||||||
|
* - Settings toggling (Enter, Space)
|
||||||
|
* - 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
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from 'ink-testing-library';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SettingsDialog } from './SettingsDialog.js';
|
||||||
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||||
|
|
||||||
|
// Mock the VimModeContext
|
||||||
|
const mockToggleVimEnabled = vi.fn();
|
||||||
|
const mockSetVimMode = vi.fn();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock console.log to avoid noise in tests
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
describe('SettingsDialog', () => {
|
||||||
|
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
console.log = vi.fn();
|
||||||
|
console.error = vi.fn();
|
||||||
|
mockToggleVimEnabled.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockSettings = (
|
||||||
|
userSettings = {},
|
||||||
|
systemSettings = {},
|
||||||
|
workspaceSettings = {},
|
||||||
|
) =>
|
||||||
|
new LoadedSettings(
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
|
||||||
|
path: '/system/settings.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
...userSettings,
|
||||||
|
},
|
||||||
|
path: '/user/settings.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
|
||||||
|
path: '/workspace/settings.json',
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Initial Rendering', () => {
|
||||||
|
it('should render the settings dialog with default state', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
expect(output).toContain('Apply To');
|
||||||
|
expect(output).toContain('Use Enter to select, Tab to change focus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show settings list with default values', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should show some default settings
|
||||||
|
expect(output).toContain('●'); // Active indicator
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight first setting by default', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// First item should be highlighted with green color and active indicator
|
||||||
|
expect(output).toContain('●');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Navigation', () => {
|
||||||
|
it('should navigate down with arrow key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press down arrow
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The active index should have changed (tested indirectly through behavior)
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate up with arrow key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First go down, then up
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate with vim keys (j/k)', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate with vim keys
|
||||||
|
stdin.write('j'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('k'); // Up
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not navigate beyond bounds', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to go up from first item
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should still be on first item
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Toggling', () => {
|
||||||
|
it('should toggle setting with Enter key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Enter to toggle current setting
|
||||||
|
stdin.write('\u000D'); // Enter key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle setting with Space key', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Space to toggle current setting
|
||||||
|
stdin.write(' '); // Space key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle vim mode setting specially', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to vim mode setting and toggle it
|
||||||
|
// This would require knowing the exact position, so we'll just test that the mock is called
|
||||||
|
stdin.write('\u000D'); // Enter key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The mock should potentially be called if vim mode was toggled
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scope Selection', () => {
|
||||||
|
it('should switch between scopes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope focus
|
||||||
|
stdin.write('\t'); // Tab key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Select different scope (numbers 1-3 typically available)
|
||||||
|
stdin.write('2'); // Select second scope option
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to settings focus when scope is selected', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope focus
|
||||||
|
stdin.write('\t'); // Tab key
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain('> Apply To');
|
||||||
|
|
||||||
|
// Select a scope
|
||||||
|
stdin.write('1'); // Select first scope option
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should be back to settings focus
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Restart Prompt', () => {
|
||||||
|
it('should show restart prompt for restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This test would need to trigger a restart-required setting change
|
||||||
|
// The exact steps depend on which settings require restart
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle restart request when r is pressed', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press 'r' key (this would only work if restart prompt is showing)
|
||||||
|
stdin.write('r');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// If restart prompt was showing, onRestartRequest should be called
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Escape Key Behavior', () => {
|
||||||
|
it('should call onSelect with undefined when Escape is pressed', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Escape key
|
||||||
|
stdin.write('\u001B'); // ESC key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Persistence', () => {
|
||||||
|
it('should persist settings across scope changes', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true });
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to scope selector
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Change scope
|
||||||
|
stdin.write('2'); // Select workspace scope
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Settings should be reloaded for new scope
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show different values for different scopes', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: true }, // User settings
|
||||||
|
{ vimMode: false }, // System settings
|
||||||
|
{ autoUpdate: false }, // Workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show user scope values initially
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to toggle a setting (this might trigger vim mode toggle)
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex State Management', () => {
|
||||||
|
it('should track modified settings correctly', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle a setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Toggle another setting
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should track multiple modified settings
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scrolling when there are many settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate down many times to test scrolling
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
await wait(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VimMode Integration', () => {
|
||||||
|
it('should sync with VimModeContext when vim mode is toggled', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<VimModeProvider settings={settings}>
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||||
|
</VimModeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to and toggle vim mode setting
|
||||||
|
// This would require knowing the exact position of vim mode setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Specific Settings Behavior', () => {
|
||||||
|
it('should show correct display values for settings with different states', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: true, hideTips: false }, // User settings
|
||||||
|
{ hideWindowTitle: true }, // System settings
|
||||||
|
{ ideMode: false }, // Workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should contain settings labels
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle immediate settings save for non-restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle a non-restart-required setting (like hideTips)
|
||||||
|
stdin.write('\u000D'); // Enter - toggle current setting
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should save immediately without showing restart prompt
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show restart prompt for restart-required settings', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This test would need to navigate to a specific restart-required setting
|
||||||
|
// Since we can't easily target specific settings, we test the general behavior
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should not show restart prompt initially
|
||||||
|
expect(lastFrame()).not.toContain(
|
||||||
|
'To see changes, Gemini CLI must be restarted',
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear restart prompt when switching scopes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restart prompt should be cleared when switching scopes
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings Display Values', () => {
|
||||||
|
it('should show correct values for inherited settings', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{}, // No user settings
|
||||||
|
{ vimMode: true, hideWindowTitle: false }, // System settings
|
||||||
|
{}, // No workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Settings should show inherited values
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show override indicator for overridden settings', () => {
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: false }, // User overrides
|
||||||
|
{ vimMode: true }, // System default
|
||||||
|
{}, // No workspace settings
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
// Should show settings with override indicators
|
||||||
|
expect(output).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||||
|
it('should handle rapid key presses gracefully', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rapid navigation
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
|
stdin.write('\u001B[A'); // Up arrow
|
||||||
|
}
|
||||||
|
await wait(100);
|
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+C to reset current setting to default', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Ctrl+C to reset current setting to default
|
||||||
|
stdin.write('\u0003'); // Ctrl+C
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should reset the current setting to its default value
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+L to reset current setting to default', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Press Ctrl+L to reset current setting to default
|
||||||
|
stdin.write('\u000C'); // Ctrl+L
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should reset the current setting to its default value
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle navigation when only one setting exists', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to navigate when potentially at bounds
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[A'); // Up
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly handle Tab navigation between sections', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start in settings section
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
// Tab to scope section
|
||||||
|
stdin.write('\t');
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain('> Apply To');
|
||||||
|
|
||||||
|
// Tab back to settings section
|
||||||
|
stdin.write('\t');
|
||||||
|
await wait();
|
||||||
|
expect(lastFrame()).toContain(' Apply To');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Recovery', () => {
|
||||||
|
it('should handle malformed settings gracefully', () => {
|
||||||
|
// Create settings with potentially problematic values
|
||||||
|
const settings = createMockSettings(
|
||||||
|
{ vimMode: null as unknown as boolean }, // Invalid value
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still render without crashing
|
||||||
|
expect(lastFrame()).toContain('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing setting definitions gracefully', () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
// Should not crash even if some settings are missing definitions
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex User Interactions', () => {
|
||||||
|
it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate down a few settings
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Toggle a setting
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Switch to scope selector
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Change scope
|
||||||
|
stdin.write('2'); // Select workspace
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Go back to settings
|
||||||
|
stdin.write('\t'); // Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate and toggle another setting
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write(' '); // Space to toggle
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
stdin.write('\u001B'); // Escape
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing multiple settings without losing pending changes', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle first setting (should require restart)
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Navigate to another setting and toggle it (should also require restart)
|
||||||
|
stdin.write('\u001B[B'); // Down
|
||||||
|
await wait();
|
||||||
|
stdin.write('\u000D'); // Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The test verifies that all changes are preserved and the dialog still works
|
||||||
|
// This tests the fix for the bug where changing one setting would reset all pending changes
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain state consistency during complex interactions', async () => {
|
||||||
|
const settings = createMockSettings({ vimMode: true });
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog settings={settings} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Multiple scope changes
|
||||||
|
stdin.write('\t'); // Tab to scope
|
||||||
|
await wait();
|
||||||
|
stdin.write('2'); // Workspace
|
||||||
|
await wait();
|
||||||
|
stdin.write('\t'); // Tab to settings
|
||||||
|
await wait();
|
||||||
|
stdin.write('\t'); // Tab to scope
|
||||||
|
await wait();
|
||||||
|
stdin.write('1'); // User
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should maintain consistent state
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle restart workflow correctly', async () => {
|
||||||
|
const settings = createMockSettings();
|
||||||
|
const onRestartRequest = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(
|
||||||
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onRestartRequest={onRestartRequest}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This would test the restart workflow if we could trigger it
|
||||||
|
stdin.write('r'); // Try restart key
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Without restart prompt showing, this should have no effect
|
||||||
|
expect(onRestartRequest).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { Colors } from '../colors.js';
|
||||||
|
import {
|
||||||
|
LoadedSettings,
|
||||||
|
SettingScope,
|
||||||
|
Settings,
|
||||||
|
} from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
getScopeItems,
|
||||||
|
getScopeMessageForSetting,
|
||||||
|
} from '../../utils/dialogScopeUtils.js';
|
||||||
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
|
import {
|
||||||
|
getDialogSettingKeys,
|
||||||
|
getSettingValue,
|
||||||
|
setPendingSettingValue,
|
||||||
|
getDisplayValue,
|
||||||
|
hasRestartRequiredSettings,
|
||||||
|
saveModifiedSettings,
|
||||||
|
getSettingDefinition,
|
||||||
|
isDefaultValue,
|
||||||
|
requiresRestart,
|
||||||
|
getRestartRequiredFromModified,
|
||||||
|
getDefaultValue,
|
||||||
|
} from '../../utils/settingsUtils.js';
|
||||||
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
settings: LoadedSettings;
|
||||||
|
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
|
||||||
|
onRestartRequest?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxItemsToShow = 8;
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
settings,
|
||||||
|
onSelect,
|
||||||
|
onRestartRequest,
|
||||||
|
}: SettingsDialogProps): React.JSX.Element {
|
||||||
|
// Get vim mode context to sync vim mode changes
|
||||||
|
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||||
|
|
||||||
|
// Focus state: 'settings' or 'scope'
|
||||||
|
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||||
|
'settings',
|
||||||
|
);
|
||||||
|
// Scope selector state (User by default)
|
||||||
|
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||||
|
SettingScope.User,
|
||||||
|
);
|
||||||
|
// Active indices
|
||||||
|
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
||||||
|
// Scroll offset for settings
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||||
|
|
||||||
|
// Local pending settings state for the selected scope
|
||||||
|
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||||
|
// Deep clone to avoid mutation
|
||||||
|
structuredClone(settings.forScope(selectedScope).settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track which settings have been modified by the user
|
||||||
|
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the intended values for modified settings
|
||||||
|
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track restart-required settings across scope changes
|
||||||
|
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingSettings(
|
||||||
|
structuredClone(settings.forScope(selectedScope).settings),
|
||||||
|
);
|
||||||
|
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
|
||||||
|
if (restartRequiredSettings.size === 0) {
|
||||||
|
setShowRestartPrompt(false);
|
||||||
|
}
|
||||||
|
}, [selectedScope, settings, restartRequiredSettings]);
|
||||||
|
|
||||||
|
// Preserve pending changes when scope changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (modifiedSettings.size > 0) {
|
||||||
|
setPendingSettings((prevPending) => {
|
||||||
|
let updatedPending = { ...prevPending };
|
||||||
|
|
||||||
|
// Reapply all modified settings to the new pending settings using stored values
|
||||||
|
modifiedSettings.forEach((key) => {
|
||||||
|
const storedValue = modifiedValues.get(key);
|
||||||
|
if (storedValue !== undefined) {
|
||||||
|
updatedPending = setPendingSettingValue(
|
||||||
|
key,
|
||||||
|
storedValue,
|
||||||
|
updatedPending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedPending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
|
||||||
|
|
||||||
|
const generateSettingsItems = () => {
|
||||||
|
const settingKeys = getDialogSettingKeys();
|
||||||
|
|
||||||
|
return settingKeys.map((key: string) => {
|
||||||
|
const currentValue = getSettingValue(key, pendingSettings, {});
|
||||||
|
const definition = getSettingDefinition(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: definition?.label || key,
|
||||||
|
value: key,
|
||||||
|
checked: currentValue,
|
||||||
|
toggle: () => {
|
||||||
|
const newValue = !currentValue;
|
||||||
|
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValue(key, newValue, prev),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiresRestart(key)) {
|
||||||
|
const immediateSettings = new Set([key]);
|
||||||
|
const immediateSettingsObject = setPendingSettingValue(
|
||||||
|
key,
|
||||||
|
newValue,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.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 === 'vimMode' && newValue !== vimEnabled) {
|
||||||
|
// Call toggleVimEnabled to sync the VimModeContext local state
|
||||||
|
toggleVimEnabled().catch((error) => {
|
||||||
|
console.error('Failed to toggle vim mode:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current modified settings before updating state
|
||||||
|
const currentModifiedSettings = new Set(modifiedSettings);
|
||||||
|
|
||||||
|
// Remove the saved setting from modifiedSettings since it's now saved
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(key);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from modifiedValues as well
|
||||||
|
setModifiedValues((prev) => {
|
||||||
|
const updated = new Map(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;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingSettings((_prevPending) => {
|
||||||
|
let updatedPending = structuredClone(
|
||||||
|
settings.forScope(selectedScope).settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
currentModifiedSettings.forEach((modifiedKey) => {
|
||||||
|
if (modifiedKey !== key) {
|
||||||
|
const modifiedValue = modifiedValues.get(modifiedKey);
|
||||||
|
if (modifiedValue !== undefined) {
|
||||||
|
updatedPending = setPendingSettingValue(
|
||||||
|
modifiedKey,
|
||||||
|
modifiedValue,
|
||||||
|
updatedPending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedPending;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For restart-required settings, store the actual value
|
||||||
|
setModifiedValues((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(key, newValue);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev).add(key);
|
||||||
|
const needsRestart = hasRestartRequiredSettings(updated);
|
||||||
|
console.log(
|
||||||
|
`[DEBUG SettingsDialog] Modified settings:`,
|
||||||
|
Array.from(updated),
|
||||||
|
'Needs restart:',
|
||||||
|
needsRestart,
|
||||||
|
);
|
||||||
|
if (needsRestart) {
|
||||||
|
setShowRestartPrompt(true);
|
||||||
|
setRestartRequiredSettings((prevRestart) =>
|
||||||
|
new Set(prevRestart).add(key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = generateSettingsItems();
|
||||||
|
|
||||||
|
// Scope selector items
|
||||||
|
const scopeItems = getScopeItems();
|
||||||
|
|
||||||
|
const handleScopeHighlight = (scope: SettingScope) => {
|
||||||
|
setSelectedScope(scope);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScopeSelect = (scope: SettingScope) => {
|
||||||
|
handleScopeHighlight(scope);
|
||||||
|
setFocusSection('settings');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll logic for settings
|
||||||
|
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||||
|
// Always show arrows for consistent UI and to indicate circular navigation
|
||||||
|
const showScrollUp = true;
|
||||||
|
const showScrollDown = true;
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (key.tab) {
|
||||||
|
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||||
|
}
|
||||||
|
if (focusSection === 'settings') {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
const newIndex =
|
||||||
|
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
|
||||||
|
setActiveSettingIndex(newIndex);
|
||||||
|
// Adjust scroll offset for wrap-around
|
||||||
|
if (newIndex === items.length - 1) {
|
||||||
|
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
|
||||||
|
} else if (newIndex < scrollOffset) {
|
||||||
|
setScrollOffset(newIndex);
|
||||||
|
}
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
const newIndex =
|
||||||
|
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
|
||||||
|
setActiveSettingIndex(newIndex);
|
||||||
|
// Adjust scroll offset for wrap-around
|
||||||
|
if (newIndex === 0) {
|
||||||
|
setScrollOffset(0);
|
||||||
|
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||||
|
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||||
|
}
|
||||||
|
} else if (key.return || input === ' ') {
|
||||||
|
items[activeSettingIndex]?.toggle();
|
||||||
|
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
|
||||||
|
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
|
||||||
|
const currentSetting = items[activeSettingIndex];
|
||||||
|
if (currentSetting) {
|
||||||
|
const defaultValue = getDefaultValue(currentSetting.value);
|
||||||
|
// Ensure defaultValue is a boolean for setPendingSettingValue
|
||||||
|
const booleanDefaultValue =
|
||||||
|
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||||
|
|
||||||
|
// Update pending settings to default value
|
||||||
|
setPendingSettings((prev) =>
|
||||||
|
setPendingSettingValue(
|
||||||
|
currentSetting.value,
|
||||||
|
booleanDefaultValue,
|
||||||
|
prev,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from modified settings since it's now at default
|
||||||
|
setModifiedSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(currentSetting.value);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from restart-required settings if it was there
|
||||||
|
setRestartRequiredSettings((prev) => {
|
||||||
|
const updated = new Set(prev);
|
||||||
|
updated.delete(currentSetting.value);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this setting doesn't require restart, save it immediately
|
||||||
|
if (!requiresRestart(currentSetting.value)) {
|
||||||
|
const immediateSettings = new Set([currentSetting.value]);
|
||||||
|
const immediateSettingsObject = setPendingSettingValue(
|
||||||
|
currentSetting.value,
|
||||||
|
booleanDefaultValue,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
saveModifiedSettings(
|
||||||
|
immediateSettings,
|
||||||
|
immediateSettingsObject,
|
||||||
|
settings,
|
||||||
|
selectedScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showRestartPrompt && input === 'r') {
|
||||||
|
// Only save settings that require restart (non-restart settings were already saved immediately)
|
||||||
|
const restartRequiredSettings =
|
||||||
|
getRestartRequiredFromModified(modifiedSettings);
|
||||||
|
const restartRequiredSet = new Set(restartRequiredSettings);
|
||||||
|
|
||||||
|
if (restartRequiredSet.size > 0) {
|
||||||
|
saveModifiedSettings(
|
||||||
|
restartRequiredSet,
|
||||||
|
pendingSettings,
|
||||||
|
settings,
|
||||||
|
selectedScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowRestartPrompt(false);
|
||||||
|
setRestartRequiredSettings(new Set()); // Clear restart-required settings
|
||||||
|
if (onRestartRequest) onRestartRequest();
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onSelect(undefined, selectedScope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={Colors.Gray}
|
||||||
|
flexDirection="row"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<Text bold color={Colors.AccentBlue}>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
{showScrollUp && <Text color={Colors.Gray}>▲</Text>}
|
||||||
|
{visibleItems.map((item, idx) => {
|
||||||
|
const isActive =
|
||||||
|
focusSection === 'settings' &&
|
||||||
|
activeSettingIndex === idx + scrollOffset;
|
||||||
|
|
||||||
|
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||||
|
const mergedSettings = settings.merged;
|
||||||
|
const displayValue = getDisplayValue(
|
||||||
|
item.value,
|
||||||
|
scopeSettings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||||
|
|
||||||
|
// Generate scope message for this setting
|
||||||
|
const scopeMessage = getScopeMessageForSetting(
|
||||||
|
item.value,
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.value}>
|
||||||
|
<Box flexDirection="row" alignItems="center">
|
||||||
|
<Box minWidth={2} flexShrink={0}>
|
||||||
|
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
|
||||||
|
{isActive ? '●' : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box minWidth={50}>
|
||||||
|
<Text
|
||||||
|
color={isActive ? Colors.AccentGreen : Colors.Foreground}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{scopeMessage && (
|
||||||
|
<Text color={Colors.Gray}> {scopeMessage}</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box minWidth={3} />
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
isActive
|
||||||
|
? Colors.AccentGreen
|
||||||
|
: shouldBeGreyedOut
|
||||||
|
? Colors.Gray
|
||||||
|
: Colors.Foreground
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box height={1} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showScrollDown && <Text color={Colors.Gray}>▼</Text>}
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text bold={focusSection === 'scope'} wrap="truncate">
|
||||||
|
{focusSection === 'scope' ? '> ' : ' '}Apply To
|
||||||
|
</Text>
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={scopeItems}
|
||||||
|
initialIndex={0}
|
||||||
|
onSelect={handleScopeSelect}
|
||||||
|
onHighlight={handleScopeHighlight}
|
||||||
|
isFocused={focusSection === 'scope'}
|
||||||
|
showNumbers={focusSection === 'scope'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
(Use Enter to select, Tab to change focus)
|
||||||
|
</Text>
|
||||||
|
{showRestartPrompt && (
|
||||||
|
<Text color={Colors.AccentYellow}>
|
||||||
|
To see changes, Gemini CLI must be restarted. Press r to exit and
|
||||||
|
apply changes now.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,6 +12,10 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
getScopeItems,
|
||||||
|
getScopeMessageForSetting,
|
||||||
|
} from '../../utils/dialogScopeUtils.js';
|
||||||
|
|
||||||
interface ThemeDialogProps {
|
interface ThemeDialogProps {
|
||||||
/** Callback function when a theme is selected */
|
/** Callback function when a theme is selected */
|
||||||
|
@ -76,11 +80,7 @@ export function ThemeDialog({
|
||||||
// If not found, fall back to the first theme
|
// If not found, fall back to the first theme
|
||||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||||
|
|
||||||
const scopeItems = [
|
const scopeItems = getScopeItems();
|
||||||
{ label: 'User Settings', value: SettingScope.User },
|
|
||||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
|
||||||
{ label: 'System Settings', value: SettingScope.System },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleThemeSelect = useCallback(
|
const handleThemeSelect = useCallback(
|
||||||
(themeName: string) => {
|
(themeName: string) => {
|
||||||
|
@ -120,23 +120,13 @@ export function ThemeDialog({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const otherScopes = Object.values(SettingScope).filter(
|
// Generate scope message for theme setting
|
||||||
(scope) => scope !== selectedScope,
|
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||||
|
'theme',
|
||||||
|
selectedScope,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiedInOtherScopes = otherScopes.filter(
|
|
||||||
(scope) => settings.forScope(scope).settings.theme !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
let otherScopeModifiedMessage = '';
|
|
||||||
if (modifiedInOtherScopes.length > 0) {
|
|
||||||
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
|
|
||||||
otherScopeModifiedMessage =
|
|
||||||
settings.forScope(selectedScope).settings.theme !== undefined
|
|
||||||
? `(Also modified in ${modifiedScopesStr})`
|
|
||||||
: `(Modified in ${modifiedScopesStr})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants for calculating preview pane layout.
|
// Constants for calculating preview pane layout.
|
||||||
// These values are based on the JSX structure below.
|
// These values are based on the JSX structure below.
|
||||||
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||||
|
|
|
@ -147,6 +147,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
vi.fn(), // toggleCorgiMode
|
vi.fn(), // toggleCorgiMode
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
|
vi.fn(), // openSettingsDialog
|
||||||
vi.fn(), // toggleVimEnabled
|
vi.fn(), // toggleVimEnabled
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
),
|
),
|
||||||
|
@ -864,6 +865,9 @@ describe('useSlashCommandProcessor', () => {
|
||||||
vi.fn(), // toggleCorgiMode
|
vi.fn(), // toggleCorgiMode
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
|
|
||||||
|
vi.fn(), // openSettingsDialog
|
||||||
|
vi.fn(), // toggleVimEnabled
|
||||||
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
||||||
vi.fn(), // setIsProcessing
|
vi.fn(), // setIsProcessing
|
||||||
),
|
),
|
||||||
|
|
|
@ -50,6 +50,7 @@ export const useSlashCommandProcessor = (
|
||||||
toggleCorgiMode: () => void,
|
toggleCorgiMode: () => void,
|
||||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||||
openPrivacyNotice: () => void,
|
openPrivacyNotice: () => void,
|
||||||
|
openSettingsDialog: () => void,
|
||||||
toggleVimEnabled: () => Promise<boolean>,
|
toggleVimEnabled: () => Promise<boolean>,
|
||||||
setIsProcessing: (isProcessing: boolean) => void,
|
setIsProcessing: (isProcessing: boolean) => void,
|
||||||
setGeminiMdFileCount: (count: number) => void,
|
setGeminiMdFileCount: (count: number) => void,
|
||||||
|
@ -359,6 +360,11 @@ export const useSlashCommandProcessor = (
|
||||||
case 'privacy':
|
case 'privacy':
|
||||||
openPrivacyNotice();
|
openPrivacyNotice();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'settings':
|
||||||
|
openSettingsDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'help':
|
||||||
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
const unhandled: never = result.dialog;
|
const unhandled: never = result.dialog;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -512,6 +518,7 @@ export const useSlashCommandProcessor = (
|
||||||
openPrivacyNotice,
|
openPrivacyNotice,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
|
openSettingsDialog,
|
||||||
setShellConfirmationRequest,
|
setShellConfirmationRequest,
|
||||||
setSessionShellAllowlist,
|
setSessionShellAllowlist,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useSettingsCommand() {
|
||||||
|
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const openSettingsDialog = useCallback(() => {
|
||||||
|
setIsSettingsDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSettingsDialog = useCallback(() => {
|
||||||
|
setIsSettingsDialogOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSettingsDialogOpen,
|
||||||
|
openSettingsDialog,
|
||||||
|
closeSettingsDialog,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SettingScope, LoadedSettings } from '../config/settings.js';
|
||||||
|
import { settingExistsInScope } from './settingsUtils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared scope labels for dialog components that need to display setting scopes
|
||||||
|
*/
|
||||||
|
export const SCOPE_LABELS = {
|
||||||
|
[SettingScope.User]: 'User Settings',
|
||||||
|
[SettingScope.Workspace]: 'Workspace Settings',
|
||||||
|
[SettingScope.System]: 'System Settings',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get scope items for radio button selects
|
||||||
|
*/
|
||||||
|
export function getScopeItems() {
|
||||||
|
return [
|
||||||
|
{ label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User },
|
||||||
|
{
|
||||||
|
label: SCOPE_LABELS[SettingScope.Workspace],
|
||||||
|
value: SettingScope.Workspace,
|
||||||
|
},
|
||||||
|
{ label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate scope message for a specific setting
|
||||||
|
*/
|
||||||
|
export function getScopeMessageForSetting(
|
||||||
|
settingKey: string,
|
||||||
|
selectedScope: SettingScope,
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): string {
|
||||||
|
const otherScopes = Object.values(SettingScope).filter(
|
||||||
|
(scope) => scope !== selectedScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiedInOtherScopes = otherScopes.filter((scope) => {
|
||||||
|
const scopeSettings = settings.forScope(scope).settings;
|
||||||
|
return settingExistsInScope(settingKey, scopeSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modifiedInOtherScopes.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
|
||||||
|
const currentScopeSettings = settings.forScope(selectedScope).settings;
|
||||||
|
const existsInCurrentScope = settingExistsInScope(
|
||||||
|
settingKey,
|
||||||
|
currentScopeSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return existsInCurrentScope
|
||||||
|
? `(Also modified in ${modifiedScopesStr})`
|
||||||
|
: `(Modified in ${modifiedScopesStr})`;
|
||||||
|
}
|
|
@ -0,0 +1,797 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
// Schema utilities
|
||||||
|
getSettingsByCategory,
|
||||||
|
getSettingDefinition,
|
||||||
|
requiresRestart,
|
||||||
|
getDefaultValue,
|
||||||
|
getRestartRequiredSettings,
|
||||||
|
getEffectiveValue,
|
||||||
|
getAllSettingKeys,
|
||||||
|
getSettingsByType,
|
||||||
|
getSettingsRequiringRestart,
|
||||||
|
isValidSettingKey,
|
||||||
|
getSettingCategory,
|
||||||
|
shouldShowInDialog,
|
||||||
|
getDialogSettingsByCategory,
|
||||||
|
getDialogSettingsByType,
|
||||||
|
getDialogSettingKeys,
|
||||||
|
// Business logic utilities
|
||||||
|
getSettingValue,
|
||||||
|
isSettingModified,
|
||||||
|
settingExistsInScope,
|
||||||
|
setPendingSettingValue,
|
||||||
|
hasRestartRequiredSettings,
|
||||||
|
getRestartRequiredFromModified,
|
||||||
|
getDisplayValue,
|
||||||
|
isDefaultValue,
|
||||||
|
isValueInherited,
|
||||||
|
getEffectiveDisplayValue,
|
||||||
|
} from './settingsUtils.js';
|
||||||
|
|
||||||
|
describe('SettingsUtils', () => {
|
||||||
|
describe('Schema Utilities', () => {
|
||||||
|
describe('getSettingsByCategory', () => {
|
||||||
|
it('should group settings by category', () => {
|
||||||
|
const categories = getSettingsByCategory();
|
||||||
|
|
||||||
|
expect(categories).toHaveProperty('General');
|
||||||
|
expect(categories).toHaveProperty('Accessibility');
|
||||||
|
expect(categories).toHaveProperty('Checkpointing');
|
||||||
|
expect(categories).toHaveProperty('File Filtering');
|
||||||
|
expect(categories).toHaveProperty('UI');
|
||||||
|
expect(categories).toHaveProperty('Mode');
|
||||||
|
expect(categories).toHaveProperty('Updates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include key property in grouped settings', () => {
|
||||||
|
const categories = getSettingsByCategory();
|
||||||
|
|
||||||
|
Object.entries(categories).forEach(([_category, settings]) => {
|
||||||
|
settings.forEach((setting) => {
|
||||||
|
expect(setting.key).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSettingDefinition', () => {
|
||||||
|
it('should return definition for valid setting', () => {
|
||||||
|
const definition = getSettingDefinition('showMemoryUsage');
|
||||||
|
expect(definition).toBeDefined();
|
||||||
|
expect(definition?.label).toBe('Show Memory Usage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid setting', () => {
|
||||||
|
const definition = getSettingDefinition('invalidSetting');
|
||||||
|
expect(definition).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requiresRestart', () => {
|
||||||
|
it('should return true for settings that require restart', () => {
|
||||||
|
expect(requiresRestart('autoConfigureMaxOldSpaceSize')).toBe(true);
|
||||||
|
expect(requiresRestart('checkpointing.enabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for settings that do not require restart', () => {
|
||||||
|
expect(requiresRestart('showMemoryUsage')).toBe(false);
|
||||||
|
expect(requiresRestart('hideTips')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid settings', () => {
|
||||||
|
expect(requiresRestart('invalidSetting')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultValue', () => {
|
||||||
|
it('should return correct default values', () => {
|
||||||
|
expect(getDefaultValue('showMemoryUsage')).toBe(false);
|
||||||
|
expect(getDefaultValue('fileFiltering.enableRecursiveFileSearch')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid settings', () => {
|
||||||
|
expect(getDefaultValue('invalidSetting')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRestartRequiredSettings', () => {
|
||||||
|
it('should return all settings that require restart', () => {
|
||||||
|
const restartSettings = getRestartRequiredSettings();
|
||||||
|
expect(restartSettings).toContain('autoConfigureMaxOldSpaceSize');
|
||||||
|
expect(restartSettings).toContain('checkpointing.enabled');
|
||||||
|
expect(restartSettings).not.toContain('showMemoryUsage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEffectiveValue', () => {
|
||||||
|
it('should return value from settings when set', () => {
|
||||||
|
const settings = { showMemoryUsage: true };
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
|
||||||
|
const value = getEffectiveValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value from merged settings when not set in current scope', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
|
||||||
|
const value = getEffectiveValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default value when not set anywhere', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
|
||||||
|
const value = getEffectiveValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(false); // default value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested settings correctly', () => {
|
||||||
|
const settings = {
|
||||||
|
accessibility: { disableLoadingPhrases: true },
|
||||||
|
};
|
||||||
|
const mergedSettings = {
|
||||||
|
accessibility: { disableLoadingPhrases: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = getEffectiveValue(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid settings', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
|
||||||
|
const value = getEffectiveValue(
|
||||||
|
'invalidSetting',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllSettingKeys', () => {
|
||||||
|
it('should return all setting keys', () => {
|
||||||
|
const keys = getAllSettingKeys();
|
||||||
|
expect(keys).toContain('showMemoryUsage');
|
||||||
|
expect(keys).toContain('accessibility.disableLoadingPhrases');
|
||||||
|
expect(keys).toContain('checkpointing.enabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSettingsByType', () => {
|
||||||
|
it('should return only boolean settings', () => {
|
||||||
|
const booleanSettings = getSettingsByType('boolean');
|
||||||
|
expect(booleanSettings.length).toBeGreaterThan(0);
|
||||||
|
booleanSettings.forEach((setting) => {
|
||||||
|
expect(setting.type).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSettingsRequiringRestart', () => {
|
||||||
|
it('should return only settings that require restart', () => {
|
||||||
|
const restartSettings = getSettingsRequiringRestart();
|
||||||
|
expect(restartSettings.length).toBeGreaterThan(0);
|
||||||
|
restartSettings.forEach((setting) => {
|
||||||
|
expect(setting.requiresRestart).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidSettingKey', () => {
|
||||||
|
it('should return true for valid setting keys', () => {
|
||||||
|
expect(isValidSettingKey('showMemoryUsage')).toBe(true);
|
||||||
|
expect(isValidSettingKey('accessibility.disableLoadingPhrases')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid setting keys', () => {
|
||||||
|
expect(isValidSettingKey('invalidSetting')).toBe(false);
|
||||||
|
expect(isValidSettingKey('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSettingCategory', () => {
|
||||||
|
it('should return correct category for valid settings', () => {
|
||||||
|
expect(getSettingCategory('showMemoryUsage')).toBe('UI');
|
||||||
|
expect(getSettingCategory('accessibility.disableLoadingPhrases')).toBe(
|
||||||
|
'Accessibility',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid settings', () => {
|
||||||
|
expect(getSettingCategory('invalidSetting')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldShowInDialog', () => {
|
||||||
|
it('should return true for settings marked to show in dialog', () => {
|
||||||
|
expect(shouldShowInDialog('showMemoryUsage')).toBe(true);
|
||||||
|
expect(shouldShowInDialog('vimMode')).toBe(true);
|
||||||
|
expect(shouldShowInDialog('hideWindowTitle')).toBe(true);
|
||||||
|
expect(shouldShowInDialog('usageStatisticsEnabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for settings marked to hide from dialog', () => {
|
||||||
|
expect(shouldShowInDialog('selectedAuthType')).toBe(false);
|
||||||
|
expect(shouldShowInDialog('coreTools')).toBe(false);
|
||||||
|
expect(shouldShowInDialog('customThemes')).toBe(false);
|
||||||
|
expect(shouldShowInDialog('theme')).toBe(false); // Changed to false
|
||||||
|
expect(shouldShowInDialog('preferredEditor')).toBe(false); // Changed to false
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for invalid settings (default behavior)', () => {
|
||||||
|
expect(shouldShowInDialog('invalidSetting')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDialogSettingsByCategory', () => {
|
||||||
|
it('should only return settings marked for dialog display', async () => {
|
||||||
|
const categories = getDialogSettingsByCategory();
|
||||||
|
|
||||||
|
// Should include UI settings that are marked for dialog
|
||||||
|
expect(categories['UI']).toBeDefined();
|
||||||
|
const uiSettings = categories['UI'];
|
||||||
|
const uiKeys = uiSettings.map((s) => s.key);
|
||||||
|
expect(uiKeys).toContain('showMemoryUsage');
|
||||||
|
expect(uiKeys).toContain('hideWindowTitle');
|
||||||
|
expect(uiKeys).not.toContain('customThemes'); // This is marked false
|
||||||
|
expect(uiKeys).not.toContain('theme'); // This is now marked false
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include Advanced category settings', () => {
|
||||||
|
const categories = getDialogSettingsByCategory();
|
||||||
|
|
||||||
|
// Advanced settings should be filtered out
|
||||||
|
expect(categories['Advanced']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include settings with showInDialog=true', () => {
|
||||||
|
const categories = getDialogSettingsByCategory();
|
||||||
|
|
||||||
|
const allSettings = Object.values(categories).flat();
|
||||||
|
const allKeys = allSettings.map((s) => s.key);
|
||||||
|
|
||||||
|
expect(allKeys).toContain('vimMode');
|
||||||
|
expect(allKeys).toContain('ideMode');
|
||||||
|
expect(allKeys).toContain('disableAutoUpdate');
|
||||||
|
expect(allKeys).toContain('showMemoryUsage');
|
||||||
|
expect(allKeys).toContain('usageStatisticsEnabled');
|
||||||
|
expect(allKeys).not.toContain('selectedAuthType');
|
||||||
|
expect(allKeys).not.toContain('coreTools');
|
||||||
|
expect(allKeys).not.toContain('theme'); // Now hidden
|
||||||
|
expect(allKeys).not.toContain('preferredEditor'); // Now hidden
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDialogSettingsByType', () => {
|
||||||
|
it('should return only boolean dialog settings', () => {
|
||||||
|
const booleanSettings = getDialogSettingsByType('boolean');
|
||||||
|
|
||||||
|
const keys = booleanSettings.map((s) => s.key);
|
||||||
|
expect(keys).toContain('showMemoryUsage');
|
||||||
|
expect(keys).toContain('vimMode');
|
||||||
|
expect(keys).toContain('hideWindowTitle');
|
||||||
|
expect(keys).toContain('usageStatisticsEnabled');
|
||||||
|
expect(keys).not.toContain('selectedAuthType'); // Advanced setting
|
||||||
|
expect(keys).not.toContain('useExternalAuth'); // Advanced setting
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only string dialog settings', () => {
|
||||||
|
const stringSettings = getDialogSettingsByType('string');
|
||||||
|
|
||||||
|
const keys = stringSettings.map((s) => s.key);
|
||||||
|
// Note: theme and preferredEditor are now hidden from dialog
|
||||||
|
expect(keys).not.toContain('theme'); // Now marked false
|
||||||
|
expect(keys).not.toContain('preferredEditor'); // Now marked false
|
||||||
|
expect(keys).not.toContain('selectedAuthType'); // Advanced setting
|
||||||
|
|
||||||
|
// Most string settings are now hidden, so let's just check they exclude advanced ones
|
||||||
|
expect(keys.every((key) => !key.startsWith('tool'))).toBe(true); // No tool-related settings
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDialogSettingKeys', () => {
|
||||||
|
it('should return only settings marked for dialog display', () => {
|
||||||
|
const dialogKeys = getDialogSettingKeys();
|
||||||
|
|
||||||
|
// Should include settings marked for dialog
|
||||||
|
expect(dialogKeys).toContain('showMemoryUsage');
|
||||||
|
expect(dialogKeys).toContain('vimMode');
|
||||||
|
expect(dialogKeys).toContain('hideWindowTitle');
|
||||||
|
expect(dialogKeys).toContain('usageStatisticsEnabled');
|
||||||
|
expect(dialogKeys).toContain('ideMode');
|
||||||
|
expect(dialogKeys).toContain('disableAutoUpdate');
|
||||||
|
|
||||||
|
// Should include nested settings marked for dialog
|
||||||
|
expect(dialogKeys).toContain('fileFiltering.respectGitIgnore');
|
||||||
|
expect(dialogKeys).toContain('fileFiltering.respectGeminiIgnore');
|
||||||
|
expect(dialogKeys).toContain('fileFiltering.enableRecursiveFileSearch');
|
||||||
|
|
||||||
|
// Should NOT include settings marked as hidden
|
||||||
|
expect(dialogKeys).not.toContain('theme'); // Hidden
|
||||||
|
expect(dialogKeys).not.toContain('customThemes'); // Hidden
|
||||||
|
expect(dialogKeys).not.toContain('preferredEditor'); // Hidden
|
||||||
|
expect(dialogKeys).not.toContain('selectedAuthType'); // Advanced
|
||||||
|
expect(dialogKeys).not.toContain('coreTools'); // Advanced
|
||||||
|
expect(dialogKeys).not.toContain('mcpServers'); // Advanced
|
||||||
|
expect(dialogKeys).not.toContain('telemetry'); // Advanced
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fewer keys than getAllSettingKeys', () => {
|
||||||
|
const allKeys = getAllSettingKeys();
|
||||||
|
const dialogKeys = getDialogSettingKeys();
|
||||||
|
|
||||||
|
expect(dialogKeys.length).toBeLessThan(allKeys.length);
|
||||||
|
expect(dialogKeys.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested settings display correctly', () => {
|
||||||
|
// Test the specific issue with fileFiltering.respectGitIgnore
|
||||||
|
const key = 'fileFiltering.respectGitIgnore';
|
||||||
|
const initialSettings = {};
|
||||||
|
const pendingSettings = {};
|
||||||
|
|
||||||
|
// Set the nested setting to true
|
||||||
|
const updatedPendingSettings = setPendingSettingValue(
|
||||||
|
key,
|
||||||
|
true,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the setting exists in pending settings
|
||||||
|
const existsInPending = settingExistsInScope(
|
||||||
|
key,
|
||||||
|
updatedPendingSettings,
|
||||||
|
);
|
||||||
|
expect(existsInPending).toBe(true);
|
||||||
|
|
||||||
|
// Get the value from pending settings
|
||||||
|
const valueFromPending = getSettingValue(
|
||||||
|
key,
|
||||||
|
updatedPendingSettings,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(valueFromPending).toBe(true);
|
||||||
|
|
||||||
|
// Test getDisplayValue should show the pending change
|
||||||
|
const displayValue = getDisplayValue(
|
||||||
|
key,
|
||||||
|
initialSettings,
|
||||||
|
{},
|
||||||
|
new Set(),
|
||||||
|
updatedPendingSettings,
|
||||||
|
);
|
||||||
|
expect(displayValue).toBe('true*'); // Should show true with * indicating change
|
||||||
|
|
||||||
|
// Test that modified settings also show the * indicator
|
||||||
|
const modifiedSettings = new Set([key]);
|
||||||
|
const displayValueWithModified = getDisplayValue(
|
||||||
|
key,
|
||||||
|
initialSettings,
|
||||||
|
{},
|
||||||
|
modifiedSettings,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(displayValueWithModified).toBe('true*'); // Should show true* because it's in modified settings and default is true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Business Logic Utilities', () => {
|
||||||
|
describe('getSettingValue', () => {
|
||||||
|
it('should return value from settings when set', () => {
|
||||||
|
const settings = { showMemoryUsage: true };
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
|
||||||
|
const value = getSettingValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value from merged settings when not set in current scope', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
|
||||||
|
const value = getSettingValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default value for invalid setting', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
|
||||||
|
const value = getSettingValue(
|
||||||
|
'invalidSetting',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(value).toBe(false); // Default fallback
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSettingModified', () => {
|
||||||
|
it('should return true when value differs from default', () => {
|
||||||
|
expect(isSettingModified('showMemoryUsage', true)).toBe(true);
|
||||||
|
expect(
|
||||||
|
isSettingModified('fileFiltering.enableRecursiveFileSearch', false),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when value matches default', () => {
|
||||||
|
expect(isSettingModified('showMemoryUsage', false)).toBe(false);
|
||||||
|
expect(
|
||||||
|
isSettingModified('fileFiltering.enableRecursiveFileSearch', true),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('settingExistsInScope', () => {
|
||||||
|
it('should return true for top-level settings that exist', () => {
|
||||||
|
const settings = { showMemoryUsage: true };
|
||||||
|
expect(settingExistsInScope('showMemoryUsage', settings)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for top-level settings that do not exist', () => {
|
||||||
|
const settings = {};
|
||||||
|
expect(settingExistsInScope('showMemoryUsage', settings)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for nested settings that exist', () => {
|
||||||
|
const settings = {
|
||||||
|
accessibility: { disableLoadingPhrases: true },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
settingExistsInScope('accessibility.disableLoadingPhrases', settings),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for nested settings that do not exist', () => {
|
||||||
|
const settings = {};
|
||||||
|
expect(
|
||||||
|
settingExistsInScope('accessibility.disableLoadingPhrases', settings),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when parent exists but child does not', () => {
|
||||||
|
const settings = { accessibility: {} };
|
||||||
|
expect(
|
||||||
|
settingExistsInScope('accessibility.disableLoadingPhrases', settings),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setPendingSettingValue', () => {
|
||||||
|
it('should set top-level setting value', () => {
|
||||||
|
const pendingSettings = {};
|
||||||
|
const result = setPendingSettingValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
true,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.showMemoryUsage).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set nested setting value', () => {
|
||||||
|
const pendingSettings = {};
|
||||||
|
const result = setPendingSettingValue(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
true,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessibility?.disableLoadingPhrases).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve existing nested settings', () => {
|
||||||
|
const pendingSettings = {
|
||||||
|
accessibility: { disableLoadingPhrases: false },
|
||||||
|
};
|
||||||
|
const result = setPendingSettingValue(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
true,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessibility?.disableLoadingPhrases).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mutate original settings', () => {
|
||||||
|
const pendingSettings = {};
|
||||||
|
setPendingSettingValue('showMemoryUsage', true, pendingSettings);
|
||||||
|
|
||||||
|
expect(pendingSettings).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasRestartRequiredSettings', () => {
|
||||||
|
it('should return true when modified settings require restart', () => {
|
||||||
|
const modifiedSettings = new Set<string>([
|
||||||
|
'autoConfigureMaxOldSpaceSize',
|
||||||
|
'showMemoryUsage',
|
||||||
|
]);
|
||||||
|
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no modified settings require restart', () => {
|
||||||
|
const modifiedSettings = new Set<string>([
|
||||||
|
'showMemoryUsage',
|
||||||
|
'hideTips',
|
||||||
|
]);
|
||||||
|
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty set', () => {
|
||||||
|
const modifiedSettings = new Set<string>();
|
||||||
|
expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRestartRequiredFromModified', () => {
|
||||||
|
it('should return only settings that require restart', () => {
|
||||||
|
const modifiedSettings = new Set<string>([
|
||||||
|
'autoConfigureMaxOldSpaceSize',
|
||||||
|
'showMemoryUsage',
|
||||||
|
'checkpointing.enabled',
|
||||||
|
]);
|
||||||
|
const result = getRestartRequiredFromModified(modifiedSettings);
|
||||||
|
|
||||||
|
expect(result).toContain('autoConfigureMaxOldSpaceSize');
|
||||||
|
expect(result).toContain('checkpointing.enabled');
|
||||||
|
expect(result).not.toContain('showMemoryUsage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no settings require restart', () => {
|
||||||
|
const modifiedSettings = new Set<string>([
|
||||||
|
'showMemoryUsage',
|
||||||
|
'hideTips',
|
||||||
|
]);
|
||||||
|
const result = getRestartRequiredFromModified(modifiedSettings);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDisplayValue', () => {
|
||||||
|
it('should show value without * when setting matches default', () => {
|
||||||
|
const settings = { showMemoryUsage: false }; // false matches default, so no *
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
const modifiedSettings = new Set<string>();
|
||||||
|
|
||||||
|
const result = getDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe('false'); // matches default, no *
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show default value when setting is not in scope', () => {
|
||||||
|
const settings = {}; // no setting in scope
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
const modifiedSettings = new Set<string>();
|
||||||
|
|
||||||
|
const result = getDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe('false'); // shows default value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show value with * when changed from default', () => {
|
||||||
|
const settings = { showMemoryUsage: true }; // true is different from default (false)
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
const modifiedSettings = new Set<string>();
|
||||||
|
|
||||||
|
const result = getDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe('true*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show default value without * when setting does not exist in scope', () => {
|
||||||
|
const settings = {}; // setting doesn't exist in scope, show default
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
const modifiedSettings = new Set<string>();
|
||||||
|
|
||||||
|
const result = getDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe('false'); // default value (false) without *
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show value with * when user changes from default', () => {
|
||||||
|
const settings = {}; // setting doesn't exist in scope originally
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
const modifiedSettings = new Set<string>(['showMemoryUsage']);
|
||||||
|
const pendingSettings = { showMemoryUsage: true }; // user changed to true
|
||||||
|
|
||||||
|
const result = getDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
modifiedSettings,
|
||||||
|
pendingSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe('true*'); // changed from default (false) to true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDefaultValue', () => {
|
||||||
|
it('should return true when setting does not exist in scope', () => {
|
||||||
|
const settings = {}; // setting doesn't exist
|
||||||
|
|
||||||
|
const result = isDefaultValue('showMemoryUsage', settings);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when setting exists in scope', () => {
|
||||||
|
const settings = { showMemoryUsage: true }; // setting exists
|
||||||
|
|
||||||
|
const result = isDefaultValue('showMemoryUsage', settings);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when nested setting does not exist in scope', () => {
|
||||||
|
const settings = {}; // nested setting doesn't exist
|
||||||
|
|
||||||
|
const result = isDefaultValue(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when nested setting exists in scope', () => {
|
||||||
|
const settings = { accessibility: { disableLoadingPhrases: true } }; // nested setting exists
|
||||||
|
|
||||||
|
const result = isDefaultValue(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValueInherited', () => {
|
||||||
|
it('should return false for top-level settings that exist in scope', () => {
|
||||||
|
const settings = { showMemoryUsage: true };
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
|
||||||
|
const result = isValueInherited(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for top-level settings that do not exist in scope', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
|
||||||
|
const result = isValueInherited(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for nested settings that exist in scope', () => {
|
||||||
|
const settings = {
|
||||||
|
accessibility: { disableLoadingPhrases: true },
|
||||||
|
};
|
||||||
|
const mergedSettings = {
|
||||||
|
accessibility: { disableLoadingPhrases: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isValueInherited(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for nested settings that do not exist in scope', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = {
|
||||||
|
accessibility: { disableLoadingPhrases: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = isValueInherited(
|
||||||
|
'accessibility.disableLoadingPhrases',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEffectiveDisplayValue', () => {
|
||||||
|
it('should return value from settings when available', () => {
|
||||||
|
const settings = { showMemoryUsage: true };
|
||||||
|
const mergedSettings = { showMemoryUsage: false };
|
||||||
|
|
||||||
|
const result = getEffectiveDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value from merged settings when not in scope', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = { showMemoryUsage: true };
|
||||||
|
|
||||||
|
const result = getEffectiveDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default value for undefined values', () => {
|
||||||
|
const settings = {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
|
||||||
|
const result = getEffectiveDisplayValue(
|
||||||
|
'showMemoryUsage',
|
||||||
|
settings,
|
||||||
|
mergedSettings,
|
||||||
|
);
|
||||||
|
expect(result).toBe(false); // Default value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,473 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Settings, SettingScope, LoadedSettings } from '../config/settings.js';
|
||||||
|
import {
|
||||||
|
SETTINGS_SCHEMA,
|
||||||
|
SettingDefinition,
|
||||||
|
SettingsSchema,
|
||||||
|
} from '../config/settingsSchema.js';
|
||||||
|
|
||||||
|
// The schema is now nested, but many parts of the UI and logic work better
|
||||||
|
// with a flattened structure and dot-notation keys. This section flattens the
|
||||||
|
// schema into a map for easier lookups.
|
||||||
|
|
||||||
|
function flattenSchema(
|
||||||
|
schema: SettingsSchema,
|
||||||
|
prefix = '',
|
||||||
|
): Record<string, SettingDefinition & { key: string }> {
|
||||||
|
let result: Record<string, SettingDefinition & { key: string }> = {};
|
||||||
|
for (const key in schema) {
|
||||||
|
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
const definition = schema[key];
|
||||||
|
result[newKey] = { ...definition, key: newKey };
|
||||||
|
if (definition.properties) {
|
||||||
|
result = { ...result, ...flattenSchema(definition.properties, newKey) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLATTENED_SCHEMA = flattenSchema(SETTINGS_SCHEMA);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all settings grouped by category
|
||||||
|
*/
|
||||||
|
export function getSettingsByCategory(): Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> {
|
||||||
|
const categories: Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
Object.values(FLATTENED_SCHEMA).forEach((definition) => {
|
||||||
|
const category = definition.category;
|
||||||
|
if (!categories[category]) {
|
||||||
|
categories[category] = [];
|
||||||
|
}
|
||||||
|
categories[category].push(definition);
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting definition by key
|
||||||
|
*/
|
||||||
|
export function getSettingDefinition(
|
||||||
|
key: string,
|
||||||
|
): (SettingDefinition & { key: string }) | undefined {
|
||||||
|
return FLATTENED_SCHEMA[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting requires restart
|
||||||
|
*/
|
||||||
|
export function requiresRestart(key: string): boolean {
|
||||||
|
return FLATTENED_SCHEMA[key]?.requiresRestart ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default value for a setting
|
||||||
|
*/
|
||||||
|
export function getDefaultValue(key: string): SettingDefinition['default'] {
|
||||||
|
return FLATTENED_SCHEMA[key]?.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all setting keys that require restart
|
||||||
|
*/
|
||||||
|
export function getRestartRequiredSettings(): string[] {
|
||||||
|
return Object.values(FLATTENED_SCHEMA)
|
||||||
|
.filter((definition) => definition.requiresRestart)
|
||||||
|
.map((definition) => definition.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively gets a value from a nested object using a key path array.
|
||||||
|
*/
|
||||||
|
function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
|
||||||
|
const [first, ...rest] = path;
|
||||||
|
if (!first || !(first in obj)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = obj[first];
|
||||||
|
if (rest.length === 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && value !== null) {
|
||||||
|
return getNestedValue(value as Record<string, unknown>, rest);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective value for a setting, considering inheritance from higher scopes
|
||||||
|
* Always returns a value (never undefined) - falls back to default if not set anywhere
|
||||||
|
*/
|
||||||
|
export function getEffectiveValue(
|
||||||
|
key: string,
|
||||||
|
settings: Settings,
|
||||||
|
mergedSettings: Settings,
|
||||||
|
): SettingDefinition['default'] {
|
||||||
|
const definition = getSettingDefinition(key);
|
||||||
|
if (!definition) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = key.split('.');
|
||||||
|
|
||||||
|
// Check the current scope's settings first
|
||||||
|
let value = getNestedValue(settings as Record<string, unknown>, path);
|
||||||
|
if (value !== undefined) {
|
||||||
|
return value as SettingDefinition['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the merged settings for an inherited value
|
||||||
|
value = getNestedValue(mergedSettings as Record<string, unknown>, path);
|
||||||
|
if (value !== undefined) {
|
||||||
|
return value as SettingDefinition['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default value if no value is set anywhere
|
||||||
|
return definition.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all setting keys from the schema
|
||||||
|
*/
|
||||||
|
export function getAllSettingKeys(): string[] {
|
||||||
|
return Object.keys(FLATTENED_SCHEMA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings by type
|
||||||
|
*/
|
||||||
|
export function getSettingsByType(
|
||||||
|
type: SettingDefinition['type'],
|
||||||
|
): Array<SettingDefinition & { key: string }> {
|
||||||
|
return Object.values(FLATTENED_SCHEMA).filter(
|
||||||
|
(definition) => definition.type === type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings that require restart
|
||||||
|
*/
|
||||||
|
export function getSettingsRequiringRestart(): Array<
|
||||||
|
SettingDefinition & {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return Object.values(FLATTENED_SCHEMA).filter(
|
||||||
|
(definition) => definition.requiresRestart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a setting key exists in the schema
|
||||||
|
*/
|
||||||
|
export function isValidSettingKey(key: string): boolean {
|
||||||
|
return key in FLATTENED_SCHEMA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category for a setting
|
||||||
|
*/
|
||||||
|
export function getSettingCategory(key: string): string | undefined {
|
||||||
|
return FLATTENED_SCHEMA[key]?.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting should be shown in the settings dialog
|
||||||
|
*/
|
||||||
|
export function shouldShowInDialog(key: string): boolean {
|
||||||
|
return FLATTENED_SCHEMA[key]?.showInDialog ?? true; // Default to true for backward compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all settings that should be shown in the dialog, grouped by category
|
||||||
|
*/
|
||||||
|
export function getDialogSettingsByCategory(): Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> {
|
||||||
|
const categories: Record<
|
||||||
|
string,
|
||||||
|
Array<SettingDefinition & { key: string }>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
Object.values(FLATTENED_SCHEMA)
|
||||||
|
.filter((definition) => definition.showInDialog !== false)
|
||||||
|
.forEach((definition) => {
|
||||||
|
const category = definition.category;
|
||||||
|
if (!categories[category]) {
|
||||||
|
categories[category] = [];
|
||||||
|
}
|
||||||
|
categories[category].push(definition);
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings by type that should be shown in the dialog
|
||||||
|
*/
|
||||||
|
export function getDialogSettingsByType(
|
||||||
|
type: SettingDefinition['type'],
|
||||||
|
): Array<SettingDefinition & { key: string }> {
|
||||||
|
return Object.values(FLATTENED_SCHEMA).filter(
|
||||||
|
(definition) =>
|
||||||
|
definition.type === type && definition.showInDialog !== false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all setting keys that should be shown in the dialog
|
||||||
|
*/
|
||||||
|
export function getDialogSettingKeys(): string[] {
|
||||||
|
return Object.values(FLATTENED_SCHEMA)
|
||||||
|
.filter((definition) => definition.showInDialog !== false)
|
||||||
|
.map((definition) => definition.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current value for a setting in a specific scope
|
||||||
|
* Always returns a value (never undefined) - falls back to default if not set anywhere
|
||||||
|
*/
|
||||||
|
export function getSettingValue(
|
||||||
|
key: string,
|
||||||
|
settings: Settings,
|
||||||
|
mergedSettings: Settings,
|
||||||
|
): boolean {
|
||||||
|
const definition = getSettingDefinition(key);
|
||||||
|
if (!definition) {
|
||||||
|
return false; // Default fallback for invalid settings
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getEffectiveValue(key, settings, mergedSettings);
|
||||||
|
// Ensure we return a boolean value, converting from the more general type
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
// Fall back to default value, ensuring it's a boolean
|
||||||
|
const defaultValue = definition.default;
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return false; // Final fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting value is modified from its default
|
||||||
|
*/
|
||||||
|
export function isSettingModified(key: string, value: boolean): boolean {
|
||||||
|
const defaultValue = getDefaultValue(key);
|
||||||
|
// Handle type comparison properly
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return value !== defaultValue;
|
||||||
|
}
|
||||||
|
// If default is not a boolean, consider it modified if value is true
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting exists in the original settings file for a scope
|
||||||
|
*/
|
||||||
|
export function settingExistsInScope(
|
||||||
|
key: string,
|
||||||
|
scopeSettings: Settings,
|
||||||
|
): boolean {
|
||||||
|
const path = key.split('.');
|
||||||
|
const value = getNestedValue(scopeSettings as Record<string, unknown>, path);
|
||||||
|
return value !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sets a value in a nested object using a key path array.
|
||||||
|
*/
|
||||||
|
function setNestedValue(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
path: string[],
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const [first, ...rest] = path;
|
||||||
|
if (!first) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length === 0) {
|
||||||
|
obj[first] = value;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj[first] || typeof obj[first] !== 'object') {
|
||||||
|
obj[first] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setNestedValue(obj[first] as Record<string, unknown>, rest, value);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a setting value in the pending settings
|
||||||
|
*/
|
||||||
|
export function setPendingSettingValue(
|
||||||
|
key: string,
|
||||||
|
value: boolean,
|
||||||
|
pendingSettings: Settings,
|
||||||
|
): Settings {
|
||||||
|
const path = key.split('.');
|
||||||
|
const newSettings = JSON.parse(JSON.stringify(pendingSettings));
|
||||||
|
setNestedValue(newSettings, path, value);
|
||||||
|
return newSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any modified settings require a restart
|
||||||
|
*/
|
||||||
|
export function hasRestartRequiredSettings(
|
||||||
|
modifiedSettings: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
return Array.from(modifiedSettings).some((key) => requiresRestart(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the restart required settings from a set of modified settings
|
||||||
|
*/
|
||||||
|
export function getRestartRequiredFromModified(
|
||||||
|
modifiedSettings: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
return Array.from(modifiedSettings).filter((key) => requiresRestart(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save modified settings to the appropriate scope
|
||||||
|
*/
|
||||||
|
export function saveModifiedSettings(
|
||||||
|
modifiedSettings: Set<string>,
|
||||||
|
pendingSettings: Settings,
|
||||||
|
loadedSettings: LoadedSettings,
|
||||||
|
scope: SettingScope,
|
||||||
|
): void {
|
||||||
|
modifiedSettings.forEach((settingKey) => {
|
||||||
|
const path = settingKey.split('.');
|
||||||
|
const value = getNestedValue(
|
||||||
|
pendingSettings as Record<string, unknown>,
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsInOriginalFile = settingExistsInScope(
|
||||||
|
settingKey,
|
||||||
|
loadedSettings.forScope(scope).settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDefaultValue = value === getDefaultValue(settingKey);
|
||||||
|
|
||||||
|
if (existsInOriginalFile || !isDefaultValue) {
|
||||||
|
// This is tricky because setValue only works on top-level keys.
|
||||||
|
// We need to set the whole parent object.
|
||||||
|
const [parentKey] = path;
|
||||||
|
if (parentKey) {
|
||||||
|
// Ensure value is a boolean for setPendingSettingValue
|
||||||
|
const booleanValue = typeof value === 'boolean' ? value : false;
|
||||||
|
const newParentValue = setPendingSettingValue(
|
||||||
|
settingKey,
|
||||||
|
booleanValue,
|
||||||
|
loadedSettings.forScope(scope).settings,
|
||||||
|
)[parentKey as keyof Settings];
|
||||||
|
|
||||||
|
loadedSettings.setValue(
|
||||||
|
scope,
|
||||||
|
parentKey as keyof Settings,
|
||||||
|
newParentValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display value for a setting, showing current scope value with default change indicator
|
||||||
|
*/
|
||||||
|
export function getDisplayValue(
|
||||||
|
key: string,
|
||||||
|
settings: Settings,
|
||||||
|
_mergedSettings: Settings,
|
||||||
|
modifiedSettings: Set<string>,
|
||||||
|
pendingSettings?: Settings,
|
||||||
|
): string {
|
||||||
|
// Prioritize pending changes if user has modified this setting
|
||||||
|
let value: boolean;
|
||||||
|
if (pendingSettings && settingExistsInScope(key, pendingSettings)) {
|
||||||
|
// Show the value from the pending (unsaved) edits when it exists
|
||||||
|
value = getSettingValue(key, pendingSettings, {});
|
||||||
|
} else if (settingExistsInScope(key, settings)) {
|
||||||
|
// Show the value defined at the current scope if present
|
||||||
|
value = getSettingValue(key, settings, {});
|
||||||
|
} else {
|
||||||
|
// Fall back to the schema default when the key is unset in this scope
|
||||||
|
const defaultValue = getDefaultValue(key);
|
||||||
|
value = typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueString = String(value);
|
||||||
|
|
||||||
|
// Check if value is different from default OR if it's in modified settings OR if there are pending changes
|
||||||
|
const defaultValue = getDefaultValue(key);
|
||||||
|
const isChangedFromDefault =
|
||||||
|
typeof defaultValue === 'boolean' ? value !== defaultValue : value === true;
|
||||||
|
const isInModifiedSettings = modifiedSettings.has(key);
|
||||||
|
const hasPendingChanges =
|
||||||
|
pendingSettings && settingExistsInScope(key, pendingSettings);
|
||||||
|
|
||||||
|
// Add * indicator when value differs from default, is in modified settings, or has pending changes
|
||||||
|
if (isChangedFromDefault || isInModifiedSettings || hasPendingChanges) {
|
||||||
|
return `${valueString}*`; // * indicates changed from default value
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting doesn't exist in current scope (should be greyed out)
|
||||||
|
*/
|
||||||
|
export function isDefaultValue(key: string, settings: Settings): boolean {
|
||||||
|
return !settingExistsInScope(key, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a setting value is inherited (not set at current scope)
|
||||||
|
*/
|
||||||
|
export function isValueInherited(
|
||||||
|
key: string,
|
||||||
|
settings: Settings,
|
||||||
|
_mergedSettings: Settings,
|
||||||
|
): boolean {
|
||||||
|
return !settingExistsInScope(key, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective value for display, considering inheritance
|
||||||
|
* Always returns a boolean value (never undefined)
|
||||||
|
*/
|
||||||
|
export function getEffectiveDisplayValue(
|
||||||
|
key: string,
|
||||||
|
settings: Settings,
|
||||||
|
mergedSettings: Settings,
|
||||||
|
): boolean {
|
||||||
|
return getSettingValue(key, settings, mergedSettings);
|
||||||
|
}
|
Loading…
Reference in New Issue