mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
Add system-wide settings config for administrators (#3498)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -168,6 +168,7 @@ async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => '/mock/home/user'),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
|
||||
import {
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
SettingScope,
|
||||
} from './settings.js';
|
||||
@@ -90,12 +92,41 @@ describe('Settings Loading and Merging', () => {
|
||||
describe('loadSettings', () => {
|
||||
it('should load empty settings if no files exist', () => {
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.system.settings).toEqual({});
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should load system settings if only system file exists', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-default',
|
||||
sandbox: false,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual(systemSettingsContent);
|
||||
});
|
||||
|
||||
it('should load user settings if only user file exists', () => {
|
||||
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
|
||||
|
||||
@@ -187,6 +218,50 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-theme',
|
||||
sandbox: false,
|
||||
telemetry: { enabled: false },
|
||||
};
|
||||
const userSettingsContent = {
|
||||
theme: 'dark',
|
||||
sandbox: true,
|
||||
contextFileName: 'USER_CONTEXT.md',
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
sandbox: false,
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
theme: 'system-theme',
|
||||
sandbox: false,
|
||||
telemetry: { enabled: false },
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle contextFileName correctly when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
@@ -409,6 +484,50 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env.WORKSPACE_ENDPOINT;
|
||||
});
|
||||
|
||||
it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => {
|
||||
const userSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const originalSharedVar = process.env.SHARED_VAR;
|
||||
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
||||
delete process.env.SHARED_VAR;
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH) {
|
||||
process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
|
||||
return JSON.stringify(userSettingsContent);
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.configValue).toBe(
|
||||
'user_value_for_user_read',
|
||||
);
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
expect(settings.merged.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
|
||||
// Restore original environment variable state
|
||||
if (originalSharedVar !== undefined) {
|
||||
process.env.SHARED_VAR = originalSharedVar;
|
||||
} else {
|
||||
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
|
||||
const userSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
@@ -453,6 +572,48 @@ describe('Settings Loading and Merging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => {
|
||||
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
const systemSettingsContent = { configValue: '$SHARED_VAR' };
|
||||
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const originalSharedVar = process.env.SHARED_VAR;
|
||||
// Temporarily delete to ensure a clean slate for the test's specific manipulations
|
||||
delete process.env.SHARED_VAR;
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH) {
|
||||
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
|
||||
process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.system.settings.configValue).toBe(
|
||||
'system_value_for_system_read',
|
||||
);
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
||||
|
||||
// Restore original environment variable state
|
||||
if (originalSharedVar !== undefined) {
|
||||
process.env.SHARED_VAR = originalSharedVar;
|
||||
} else {
|
||||
delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
|
||||
}
|
||||
});
|
||||
|
||||
it('should leave unresolved environment variables as is', () => {
|
||||
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
@@ -624,10 +785,10 @@ describe('Settings Loading and Merging', () => {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Workspace theme overrides user theme
|
||||
loadedSettings.setValue(SettingScope.Workspace, 'theme', 'ocean');
|
||||
// System theme overrides user and workspace themes
|
||||
loadedSettings.setValue(SettingScope.System, 'theme', 'ocean');
|
||||
|
||||
expect(loadedSettings.workspace.settings.theme).toBe('ocean');
|
||||
expect(loadedSettings.system.settings.theme).toBe('ocean');
|
||||
expect(loadedSettings.merged.theme).toBe('ocean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { homedir, platform } from 'os';
|
||||
import * as dotenv from 'dotenv';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
@@ -24,9 +24,22 @@ export const SETTINGS_DIRECTORY_NAME = '.gemini';
|
||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function getSystemSettingsPath(): string {
|
||||
if (platform() === 'darwin') {
|
||||
return '/Library/Application Support/GeminiCli/settings.json';
|
||||
} else if (platform() === 'win32') {
|
||||
return 'C:\\ProgramData\\gemini-cli\\settings.json';
|
||||
} else {
|
||||
return '/etc/gemini-cli/settings.json';
|
||||
}
|
||||
}
|
||||
|
||||
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
|
||||
|
||||
export enum SettingScope {
|
||||
User = 'User',
|
||||
Workspace = 'Workspace',
|
||||
System = 'System',
|
||||
}
|
||||
|
||||
export interface CheckpointingSettings {
|
||||
@@ -81,16 +94,19 @@ export interface SettingsFile {
|
||||
}
|
||||
export class LoadedSettings {
|
||||
constructor(
|
||||
system: SettingsFile,
|
||||
user: SettingsFile,
|
||||
workspace: SettingsFile,
|
||||
errors: SettingsError[],
|
||||
) {
|
||||
this.system = system;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
readonly system: SettingsFile;
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly errors: SettingsError[];
|
||||
@@ -105,6 +121,7 @@ export class LoadedSettings {
|
||||
return {
|
||||
...this.user.settings,
|
||||
...this.workspace.settings,
|
||||
...this.system.settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +131,8 @@ export class LoadedSettings {
|
||||
return this.user;
|
||||
case SettingScope.Workspace:
|
||||
return this.workspace;
|
||||
case SettingScope.System:
|
||||
return this.system;
|
||||
default:
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
@@ -243,10 +262,27 @@ export function loadEnvironment(): void {
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
loadEnvironment();
|
||||
let systemSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
|
||||
// Load system settings
|
||||
try {
|
||||
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
|
||||
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
|
||||
const parsedSystemSettings = JSON.parse(
|
||||
stripJsonComments(systemContent),
|
||||
) as Settings;
|
||||
systemSettings = resolveEnvVarsInObject(parsedSystemSettings);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
@@ -300,6 +336,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
}
|
||||
|
||||
return new LoadedSettings(
|
||||
{
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
settings: systemSettings,
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettings,
|
||||
|
||||
@@ -112,7 +112,12 @@ describe('gemini.tsx main function', () => {
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: {},
|
||||
};
|
||||
const systemSettingsFile: SettingsFile = {
|
||||
path: '/system/settings.json',
|
||||
settings: {},
|
||||
};
|
||||
const mockLoadedSettings = new LoadedSettings(
|
||||
systemSettingsFile,
|
||||
userSettingsFile,
|
||||
workspaceSettingsFile,
|
||||
[settingsError],
|
||||
|
||||
@@ -185,19 +185,30 @@ describe('App UI', () => {
|
||||
let currentUnmount: (() => void) | undefined;
|
||||
|
||||
const createMockSettings = (
|
||||
settings: Partial<Settings> = {},
|
||||
settings: {
|
||||
system?: Partial<Settings>;
|
||||
user?: Partial<Settings>;
|
||||
workspace?: Partial<Settings>;
|
||||
} = {},
|
||||
): LoadedSettings => {
|
||||
const systemSettingsFile: SettingsFile = {
|
||||
path: '/system/settings.json',
|
||||
settings: settings.system || {},
|
||||
};
|
||||
const userSettingsFile: SettingsFile = {
|
||||
path: '/user/settings.json',
|
||||
settings: {},
|
||||
settings: settings.user || {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: {
|
||||
...settings,
|
||||
},
|
||||
settings: settings.workspace || {},
|
||||
};
|
||||
return new LoadedSettings(userSettingsFile, workspaceSettingsFile, []);
|
||||
return new LoadedSettings(
|
||||
systemSettingsFile,
|
||||
userSettingsFile,
|
||||
workspaceSettingsFile,
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -222,7 +233,7 @@ describe('App UI', () => {
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
|
||||
|
||||
// Ensure a theme is set so the theme dialog does not appear.
|
||||
mockSettings = createMockSettings({ theme: 'Default' });
|
||||
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -268,8 +279,7 @@ describe('App UI', () => {
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
contextFileName: 'AGENTS.md',
|
||||
theme: 'Default',
|
||||
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
@@ -288,8 +298,10 @@ describe('App UI', () => {
|
||||
|
||||
it('should display a generic message when multiple context files with different names are provided', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
contextFileName: ['AGENTS.md', 'CONTEXT.md'],
|
||||
theme: 'Default',
|
||||
workspace: {
|
||||
contextFileName: ['AGENTS.md', 'CONTEXT.md'],
|
||||
theme: 'Default',
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
@@ -308,8 +320,7 @@ describe('App UI', () => {
|
||||
|
||||
it('should display custom contextFileName with plural when set and count is > 1', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
contextFileName: 'MY_NOTES.TXT',
|
||||
theme: 'Default',
|
||||
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
@@ -328,8 +339,7 @@ describe('App UI', () => {
|
||||
|
||||
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
contextFileName: 'ANY_FILE.MD',
|
||||
theme: 'Default',
|
||||
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
@@ -399,7 +409,9 @@ describe('App UI', () => {
|
||||
|
||||
it('should not display Tips component when hideTips is true', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
hideTips: true,
|
||||
workspace: {
|
||||
hideTips: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
@@ -413,6 +425,24 @@ describe('App UI', () => {
|
||||
expect(vi.mocked(Tips)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show tips if system says show, but workspace and user settings say hide', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
system: { hideTips: false },
|
||||
user: { hideTips: true },
|
||||
workspace: { hideTips: true },
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Tips)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when no theme is set', () => {
|
||||
let originalNoColor: string | undefined;
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ describe('AuthDialog', () => {
|
||||
process.env.GEMINI_API_KEY = '';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
@@ -86,6 +90,12 @@ describe('AuthDialog', () => {
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
@@ -147,6 +157,10 @@ describe('AuthDialog', () => {
|
||||
it('should allow exiting when auth method is already selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function ThemeDialog({
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
||||
{ label: 'System Settings', value: SettingScope.System },
|
||||
];
|
||||
|
||||
const handleThemeSelect = (themeName: string) => {
|
||||
@@ -86,16 +87,21 @@ export function ThemeDialog({
|
||||
}
|
||||
});
|
||||
|
||||
const otherScopes = Object.values(SettingScope).filter(
|
||||
(scope) => scope !== selectedScope,
|
||||
);
|
||||
|
||||
const modifiedInOtherScopes = otherScopes.filter(
|
||||
(scope) => settings.forScope(scope).settings.theme !== undefined,
|
||||
);
|
||||
|
||||
let otherScopeModifiedMessage = '';
|
||||
const otherScope =
|
||||
selectedScope === SettingScope.User
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
if (settings.forScope(otherScope).settings.theme !== undefined) {
|
||||
if (modifiedInOtherScopes.length > 0) {
|
||||
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
|
||||
otherScopeModifiedMessage =
|
||||
settings.forScope(selectedScope).settings.theme !== undefined
|
||||
? `(Also modified in ${otherScope})`
|
||||
: `(Modified in ${otherScope})`;
|
||||
? `(Also modified in ${modifiedScopesStr})`
|
||||
: `(Modified in ${modifiedScopesStr})`;
|
||||
}
|
||||
|
||||
// Constants for calculating preview pane layout.
|
||||
|
||||
Reference in New Issue
Block a user