mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
chore: sync gemini-cli v0.1.19
This commit is contained in:
68
packages/cli/src/utils/cleanup.test.ts
Normal file
68
packages/cli/src/utils/cleanup.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { registerCleanup, runExitCleanup } from './cleanup';
|
||||
|
||||
describe('cleanup', () => {
|
||||
const originalCleanupFunctions = global['cleanupFunctions'];
|
||||
|
||||
beforeEach(() => {
|
||||
// Isolate cleanup functions for each test
|
||||
global['cleanupFunctions'] = [];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original cleanup functions
|
||||
global['cleanupFunctions'] = originalCleanupFunctions;
|
||||
});
|
||||
|
||||
it('should run a registered synchronous function', async () => {
|
||||
const cleanupFn = vi.fn();
|
||||
registerCleanup(cleanupFn);
|
||||
|
||||
await runExitCleanup();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run a registered asynchronous function', async () => {
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
registerCleanup(cleanupFn);
|
||||
|
||||
await runExitCleanup();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run multiple registered functions', async () => {
|
||||
const syncFn = vi.fn();
|
||||
const asyncFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
registerCleanup(syncFn);
|
||||
registerCleanup(asyncFn);
|
||||
|
||||
await runExitCleanup();
|
||||
|
||||
expect(syncFn).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should continue running cleanup functions even if one throws an error', async () => {
|
||||
const errorFn = vi.fn(() => {
|
||||
throw new Error('Test Error');
|
||||
});
|
||||
const successFn = vi.fn();
|
||||
|
||||
registerCleanup(errorFn);
|
||||
registerCleanup(successFn);
|
||||
|
||||
await runExitCleanup();
|
||||
|
||||
expect(errorFn).toHaveBeenCalledTimes(1);
|
||||
expect(successFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,16 @@ import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getProjectTempDir } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const cleanupFunctions: Array<() => void> = [];
|
||||
const cleanupFunctions: Array<(() => void) | (() => Promise<void>)> = [];
|
||||
|
||||
export function registerCleanup(fn: () => void) {
|
||||
export function registerCleanup(fn: (() => void) | (() => Promise<void>)) {
|
||||
cleanupFunctions.push(fn);
|
||||
}
|
||||
|
||||
export function runExitCleanup() {
|
||||
export async function runExitCleanup() {
|
||||
for (const fn of cleanupFunctions) {
|
||||
try {
|
||||
fn();
|
||||
await fn();
|
||||
} catch (_) {
|
||||
// Ignore errors during cleanup.
|
||||
}
|
||||
|
||||
64
packages/cli/src/utils/dialogScopeUtils.ts
Normal file
64
packages/cli/src/utils/dialogScopeUtils.ts
Normal file
@@ -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})`;
|
||||
}
|
||||
149
packages/cli/src/utils/gitUtils.test.ts
Normal file
149
packages/cli/src/utils/gitUtils.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||
import * as child_process from 'child_process';
|
||||
import {
|
||||
isGitHubRepository,
|
||||
getGitRepoRoot,
|
||||
getLatestGitHubRelease,
|
||||
getGitHubRepoInfo,
|
||||
} from './gitUtils.js';
|
||||
|
||||
vi.mock('child_process');
|
||||
|
||||
describe('isGitHubRepository', async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns false if the git command fails', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation((): string => {
|
||||
throw new Error('oops');
|
||||
});
|
||||
expect(isGitHubRepository()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if the remote is not github.com', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce('https://gitlab.com');
|
||||
expect(isGitHubRepository()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if the remote is github.com', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(`
|
||||
origin https://github.com/sethvargo/gemini-cli (fetch)
|
||||
origin https://github.com/sethvargo/gemini-cli (push)
|
||||
`);
|
||||
expect(isGitHubRepository()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitHubRepoInfo', async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws an error if github repo info cannot be determined', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation((): string => {
|
||||
throw new Error('oops');
|
||||
});
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/oops/);
|
||||
});
|
||||
|
||||
it('throws an error if owner/repo could not be determined', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce('');
|
||||
expect(() => {
|
||||
getGitHubRepoInfo();
|
||||
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
|
||||
});
|
||||
|
||||
it('returns the owner and repo', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce(
|
||||
'https://github.com/owner/repo.git ',
|
||||
);
|
||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRepoRoot', async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws an error if git root cannot be determined', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation((): string => {
|
||||
throw new Error('oops');
|
||||
});
|
||||
expect(() => {
|
||||
getGitRepoRoot();
|
||||
}).toThrowError(/oops/);
|
||||
});
|
||||
|
||||
it('throws an error if git root is empty', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce('');
|
||||
expect(() => {
|
||||
getGitRepoRoot();
|
||||
}).toThrowError(/Git repo returned empty value/);
|
||||
});
|
||||
|
||||
it('returns the root', async () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValueOnce('/path/to/git/repo');
|
||||
expect(getGitRepoRoot()).toBe('/path/to/git/repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestRelease', async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws an error if the fetch fails', async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject('nope'));
|
||||
expect(getLatestGitHubRelease()).rejects.toThrowError(
|
||||
/Unable to determine the latest/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if the fetch does not return a json body', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ foo: 'bar' }),
|
||||
} as Response),
|
||||
);
|
||||
expect(getLatestGitHubRelease()).rejects.toThrowError(
|
||||
/Unable to determine the latest/,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the release version', async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ tag_name: 'v1.2.3' }),
|
||||
} as Response),
|
||||
);
|
||||
expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3');
|
||||
});
|
||||
});
|
||||
@@ -5,22 +5,112 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
/**
|
||||
* Checks if a directory is within a git repository hosted on GitHub.
|
||||
* @returns true if the directory is in a git repository with a github.com remote, false otherwise
|
||||
*/
|
||||
export function isGitHubRepository(): boolean {
|
||||
export const isGitHubRepository = (): boolean => {
|
||||
try {
|
||||
const remotes = execSync('git remote -v', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const remotes = (
|
||||
execSync('git remote -v', {
|
||||
encoding: 'utf-8',
|
||||
}) || ''
|
||||
).trim();
|
||||
|
||||
const pattern = /github\.com/;
|
||||
|
||||
return pattern.test(remotes);
|
||||
} catch (_error) {
|
||||
// If any filesystem error occurs, assume not a git repo
|
||||
console.debug(`Failed to get git remote:`, _error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* getGitRepoRoot returns the root directory of the git repository.
|
||||
* @returns the path to the root of the git repo.
|
||||
* @throws error if the exec command fails.
|
||||
*/
|
||||
export const getGitRepoRoot = (): string => {
|
||||
const gitRepoRoot = (
|
||||
execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf-8',
|
||||
}) || ''
|
||||
).trim();
|
||||
|
||||
if (!gitRepoRoot) {
|
||||
throw new Error(`Git repo returned empty value`);
|
||||
}
|
||||
|
||||
return gitRepoRoot;
|
||||
};
|
||||
|
||||
/**
|
||||
* getLatestGitHubRelease returns the release tag as a string.
|
||||
* @returns string of the release tag (e.g. "v1.2.3").
|
||||
*/
|
||||
export const getLatestGitHubRelease = async (
|
||||
proxy?: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
signal: AbortSignal.any([AbortSignal.timeout(30_000), controller.signal]),
|
||||
} as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Invalid response code: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const releaseTag = (await response.json()).tag_name;
|
||||
if (!releaseTag) {
|
||||
throw new Error(`Response did not include tag_name field`);
|
||||
}
|
||||
return releaseTag;
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
|
||||
throw new Error(
|
||||
`Unable to determine the latest run-gemini-cli release on GitHub.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* getGitHubRepoInfo returns the owner and repository for a GitHub repo.
|
||||
* @returns the owner and repository of the github repo.
|
||||
* @throws error if the exec command fails.
|
||||
*/
|
||||
export function getGitHubRepoInfo(): { owner: string; repo: string } {
|
||||
const remoteUrl = execSync('git remote get-url origin', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
const match = remoteUrl.match(
|
||||
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
|
||||
);
|
||||
|
||||
// If the regex fails match, throw an error.
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
throw new Error(
|
||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { owner: match[1], repo: match[2] };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
797
packages/cli/src/utils/settingsUtils.test.ts
Normal file
797
packages/cli/src/utils/settingsUtils.test.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
473
packages/cli/src/utils/settingsUtils.ts
Normal file
473
packages/cli/src/utils/settingsUtils.ts
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user