Feature custom themes logic (#2639)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
c0bfa388c5
commit
76b935d598
|
@ -95,7 +95,10 @@ describe('Settings Loading and Merging', () => {
|
||||||
expect(settings.system.settings).toEqual({});
|
expect(settings.system.settings).toEqual({});
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual({});
|
expect(settings.merged).toEqual({
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
expect(settings.errors.length).toBe(0);
|
expect(settings.errors.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -124,7 +127,11 @@ describe('Settings Loading and Merging', () => {
|
||||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual(systemSettingsContent);
|
expect(settings.merged).toEqual({
|
||||||
|
...systemSettingsContent,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load user settings if only user file exists', () => {
|
it('should load user settings if only user file exists', () => {
|
||||||
|
@ -153,7 +160,11 @@ describe('Settings Loading and Merging', () => {
|
||||||
);
|
);
|
||||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual(userSettingsContent);
|
expect(settings.merged).toEqual({
|
||||||
|
...userSettingsContent,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load workspace settings if only workspace file exists', () => {
|
it('should load workspace settings if only workspace file exists', () => {
|
||||||
|
@ -180,7 +191,11 @@ describe('Settings Loading and Merging', () => {
|
||||||
);
|
);
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||||
expect(settings.merged).toEqual(workspaceSettingsContent);
|
expect(settings.merged).toEqual({
|
||||||
|
...workspaceSettingsContent,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge user and workspace settings, with workspace taking precedence', () => {
|
it('should merge user and workspace settings, with workspace taking precedence', () => {
|
||||||
|
@ -215,6 +230,8 @@ describe('Settings Loading and Merging', () => {
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
coreTools: ['tool1'],
|
coreTools: ['tool1'],
|
||||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -262,6 +279,8 @@ describe('Settings Loading and Merging', () => {
|
||||||
coreTools: ['tool1'],
|
coreTools: ['tool1'],
|
||||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||||
allowMCPServers: ['server1', 'server2'],
|
allowMCPServers: ['server1', 'server2'],
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -373,6 +392,134 @@ describe('Settings Loading and Merging', () => {
|
||||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
expect(settings.merged.telemetry).toBeUndefined();
|
expect(settings.merged.telemetry).toBeUndefined();
|
||||||
|
expect(settings.merged.customThemes).toEqual({});
|
||||||
|
expect(settings.merged.mcpServers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge MCP servers correctly, with workspace taking precedence', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||||
|
const userSettingsContent = {
|
||||||
|
mcpServers: {
|
||||||
|
'user-server': {
|
||||||
|
command: 'user-command',
|
||||||
|
args: ['--user-arg'],
|
||||||
|
description: 'User MCP server',
|
||||||
|
},
|
||||||
|
'shared-server': {
|
||||||
|
command: 'user-shared-command',
|
||||||
|
description: 'User shared server config',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
mcpServers: {
|
||||||
|
'workspace-server': {
|
||||||
|
command: 'workspace-command',
|
||||||
|
args: ['--workspace-arg'],
|
||||||
|
description: 'Workspace MCP server',
|
||||||
|
},
|
||||||
|
'shared-server': {
|
||||||
|
command: 'workspace-shared-command',
|
||||||
|
description: 'Workspace shared server config',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
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.user.settings).toEqual(userSettingsContent);
|
||||||
|
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||||
|
expect(settings.merged.mcpServers).toEqual({
|
||||||
|
'user-server': {
|
||||||
|
command: 'user-command',
|
||||||
|
args: ['--user-arg'],
|
||||||
|
description: 'User MCP server',
|
||||||
|
},
|
||||||
|
'workspace-server': {
|
||||||
|
command: 'workspace-command',
|
||||||
|
args: ['--workspace-arg'],
|
||||||
|
description: 'Workspace MCP server',
|
||||||
|
},
|
||||||
|
'shared-server': {
|
||||||
|
command: 'workspace-shared-command',
|
||||||
|
description: 'Workspace shared server config',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MCP servers when only in user settings', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const userSettingsContent = {
|
||||||
|
mcpServers: {
|
||||||
|
'user-only-server': {
|
||||||
|
command: 'user-only-command',
|
||||||
|
description: 'User only server',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === USER_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(userSettingsContent);
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.mcpServers).toEqual({
|
||||||
|
'user-only-server': {
|
||||||
|
command: 'user-only-command',
|
||||||
|
description: 'User only server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MCP servers when only in workspace settings', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
const workspaceSettingsContent = {
|
||||||
|
mcpServers: {
|
||||||
|
'workspace-only-server': {
|
||||||
|
command: 'workspace-only-command',
|
||||||
|
description: 'Workspace only server',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation(
|
||||||
|
(p: fs.PathOrFileDescriptor) => {
|
||||||
|
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||||
|
return JSON.stringify(workspaceSettingsContent);
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.mcpServers).toEqual({
|
||||||
|
'workspace-only-server': {
|
||||||
|
command: 'workspace-only-command',
|
||||||
|
description: 'Workspace only server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have mcpServers as empty object if not in any settings file', () => {
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||||
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||||
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
expect(settings.merged.mcpServers).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle JSON parsing errors gracefully', () => {
|
it('should handle JSON parsing errors gracefully', () => {
|
||||||
|
@ -410,7 +557,10 @@ describe('Settings Loading and Merging', () => {
|
||||||
// Check that settings are empty due to parsing errors
|
// Check that settings are empty due to parsing errors
|
||||||
expect(settings.user.settings).toEqual({});
|
expect(settings.user.settings).toEqual({});
|
||||||
expect(settings.workspace.settings).toEqual({});
|
expect(settings.workspace.settings).toEqual({});
|
||||||
expect(settings.merged).toEqual({});
|
expect(settings.merged).toEqual({
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
});
|
||||||
|
|
||||||
// Check that error objects are populated in settings.errors
|
// Check that error objects are populated in settings.errors
|
||||||
expect(settings.errors).toBeDefined();
|
expect(settings.errors).toBeDefined();
|
||||||
|
@ -451,10 +601,13 @@ describe('Settings Loading and Merging', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.user.settings.someUrl).toBe(
|
expect(settings.user.settings.someUrl).toBe(
|
||||||
'https://test.com/user_api_key_from_env',
|
'https://test.com/user_api_key_from_env',
|
||||||
);
|
);
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
||||||
delete process.env.TEST_API_KEY;
|
delete process.env.TEST_API_KEY;
|
||||||
});
|
});
|
||||||
|
@ -483,6 +636,7 @@ describe('Settings Loading and Merging', () => {
|
||||||
expect(settings.workspace.settings.nested.value).toBe(
|
expect(settings.workspace.settings.nested.value).toBe(
|
||||||
'workspace_endpoint_from_env',
|
'workspace_endpoint_from_env',
|
||||||
);
|
);
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
||||||
delete process.env.WORKSPACE_ENDPOINT;
|
delete process.env.WORKSPACE_ENDPOINT;
|
||||||
});
|
});
|
||||||
|
@ -512,13 +666,16 @@ describe('Settings Loading and Merging', () => {
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.user.settings.configValue).toBe(
|
expect(settings.user.settings.configValue).toBe(
|
||||||
'user_value_for_user_read',
|
'user_value_for_user_read',
|
||||||
);
|
);
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.workspace.settings.configValue).toBe(
|
expect(settings.workspace.settings.configValue).toBe(
|
||||||
'workspace_value_for_workspace_read',
|
'workspace_value_for_workspace_read',
|
||||||
);
|
);
|
||||||
// Merged should take workspace's resolved value
|
// Merged should take workspace's resolved value
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.merged.configValue).toBe(
|
expect(settings.merged.configValue).toBe(
|
||||||
'workspace_value_for_workspace_read',
|
'workspace_value_for_workspace_read',
|
||||||
);
|
);
|
||||||
|
@ -600,13 +757,16 @@ describe('Settings Loading and Merging', () => {
|
||||||
|
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||||
|
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.system.settings.configValue).toBe(
|
expect(settings.system.settings.configValue).toBe(
|
||||||
'system_value_for_system_read',
|
'system_value_for_system_read',
|
||||||
);
|
);
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.workspace.settings.configValue).toBe(
|
expect(settings.workspace.settings.configValue).toBe(
|
||||||
'workspace_value_for_workspace_read',
|
'workspace_value_for_workspace_read',
|
||||||
);
|
);
|
||||||
// Merged should take workspace's resolved value
|
// Merged should take system's resolved value
|
||||||
|
// @ts-expect-error: dynamic property for test
|
||||||
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
||||||
|
|
||||||
// Restore original environment variable state
|
// Restore original environment variable state
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
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';
|
||||||
|
|
||||||
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);
|
||||||
|
@ -56,6 +57,7 @@ export interface AccessibilitySettings {
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
customThemes?: Record<string, CustomTheme>;
|
||||||
selectedAuthType?: AuthType;
|
selectedAuthType?: AuthType;
|
||||||
sandbox?: boolean | string;
|
sandbox?: boolean | string;
|
||||||
coreTools?: string[];
|
coreTools?: string[];
|
||||||
|
@ -84,6 +86,7 @@ export interface Settings {
|
||||||
|
|
||||||
// UI setting. Does not display the ANSI-controlled terminal title.
|
// UI setting. Does not display the ANSI-controlled terminal title.
|
||||||
hideWindowTitle?: boolean;
|
hideWindowTitle?: boolean;
|
||||||
|
|
||||||
hideTips?: boolean;
|
hideTips?: boolean;
|
||||||
hideBanner?: boolean;
|
hideBanner?: boolean;
|
||||||
|
|
||||||
|
@ -132,10 +135,24 @@ export class LoadedSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeMergedSettings(): Settings {
|
private computeMergedSettings(): Settings {
|
||||||
|
const system = this.system.settings;
|
||||||
|
const user = this.user.settings;
|
||||||
|
const workspace = this.workspace.settings;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.user.settings,
|
...user,
|
||||||
...this.workspace.settings,
|
...workspace,
|
||||||
...this.system.settings,
|
...system,
|
||||||
|
customThemes: {
|
||||||
|
...(user.customThemes || {}),
|
||||||
|
...(workspace.customThemes || {}),
|
||||||
|
...(system.customThemes || {}),
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
...(user.mcpServers || {}),
|
||||||
|
...(workspace.mcpServers || {}),
|
||||||
|
...(system.mcpServers || {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,13 +169,12 @@ export class LoadedSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setValue(
|
setValue<K extends keyof Settings>(
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
key: keyof Settings,
|
key: K,
|
||||||
value: string | Record<string, MCPServerConfig> | undefined,
|
value: Settings[K],
|
||||||
): void {
|
): void {
|
||||||
const settingsFile = this.forScope(scope);
|
const settingsFile = this.forScope(scope);
|
||||||
// @ts-expect-error - value can be string | Record<string, MCPServerConfig>
|
|
||||||
settingsFile.settings[key] = value;
|
settingsFile.settings[key] = value;
|
||||||
this._merged = this.computeMergedSettings();
|
this._merged = this.computeMergedSettings();
|
||||||
saveSettings(settingsFile);
|
saveSettings(settingsFile);
|
||||||
|
|
|
@ -143,6 +143,9 @@ export async function main() {
|
||||||
|
|
||||||
await config.initialize();
|
await config.initialize();
|
||||||
|
|
||||||
|
// Load custom themes from settings
|
||||||
|
themeManager.loadCustomThemes(settings.merged.customThemes);
|
||||||
|
|
||||||
if (settings.merged.theme) {
|
if (settings.merged.theme) {
|
||||||
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
||||||
// If the theme is not found during initial load, log a warning and continue.
|
// If the theme is not found during initial load, log a warning and continue.
|
||||||
|
|
|
@ -603,7 +603,7 @@ describe('App UI', () => {
|
||||||
);
|
);
|
||||||
currentUnmount = unmount;
|
currentUnmount = unmount;
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Select Theme');
|
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a message if NO_COLOR is set', async () => {
|
it('should display a message if NO_COLOR is set', async () => {
|
||||||
|
@ -618,9 +618,7 @@ describe('App UI', () => {
|
||||||
);
|
);
|
||||||
currentUnmount = unmount;
|
currentUnmount = unmount;
|
||||||
|
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
|
||||||
);
|
|
||||||
expect(lastFrame()).not.toContain('Select Theme');
|
expect(lastFrame()).not.toContain('Select Theme');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe('AuthDialog', () => {
|
||||||
|
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -41,7 +41,7 @@ describe('AuthDialog', () => {
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -68,11 +68,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -95,11 +101,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -122,11 +134,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -150,11 +168,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -173,11 +197,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -198,11 +228,17 @@ describe('AuthDialog', () => {
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -225,17 +261,19 @@ describe('AuthDialog', () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: undefined,
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -262,11 +300,19 @@ describe('AuthDialog', () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: {
|
||||||
|
selectedAuthType: undefined,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -296,17 +342,19 @@ describe('AuthDialog', () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {
|
settings: {
|
||||||
selectedAuthType: AuthType.USE_GEMINI,
|
selectedAuthType: AuthType.USE_GEMINI,
|
||||||
|
customThemes: {},
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
settings: {},
|
settings: { customThemes: {}, mcpServers: {} },
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|
|
@ -36,22 +36,45 @@ export function ThemeDialog({
|
||||||
SettingScope.User,
|
SettingScope.User,
|
||||||
);
|
);
|
||||||
|
|
||||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
// Track the currently highlighted theme name
|
||||||
|
const [highlightedThemeName, setHighlightedThemeName] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(settings.merged.theme || DEFAULT_THEME.name);
|
||||||
|
|
||||||
|
// Generate theme items filtered by selected scope
|
||||||
|
const customThemes =
|
||||||
|
selectedScope === SettingScope.User
|
||||||
|
? settings.user.settings.customThemes || {}
|
||||||
|
: settings.merged.customThemes || {};
|
||||||
|
const builtInThemes = themeManager
|
||||||
|
.getAvailableThemes()
|
||||||
|
.filter((theme) => theme.type !== 'custom');
|
||||||
|
const customThemeNames = Object.keys(customThemes);
|
||||||
|
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
// Generate theme items
|
// Generate theme items
|
||||||
const themeItems = themeManager.getAvailableThemes().map((theme) => ({
|
const themeItems = [
|
||||||
label: theme.name,
|
...builtInThemes.map((theme) => ({
|
||||||
value: theme.name,
|
label: theme.name,
|
||||||
themeNameDisplay: theme.name,
|
value: theme.name,
|
||||||
themeTypeDisplay: capitalize(theme.type),
|
themeNameDisplay: theme.name,
|
||||||
}));
|
themeTypeDisplay: capitalize(theme.type),
|
||||||
|
})),
|
||||||
|
...customThemeNames.map((name) => ({
|
||||||
|
label: name,
|
||||||
|
value: name,
|
||||||
|
themeNameDisplay: name,
|
||||||
|
themeTypeDisplay: 'Custom',
|
||||||
|
})),
|
||||||
|
];
|
||||||
const [selectInputKey, setSelectInputKey] = useState(Date.now());
|
const [selectInputKey, setSelectInputKey] = useState(Date.now());
|
||||||
|
|
||||||
// Determine which radio button should be initially selected in the theme list
|
// Find the index of the selected theme, but only if it exists in the list
|
||||||
// This should reflect the theme *saved* for the selected scope, or the default
|
const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
|
||||||
const initialThemeIndex = themeItems.findIndex(
|
const initialThemeIndex = themeItems.findIndex(
|
||||||
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
|
(item) => item.value === selectedThemeName,
|
||||||
);
|
);
|
||||||
|
// If not found, fallback to the first theme
|
||||||
|
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||||
|
|
||||||
const scopeItems = [
|
const scopeItems = [
|
||||||
{ label: 'User Settings', value: SettingScope.User },
|
{ label: 'User Settings', value: SettingScope.User },
|
||||||
|
@ -66,6 +89,11 @@ export function ThemeDialog({
|
||||||
[onSelect, selectedScope],
|
[onSelect, selectedScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleThemeHighlight = (themeName: string) => {
|
||||||
|
setHighlightedThemeName(themeName);
|
||||||
|
onHighlight(themeName);
|
||||||
|
};
|
||||||
|
|
||||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||||
setSelectedScope(scope);
|
setSelectedScope(scope);
|
||||||
setSelectInputKey(Date.now());
|
setSelectInputKey(Date.now());
|
||||||
|
@ -182,7 +210,6 @@ export function ThemeDialog({
|
||||||
// The code block is slightly longer than the diff, so give it more space.
|
// The code block is slightly longer than the diff, so give it more space.
|
||||||
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
|
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
|
||||||
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
|
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
|
||||||
const themeType = capitalize(themeManager.getActiveTheme().type);
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
|
@ -204,9 +231,9 @@ export function ThemeDialog({
|
||||||
<RadioButtonSelect
|
<RadioButtonSelect
|
||||||
key={selectInputKey}
|
key={selectInputKey}
|
||||||
items={themeItems}
|
items={themeItems}
|
||||||
initialIndex={initialThemeIndex}
|
initialIndex={safeInitialThemeIndex}
|
||||||
onSelect={handleThemeSelect}
|
onSelect={handleThemeSelect}
|
||||||
onHighlight={onHighlight}
|
onHighlight={handleThemeHighlight}
|
||||||
isFocused={currenFocusedSection === 'theme'}
|
isFocused={currenFocusedSection === 'theme'}
|
||||||
maxItemsToShow={8}
|
maxItemsToShow={8}
|
||||||
showScrollArrows={true}
|
showScrollArrows={true}
|
||||||
|
@ -233,40 +260,44 @@ export function ThemeDialog({
|
||||||
|
|
||||||
{/* Right Column: Preview */}
|
{/* Right Column: Preview */}
|
||||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||||
<Text bold>{themeType} Theme Preview</Text>
|
<Text bold>Preview</Text>
|
||||||
<Box
|
{/* Get the Theme object for the highlighted theme, fallback to default if not found */}
|
||||||
borderStyle="single"
|
{(() => {
|
||||||
borderColor={Colors.Gray}
|
const previewTheme =
|
||||||
paddingTop={includePadding ? 1 : 0}
|
themeManager.getTheme(
|
||||||
paddingBottom={includePadding ? 1 : 0}
|
highlightedThemeName || DEFAULT_THEME.name,
|
||||||
paddingLeft={1}
|
) || DEFAULT_THEME;
|
||||||
paddingRight={1}
|
return (
|
||||||
flexDirection="column"
|
<Box
|
||||||
>
|
borderStyle="single"
|
||||||
{colorizeCode(
|
borderColor={Colors.Gray}
|
||||||
`# python function
|
paddingTop={includePadding ? 1 : 0}
|
||||||
def fibonacci(n):
|
paddingBottom={includePadding ? 1 : 0}
|
||||||
a, b = 0, 1
|
paddingLeft={1}
|
||||||
for _ in range(n):
|
paddingRight={1}
|
||||||
a, b = b, a + b
|
flexDirection="column"
|
||||||
return a`,
|
>
|
||||||
'python',
|
{colorizeCode(
|
||||||
codeBlockHeight,
|
`# function
|
||||||
colorizeCodeWidth,
|
-def fibonacci(n):
|
||||||
)}
|
- a, b = 0, 1
|
||||||
<Box marginTop={1} />
|
- for _ in range(n):
|
||||||
<DiffRenderer
|
- a, b = b, a + b
|
||||||
diffContent={`--- a/util.py
|
- return a`,
|
||||||
+++ b/util.py
|
'python',
|
||||||
@@ -1,3 +1,3 @@
|
codeBlockHeight,
|
||||||
def greet(name):
|
colorizeCodeWidth,
|
||||||
- print("Hello, " + name)
|
)}
|
||||||
+ print(f"Hello, {name}!")
|
<Box marginTop={1} />
|
||||||
`}
|
<DiffRenderer
|
||||||
availableTerminalHeight={diffHeight}
|
diffContent={`--- a/old_file.txt\n+++ b/new_file.txt\n@@ -1,6 +1,7 @@\n # function\n-def fibonacci(n):\n- a, b = 0, 1\n- for _ in range(n):\n- a, b = b, a + b\n- return a\n+def fibonacci(n):\n+ a, b = 0, 1\n+ for _ in range(n):\n+ a, b = b, a + b\n+ return a\n+\n+print(fibonacci(10))\n`}
|
||||||
terminalWidth={colorizeCodeWidth}
|
availableTerminalHeight={diffHeight}
|
||||||
/>
|
terminalWidth={colorizeCodeWidth}
|
||||||
</Box>
|
theme={previewTheme}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|
|
@ -44,6 +44,7 @@ index 0000000..e69de29
|
||||||
'python',
|
'python',
|
||||||
undefined,
|
undefined,
|
||||||
80,
|
80,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,6 +72,7 @@ index 0000000..e69de29
|
||||||
null,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
80,
|
80,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -94,6 +96,7 @@ index 0000000..e69de29
|
||||||
null,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
80,
|
80,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -93,6 +93,7 @@ interface DiffRendererProps {
|
||||||
tabWidth?: number;
|
tabWidth?: number;
|
||||||
availableTerminalHeight?: number;
|
availableTerminalHeight?: number;
|
||||||
terminalWidth: number;
|
terminalWidth: number;
|
||||||
|
theme?: import('../../themes/theme.js').Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||||
|
@ -103,6 +104,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
tabWidth = DEFAULT_TAB_WIDTH,
|
tabWidth = DEFAULT_TAB_WIDTH,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
|
theme,
|
||||||
}) => {
|
}) => {
|
||||||
if (!diffContent || typeof diffContent !== 'string') {
|
if (!diffContent || typeof diffContent !== 'string') {
|
||||||
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
||||||
|
@ -146,6 +148,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
language,
|
language,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
renderedOutput = renderDiffContent(
|
renderedOutput = renderDiffContent(
|
||||||
|
@ -154,6 +157,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
tabWidth,
|
tabWidth,
|
||||||
availableTerminalHeight,
|
availableTerminalHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,6 +170,7 @@ const renderDiffContent = (
|
||||||
tabWidth = DEFAULT_TAB_WIDTH,
|
tabWidth = DEFAULT_TAB_WIDTH,
|
||||||
availableTerminalHeight: number | undefined,
|
availableTerminalHeight: number | undefined,
|
||||||
terminalWidth: number,
|
terminalWidth: number,
|
||||||
|
theme?: import('../../themes/theme.js').Theme,
|
||||||
) => {
|
) => {
|
||||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||||
const normalizedLines = parsedLines.map((line) => ({
|
const normalizedLines = parsedLines.map((line) => ({
|
||||||
|
@ -246,13 +251,13 @@ const renderDiffContent = (
|
||||||
switch (line.type) {
|
switch (line.type) {
|
||||||
case 'add':
|
case 'add':
|
||||||
gutterNumStr = (line.newLine ?? '').toString();
|
gutterNumStr = (line.newLine ?? '').toString();
|
||||||
color = 'green';
|
color = theme?.colors?.AccentGreen || 'green';
|
||||||
prefixSymbol = '+';
|
prefixSymbol = '+';
|
||||||
lastLineNumber = line.newLine ?? null;
|
lastLineNumber = line.newLine ?? null;
|
||||||
break;
|
break;
|
||||||
case 'del':
|
case 'del':
|
||||||
gutterNumStr = (line.oldLine ?? '').toString();
|
gutterNumStr = (line.oldLine ?? '').toString();
|
||||||
color = 'red';
|
color = theme?.colors?.AccentRed || 'red';
|
||||||
prefixSymbol = '-';
|
prefixSymbol = '-';
|
||||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||||
|
|
|
@ -56,6 +56,7 @@ export const useAuthCommand = (
|
||||||
async (authType: AuthType | undefined, scope: SettingScope) => {
|
async (authType: AuthType | undefined, scope: SettingScope) => {
|
||||||
if (authType) {
|
if (authType) {
|
||||||
await clearCachedCredentialFile();
|
await clearCachedCredentialFile();
|
||||||
|
|
||||||
settings.setValue(scope, 'selectedAuthType', authType);
|
settings.setValue(scope, 'selectedAuthType', authType);
|
||||||
if (
|
if (
|
||||||
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||||
|
|
|
@ -25,39 +25,18 @@ export const useThemeCommand = (
|
||||||
setThemeError: (error: string | null) => void,
|
setThemeError: (error: string | null) => void,
|
||||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||||
): UseThemeCommandReturn => {
|
): UseThemeCommandReturn => {
|
||||||
// Determine the effective theme
|
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
|
||||||
const effectiveTheme = loadedSettings.merged.theme;
|
|
||||||
|
|
||||||
// Initial state: Open dialog if no theme is set in either user or workspace settings
|
// Check for invalid theme configuration on startup
|
||||||
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(
|
|
||||||
effectiveTheme === undefined && !process.env.NO_COLOR,
|
|
||||||
);
|
|
||||||
// TODO: refactor how theme's are accessed to avoid requiring a forced render.
|
|
||||||
const [, setForceRender] = useState(0);
|
|
||||||
|
|
||||||
// Apply initial theme on component mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effectiveTheme === undefined) {
|
const effectiveTheme = loadedSettings.merged.theme;
|
||||||
if (process.env.NO_COLOR) {
|
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||||
addItem(
|
|
||||||
{
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: 'Theme configuration unavailable due to NO_COLOR env variable.',
|
|
||||||
},
|
|
||||||
Date.now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If no theme is set and NO_COLOR is not set, the dialog is already open.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!themeManager.setActiveTheme(effectiveTheme)) {
|
|
||||||
setIsThemeDialogOpen(true);
|
setIsThemeDialogOpen(true);
|
||||||
setThemeError(`Theme "${effectiveTheme}" not found.`);
|
setThemeError(`Theme "${effectiveTheme}" not found.`);
|
||||||
} else {
|
} else {
|
||||||
setThemeError(null);
|
setThemeError(null);
|
||||||
}
|
}
|
||||||
}, [effectiveTheme, setThemeError, addItem]); // Re-run if effectiveTheme or setThemeError changes
|
}, [loadedSettings.merged.theme, setThemeError]);
|
||||||
|
|
||||||
const openThemeDialog = useCallback(() => {
|
const openThemeDialog = useCallback(() => {
|
||||||
if (process.env.NO_COLOR) {
|
if (process.env.NO_COLOR) {
|
||||||
|
@ -80,11 +59,10 @@ export const useThemeCommand = (
|
||||||
setIsThemeDialogOpen(true);
|
setIsThemeDialogOpen(true);
|
||||||
setThemeError(`Theme "${themeName}" not found.`);
|
setThemeError(`Theme "${themeName}" not found.`);
|
||||||
} else {
|
} else {
|
||||||
setForceRender((v) => v + 1); // Trigger potential re-render
|
|
||||||
setThemeError(null); // Clear any previous theme error on success
|
setThemeError(null); // Clear any previous theme error on success
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setForceRender, setThemeError],
|
[setThemeError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleThemeHighlight = useCallback(
|
const handleThemeHighlight = useCallback(
|
||||||
|
@ -96,15 +74,31 @@ export const useThemeCommand = (
|
||||||
|
|
||||||
const handleThemeSelect = useCallback(
|
const handleThemeSelect = useCallback(
|
||||||
(themeName: string | undefined, scope: SettingScope) => {
|
(themeName: string | undefined, scope: SettingScope) => {
|
||||||
// Added scope parameter
|
|
||||||
try {
|
try {
|
||||||
|
// Merge user and workspace custom themes (workspace takes precedence)
|
||||||
|
const mergedCustomThemes = {
|
||||||
|
...(loadedSettings.user.settings.customThemes || {}),
|
||||||
|
...(loadedSettings.workspace.settings.customThemes || {}),
|
||||||
|
};
|
||||||
|
// Only allow selecting themes available in the merged custom themes or built-in themes
|
||||||
|
const isBuiltIn = themeManager.findThemeByName(themeName);
|
||||||
|
const isCustom = themeName && mergedCustomThemes[themeName];
|
||||||
|
if (!isBuiltIn && !isCustom) {
|
||||||
|
setThemeError(`Theme "${themeName}" not found in selected scope.`);
|
||||||
|
setIsThemeDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings
|
loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings
|
||||||
|
if (loadedSettings.merged.customThemes) {
|
||||||
|
themeManager.loadCustomThemes(loadedSettings.merged.customThemes);
|
||||||
|
}
|
||||||
applyTheme(loadedSettings.merged.theme); // Apply the current theme
|
applyTheme(loadedSettings.merged.theme); // Apply the current theme
|
||||||
|
setThemeError(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsThemeDialogOpen(false); // Close the dialog
|
setIsThemeDialogOpen(false); // Close the dialog
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyTheme, loadedSettings],
|
[applyTheme, loadedSettings, setThemeError],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidColor,
|
||||||
|
resolveColor,
|
||||||
|
CSS_NAME_TO_HEX_MAP,
|
||||||
|
INK_SUPPORTED_NAMES,
|
||||||
|
} from './color-utils.js';
|
||||||
|
|
||||||
|
describe('Color Utils', () => {
|
||||||
|
describe('isValidColor', () => {
|
||||||
|
it('should validate hex colors', () => {
|
||||||
|
expect(isValidColor('#ff0000')).toBe(true);
|
||||||
|
expect(isValidColor('#00ff00')).toBe(true);
|
||||||
|
expect(isValidColor('#0000ff')).toBe(true);
|
||||||
|
expect(isValidColor('#fff')).toBe(true);
|
||||||
|
expect(isValidColor('#000')).toBe(true);
|
||||||
|
expect(isValidColor('#FF0000')).toBe(true); // Case insensitive
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate Ink-supported color names', () => {
|
||||||
|
expect(isValidColor('black')).toBe(true);
|
||||||
|
expect(isValidColor('red')).toBe(true);
|
||||||
|
expect(isValidColor('green')).toBe(true);
|
||||||
|
expect(isValidColor('yellow')).toBe(true);
|
||||||
|
expect(isValidColor('blue')).toBe(true);
|
||||||
|
expect(isValidColor('cyan')).toBe(true);
|
||||||
|
expect(isValidColor('magenta')).toBe(true);
|
||||||
|
expect(isValidColor('white')).toBe(true);
|
||||||
|
expect(isValidColor('gray')).toBe(true);
|
||||||
|
expect(isValidColor('grey')).toBe(true);
|
||||||
|
expect(isValidColor('blackbright')).toBe(true);
|
||||||
|
expect(isValidColor('redbright')).toBe(true);
|
||||||
|
expect(isValidColor('greenbright')).toBe(true);
|
||||||
|
expect(isValidColor('yellowbright')).toBe(true);
|
||||||
|
expect(isValidColor('bluebright')).toBe(true);
|
||||||
|
expect(isValidColor('cyanbright')).toBe(true);
|
||||||
|
expect(isValidColor('magentabright')).toBe(true);
|
||||||
|
expect(isValidColor('whitebright')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate Ink-supported color names case insensitive', () => {
|
||||||
|
expect(isValidColor('BLACK')).toBe(true);
|
||||||
|
expect(isValidColor('Red')).toBe(true);
|
||||||
|
expect(isValidColor('GREEN')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate CSS color names', () => {
|
||||||
|
expect(isValidColor('darkkhaki')).toBe(true);
|
||||||
|
expect(isValidColor('coral')).toBe(true);
|
||||||
|
expect(isValidColor('teal')).toBe(true);
|
||||||
|
expect(isValidColor('tomato')).toBe(true);
|
||||||
|
expect(isValidColor('turquoise')).toBe(true);
|
||||||
|
expect(isValidColor('violet')).toBe(true);
|
||||||
|
expect(isValidColor('wheat')).toBe(true);
|
||||||
|
expect(isValidColor('whitesmoke')).toBe(true);
|
||||||
|
expect(isValidColor('yellowgreen')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate CSS color names case insensitive', () => {
|
||||||
|
expect(isValidColor('DARKKHAKI')).toBe(true);
|
||||||
|
expect(isValidColor('Coral')).toBe(true);
|
||||||
|
expect(isValidColor('TEAL')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid color names', () => {
|
||||||
|
expect(isValidColor('invalidcolor')).toBe(false);
|
||||||
|
expect(isValidColor('notacolor')).toBe(false);
|
||||||
|
expect(isValidColor('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveColor', () => {
|
||||||
|
it('should resolve hex colors', () => {
|
||||||
|
expect(resolveColor('#ff0000')).toBe('#ff0000');
|
||||||
|
expect(resolveColor('#00ff00')).toBe('#00ff00');
|
||||||
|
expect(resolveColor('#0000ff')).toBe('#0000ff');
|
||||||
|
expect(resolveColor('#fff')).toBe('#fff');
|
||||||
|
expect(resolveColor('#000')).toBe('#000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve Ink-supported color names', () => {
|
||||||
|
expect(resolveColor('black')).toBe('black');
|
||||||
|
expect(resolveColor('red')).toBe('red');
|
||||||
|
expect(resolveColor('green')).toBe('green');
|
||||||
|
expect(resolveColor('yellow')).toBe('yellow');
|
||||||
|
expect(resolveColor('blue')).toBe('blue');
|
||||||
|
expect(resolveColor('cyan')).toBe('cyan');
|
||||||
|
expect(resolveColor('magenta')).toBe('magenta');
|
||||||
|
expect(resolveColor('white')).toBe('white');
|
||||||
|
expect(resolveColor('gray')).toBe('gray');
|
||||||
|
expect(resolveColor('grey')).toBe('grey');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve CSS color names to hex', () => {
|
||||||
|
expect(resolveColor('darkkhaki')).toBe('#bdb76b');
|
||||||
|
expect(resolveColor('coral')).toBe('#ff7f50');
|
||||||
|
expect(resolveColor('teal')).toBe('#008080');
|
||||||
|
expect(resolveColor('tomato')).toBe('#ff6347');
|
||||||
|
expect(resolveColor('turquoise')).toBe('#40e0d0');
|
||||||
|
expect(resolveColor('violet')).toBe('#ee82ee');
|
||||||
|
expect(resolveColor('wheat')).toBe('#f5deb3');
|
||||||
|
expect(resolveColor('whitesmoke')).toBe('#f5f5f5');
|
||||||
|
expect(resolveColor('yellowgreen')).toBe('#9acd32');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case insensitive color names', () => {
|
||||||
|
expect(resolveColor('DARKKHAKI')).toBe('#bdb76b');
|
||||||
|
expect(resolveColor('Coral')).toBe('#ff7f50');
|
||||||
|
expect(resolveColor('TEAL')).toBe('#008080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid colors', () => {
|
||||||
|
expect(resolveColor('invalidcolor')).toBeUndefined();
|
||||||
|
expect(resolveColor('notacolor')).toBeUndefined();
|
||||||
|
expect(resolveColor('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSS_NAME_TO_HEX_MAP', () => {
|
||||||
|
it('should contain expected CSS color mappings', () => {
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.darkkhaki).toBe('#bdb76b');
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.coral).toBe('#ff7f50');
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.teal).toBe('#008080');
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.tomato).toBe('#ff6347');
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.turquoise).toBe('#40e0d0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not contain Ink-supported color names', () => {
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.black).toBeUndefined();
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.red).toBeUndefined();
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.green).toBeUndefined();
|
||||||
|
expect(CSS_NAME_TO_HEX_MAP.blue).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('INK_SUPPORTED_NAMES', () => {
|
||||||
|
it('should contain all Ink-supported color names', () => {
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('black')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('red')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('green')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('yellow')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('blue')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('cyan')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('magenta')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('white')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('gray')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('grey')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('blackbright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('redbright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('greenbright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('yellowbright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('bluebright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('cyanbright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('magentabright')).toBe(true);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('whitebright')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not contain CSS color names', () => {
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('darkkhaki')).toBe(false);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('coral')).toBe(false);
|
||||||
|
expect(INK_SUPPORTED_NAMES.has('teal')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Consistency between validation and resolution', () => {
|
||||||
|
it('should have consistent behavior between isValidColor and resolveColor', () => {
|
||||||
|
// Test that any color that isValidColor returns true for can be resolved
|
||||||
|
const testColors = [
|
||||||
|
'#ff0000',
|
||||||
|
'#00ff00',
|
||||||
|
'#0000ff',
|
||||||
|
'#fff',
|
||||||
|
'#000',
|
||||||
|
'black',
|
||||||
|
'red',
|
||||||
|
'green',
|
||||||
|
'yellow',
|
||||||
|
'blue',
|
||||||
|
'cyan',
|
||||||
|
'magenta',
|
||||||
|
'white',
|
||||||
|
'gray',
|
||||||
|
'grey',
|
||||||
|
'darkkhaki',
|
||||||
|
'coral',
|
||||||
|
'teal',
|
||||||
|
'tomato',
|
||||||
|
'turquoise',
|
||||||
|
'violet',
|
||||||
|
'wheat',
|
||||||
|
'whitesmoke',
|
||||||
|
'yellowgreen',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const color of testColors) {
|
||||||
|
expect(isValidColor(color)).toBe(true);
|
||||||
|
expect(resolveColor(color)).toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that invalid colors are consistently rejected
|
||||||
|
const invalidColors = [
|
||||||
|
'invalidcolor',
|
||||||
|
'notacolor',
|
||||||
|
'',
|
||||||
|
'#gg0000',
|
||||||
|
'#ff00',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const color of invalidColors) {
|
||||||
|
expect(isValidColor(color)).toBe(false);
|
||||||
|
expect(resolveColor(color)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,231 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
|
||||||
|
// Excludes names directly supported by Ink
|
||||||
|
export const CSS_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
|
||||||
|
aliceblue: '#f0f8ff',
|
||||||
|
antiquewhite: '#faebd7',
|
||||||
|
aqua: '#00ffff',
|
||||||
|
aquamarine: '#7fffd4',
|
||||||
|
azure: '#f0ffff',
|
||||||
|
beige: '#f5f5dc',
|
||||||
|
bisque: '#ffe4c4',
|
||||||
|
blanchedalmond: '#ffebcd',
|
||||||
|
blueviolet: '#8a2be2',
|
||||||
|
brown: '#a52a2a',
|
||||||
|
burlywood: '#deb887',
|
||||||
|
cadetblue: '#5f9ea0',
|
||||||
|
chartreuse: '#7fff00',
|
||||||
|
chocolate: '#d2691e',
|
||||||
|
coral: '#ff7f50',
|
||||||
|
cornflowerblue: '#6495ed',
|
||||||
|
cornsilk: '#fff8dc',
|
||||||
|
crimson: '#dc143c',
|
||||||
|
darkblue: '#00008b',
|
||||||
|
darkcyan: '#008b8b',
|
||||||
|
darkgoldenrod: '#b8860b',
|
||||||
|
darkgray: '#a9a9a9',
|
||||||
|
darkgrey: '#a9a9a9',
|
||||||
|
darkgreen: '#006400',
|
||||||
|
darkkhaki: '#bdb76b',
|
||||||
|
darkmagenta: '#8b008b',
|
||||||
|
darkolivegreen: '#556b2f',
|
||||||
|
darkorange: '#ff8c00',
|
||||||
|
darkorchid: '#9932cc',
|
||||||
|
darkred: '#8b0000',
|
||||||
|
darksalmon: '#e9967a',
|
||||||
|
darkseagreen: '#8fbc8f',
|
||||||
|
darkslateblue: '#483d8b',
|
||||||
|
darkslategray: '#2f4f4f',
|
||||||
|
darkslategrey: '#2f4f4f',
|
||||||
|
darkturquoise: '#00ced1',
|
||||||
|
darkviolet: '#9400d3',
|
||||||
|
deeppink: '#ff1493',
|
||||||
|
deepskyblue: '#00bfff',
|
||||||
|
dimgray: '#696969',
|
||||||
|
dimgrey: '#696969',
|
||||||
|
dodgerblue: '#1e90ff',
|
||||||
|
firebrick: '#b22222',
|
||||||
|
floralwhite: '#fffaf0',
|
||||||
|
forestgreen: '#228b22',
|
||||||
|
fuchsia: '#ff00ff',
|
||||||
|
gainsboro: '#dcdcdc',
|
||||||
|
ghostwhite: '#f8f8ff',
|
||||||
|
gold: '#ffd700',
|
||||||
|
goldenrod: '#daa520',
|
||||||
|
greenyellow: '#adff2f',
|
||||||
|
honeydew: '#f0fff0',
|
||||||
|
hotpink: '#ff69b4',
|
||||||
|
indianred: '#cd5c5c',
|
||||||
|
indigo: '#4b0082',
|
||||||
|
ivory: '#fffff0',
|
||||||
|
khaki: '#f0e68c',
|
||||||
|
lavender: '#e6e6fa',
|
||||||
|
lavenderblush: '#fff0f5',
|
||||||
|
lawngreen: '#7cfc00',
|
||||||
|
lemonchiffon: '#fffacd',
|
||||||
|
lightblue: '#add8e6',
|
||||||
|
lightcoral: '#f08080',
|
||||||
|
lightcyan: '#e0ffff',
|
||||||
|
lightgoldenrodyellow: '#fafad2',
|
||||||
|
lightgray: '#d3d3d3',
|
||||||
|
lightgrey: '#d3d3d3',
|
||||||
|
lightgreen: '#90ee90',
|
||||||
|
lightpink: '#ffb6c1',
|
||||||
|
lightsalmon: '#ffa07a',
|
||||||
|
lightseagreen: '#20b2aa',
|
||||||
|
lightskyblue: '#87cefa',
|
||||||
|
lightslategray: '#778899',
|
||||||
|
lightslategrey: '#778899',
|
||||||
|
lightsteelblue: '#b0c4de',
|
||||||
|
lightyellow: '#ffffe0',
|
||||||
|
lime: '#00ff00',
|
||||||
|
limegreen: '#32cd32',
|
||||||
|
linen: '#faf0e6',
|
||||||
|
maroon: '#800000',
|
||||||
|
mediumaquamarine: '#66cdaa',
|
||||||
|
mediumblue: '#0000cd',
|
||||||
|
mediumorchid: '#ba55d3',
|
||||||
|
mediumpurple: '#9370db',
|
||||||
|
mediumseagreen: '#3cb371',
|
||||||
|
mediumslateblue: '#7b68ee',
|
||||||
|
mediumspringgreen: '#00fa9a',
|
||||||
|
mediumturquoise: '#48d1cc',
|
||||||
|
mediumvioletred: '#c71585',
|
||||||
|
midnightblue: '#191970',
|
||||||
|
mintcream: '#f5fffa',
|
||||||
|
mistyrose: '#ffe4e1',
|
||||||
|
moccasin: '#ffe4b5',
|
||||||
|
navajowhite: '#ffdead',
|
||||||
|
navy: '#000080',
|
||||||
|
oldlace: '#fdf5e6',
|
||||||
|
olive: '#808000',
|
||||||
|
olivedrab: '#6b8e23',
|
||||||
|
orange: '#ffa500',
|
||||||
|
orangered: '#ff4500',
|
||||||
|
orchid: '#da70d6',
|
||||||
|
palegoldenrod: '#eee8aa',
|
||||||
|
palegreen: '#98fb98',
|
||||||
|
paleturquoise: '#afeeee',
|
||||||
|
palevioletred: '#db7093',
|
||||||
|
papayawhip: '#ffefd5',
|
||||||
|
peachpuff: '#ffdab9',
|
||||||
|
peru: '#cd853f',
|
||||||
|
pink: '#ffc0cb',
|
||||||
|
plum: '#dda0dd',
|
||||||
|
powderblue: '#b0e0e6',
|
||||||
|
purple: '#800080',
|
||||||
|
rebeccapurple: '#663399',
|
||||||
|
rosybrown: '#bc8f8f',
|
||||||
|
royalblue: '#4169e1',
|
||||||
|
saddlebrown: '#8b4513',
|
||||||
|
salmon: '#fa8072',
|
||||||
|
sandybrown: '#f4a460',
|
||||||
|
seagreen: '#2e8b57',
|
||||||
|
seashell: '#fff5ee',
|
||||||
|
sienna: '#a0522d',
|
||||||
|
silver: '#c0c0c0',
|
||||||
|
skyblue: '#87ceeb',
|
||||||
|
slateblue: '#6a5acd',
|
||||||
|
slategray: '#708090',
|
||||||
|
slategrey: '#708090',
|
||||||
|
snow: '#fffafa',
|
||||||
|
springgreen: '#00ff7f',
|
||||||
|
steelblue: '#4682b4',
|
||||||
|
tan: '#d2b48c',
|
||||||
|
teal: '#008080',
|
||||||
|
thistle: '#d8bfd8',
|
||||||
|
tomato: '#ff6347',
|
||||||
|
turquoise: '#40e0d0',
|
||||||
|
violet: '#ee82ee',
|
||||||
|
wheat: '#f5deb3',
|
||||||
|
whitesmoke: '#f5f5f5',
|
||||||
|
yellowgreen: '#9acd32',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the set of Ink's named colors for quick lookup
|
||||||
|
export const INK_SUPPORTED_NAMES = new Set([
|
||||||
|
'black',
|
||||||
|
'red',
|
||||||
|
'green',
|
||||||
|
'yellow',
|
||||||
|
'blue',
|
||||||
|
'cyan',
|
||||||
|
'magenta',
|
||||||
|
'white',
|
||||||
|
'gray',
|
||||||
|
'grey',
|
||||||
|
'blackbright',
|
||||||
|
'redbright',
|
||||||
|
'greenbright',
|
||||||
|
'yellowbright',
|
||||||
|
'bluebright',
|
||||||
|
'cyanbright',
|
||||||
|
'magentabright',
|
||||||
|
'whitebright',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
|
||||||
|
* This function uses the same validation logic as the Theme class's _resolveColor method
|
||||||
|
* to ensure consistency between validation and resolution.
|
||||||
|
* @param color The color string to validate.
|
||||||
|
* @returns True if the color is valid.
|
||||||
|
*/
|
||||||
|
export function isValidColor(color: string): boolean {
|
||||||
|
const lowerColor = color.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Check if it's a hex code
|
||||||
|
if (lowerColor.startsWith('#')) {
|
||||||
|
return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if it's an Ink supported name
|
||||||
|
if (INK_SUPPORTED_NAMES.has(lowerColor)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if it's a known CSS name we can map to hex
|
||||||
|
if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Not a valid color
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a CSS color value (name or hex) into an Ink-compatible color string.
|
||||||
|
* @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
|
||||||
|
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
|
||||||
|
*/
|
||||||
|
export function resolveColor(colorValue: string): string | undefined {
|
||||||
|
const lowerColor = colorValue.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Check if it's already a hex code and valid
|
||||||
|
if (lowerColor.startsWith('#')) {
|
||||||
|
if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
|
||||||
|
return lowerColor;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Check if it's an Ink supported name (lowercase)
|
||||||
|
else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
|
||||||
|
return lowerColor; // Use Ink name directly
|
||||||
|
}
|
||||||
|
// 3. Check if it's a known CSS name we can map to hex
|
||||||
|
else if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
|
||||||
|
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Could not resolve
|
||||||
|
console.warn(
|
||||||
|
`[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ const noColorColorsTheme: ColorsTheme = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoColorTheme: Theme = new Theme(
|
export const NoColorTheme: Theme = new Theme(
|
||||||
'No Color',
|
'NoColor',
|
||||||
'dark',
|
'dark',
|
||||||
{
|
{
|
||||||
hljs: {
|
hljs: {
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Patch: Unset NO_COLOR at the very top before any imports
|
||||||
|
if (process.env.NO_COLOR !== undefined) {
|
||||||
|
delete process.env.NO_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
|
||||||
|
import { CustomTheme } from './theme.js';
|
||||||
|
|
||||||
|
const validCustomTheme: CustomTheme = {
|
||||||
|
type: 'custom',
|
||||||
|
name: 'MyCustomTheme',
|
||||||
|
Background: '#000000',
|
||||||
|
Foreground: '#ffffff',
|
||||||
|
LightBlue: '#89BDCD',
|
||||||
|
AccentBlue: '#3B82F6',
|
||||||
|
AccentPurple: '#8B5CF6',
|
||||||
|
AccentCyan: '#06B6D4',
|
||||||
|
AccentGreen: '#3CA84B',
|
||||||
|
AccentYellow: '#D5A40A',
|
||||||
|
AccentRed: '#DD4C4C',
|
||||||
|
Comment: '#008000',
|
||||||
|
Gray: '#B7BECC',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ThemeManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset themeManager state
|
||||||
|
themeManager.loadCustomThemes({});
|
||||||
|
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load valid custom themes', () => {
|
||||||
|
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||||
|
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
|
||||||
|
expect(themeManager.isCustomTheme('MyCustomTheme')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not load invalid custom themes', () => {
|
||||||
|
const invalidTheme = { ...validCustomTheme, Background: 'not-a-color' };
|
||||||
|
themeManager.loadCustomThemes({
|
||||||
|
InvalidTheme: invalidTheme as unknown as CustomTheme,
|
||||||
|
});
|
||||||
|
expect(themeManager.getCustomThemeNames()).not.toContain('InvalidTheme');
|
||||||
|
expect(themeManager.isCustomTheme('InvalidTheme')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get the active theme', () => {
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
themeManager.setActiveTheme('Ayu');
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe('Ayu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get a custom active theme', () => {
|
||||||
|
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||||
|
themeManager.setActiveTheme('MyCustomTheme');
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe('MyCustomTheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when setting a non-existent theme', () => {
|
||||||
|
expect(themeManager.setActiveTheme('NonExistentTheme')).toBe(false);
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list available themes including custom themes', () => {
|
||||||
|
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||||
|
const available = themeManager.getAvailableThemes();
|
||||||
|
expect(
|
||||||
|
available.some(
|
||||||
|
(t: { name: string; isCustom?: boolean }) =>
|
||||||
|
t.name === 'MyCustomTheme' && t.isCustom,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a theme by name', () => {
|
||||||
|
expect(themeManager.getTheme('Ayu')).toBeDefined();
|
||||||
|
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||||
|
expect(themeManager.getTheme('MyCustomTheme')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to default theme if active theme is invalid', () => {
|
||||||
|
(themeManager as unknown as { activeTheme: unknown }).activeTheme = {
|
||||||
|
name: 'NonExistent',
|
||||||
|
type: 'custom',
|
||||||
|
};
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NoColorTheme if NO_COLOR is set', () => {
|
||||||
|
const original = process.env.NO_COLOR;
|
||||||
|
process.env.NO_COLOR = '1';
|
||||||
|
expect(themeManager.getActiveTheme().name).toBe('NoColor');
|
||||||
|
if (original === undefined) {
|
||||||
|
delete process.env.NO_COLOR;
|
||||||
|
} else {
|
||||||
|
process.env.NO_COLOR = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -15,7 +15,13 @@ import { DefaultLight } from './default-light.js';
|
||||||
import { DefaultDark } from './default.js';
|
import { DefaultDark } from './default.js';
|
||||||
import { ShadesOfPurple } from './shades-of-purple.js';
|
import { ShadesOfPurple } from './shades-of-purple.js';
|
||||||
import { XCode } from './xcode.js';
|
import { XCode } from './xcode.js';
|
||||||
import { Theme, ThemeType } from './theme.js';
|
import {
|
||||||
|
Theme,
|
||||||
|
ThemeType,
|
||||||
|
CustomTheme,
|
||||||
|
createCustomTheme,
|
||||||
|
validateCustomTheme,
|
||||||
|
} from './theme.js';
|
||||||
import { ANSI } from './ansi.js';
|
import { ANSI } from './ansi.js';
|
||||||
import { ANSILight } from './ansi-light.js';
|
import { ANSILight } from './ansi-light.js';
|
||||||
import { NoColorTheme } from './no-color.js';
|
import { NoColorTheme } from './no-color.js';
|
||||||
|
@ -24,6 +30,7 @@ import process from 'node:process';
|
||||||
export interface ThemeDisplay {
|
export interface ThemeDisplay {
|
||||||
name: string;
|
name: string;
|
||||||
type: ThemeType;
|
type: ThemeType;
|
||||||
|
isCustom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_THEME: Theme = DefaultDark;
|
export const DEFAULT_THEME: Theme = DefaultDark;
|
||||||
|
@ -31,6 +38,7 @@ export const DEFAULT_THEME: Theme = DefaultDark;
|
||||||
class ThemeManager {
|
class ThemeManager {
|
||||||
private readonly availableThemes: Theme[];
|
private readonly availableThemes: Theme[];
|
||||||
private activeTheme: Theme;
|
private activeTheme: Theme;
|
||||||
|
private customThemes: Map<string, Theme> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.availableThemes = [
|
this.availableThemes = [
|
||||||
|
@ -51,19 +59,121 @@ class ThemeManager {
|
||||||
this.activeTheme = DEFAULT_THEME;
|
this.activeTheme = DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads custom themes from settings.
|
||||||
|
* @param customThemesSettings Custom themes from settings.
|
||||||
|
*/
|
||||||
|
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
|
||||||
|
this.customThemes.clear();
|
||||||
|
|
||||||
|
if (!customThemesSettings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, customThemeConfig] of Object.entries(
|
||||||
|
customThemesSettings,
|
||||||
|
)) {
|
||||||
|
const validation = validateCustomTheme(customThemeConfig);
|
||||||
|
if (validation.isValid) {
|
||||||
|
try {
|
||||||
|
const theme = createCustomTheme(customThemeConfig);
|
||||||
|
this.customThemes.set(name, theme);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load custom theme "${name}":`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Invalid custom theme "${name}": ${validation.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the current active theme is a custom theme, keep it if still valid
|
||||||
|
if (
|
||||||
|
this.activeTheme &&
|
||||||
|
this.activeTheme.type === 'custom' &&
|
||||||
|
this.customThemes.has(this.activeTheme.name)
|
||||||
|
) {
|
||||||
|
this.activeTheme = this.customThemes.get(this.activeTheme.name)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the active theme.
|
||||||
|
* @param themeName The name of the theme to set as active.
|
||||||
|
* @returns True if the theme was successfully set, false otherwise.
|
||||||
|
*/
|
||||||
|
setActiveTheme(themeName: string | undefined): boolean {
|
||||||
|
const theme = this.findThemeByName(themeName);
|
||||||
|
if (!theme) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.activeTheme = theme;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the currently active theme.
|
||||||
|
* @returns The active theme.
|
||||||
|
*/
|
||||||
|
getActiveTheme(): Theme {
|
||||||
|
if (process.env.NO_COLOR) {
|
||||||
|
return NoColorTheme;
|
||||||
|
}
|
||||||
|
// Ensure the active theme is always valid (fallback to default if not)
|
||||||
|
if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
|
||||||
|
this.activeTheme = DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
return this.activeTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a list of custom theme names.
|
||||||
|
* @returns Array of custom theme names.
|
||||||
|
*/
|
||||||
|
getCustomThemeNames(): string[] {
|
||||||
|
return Array.from(this.customThemes.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a theme name is a custom theme.
|
||||||
|
* @param themeName The theme name to check.
|
||||||
|
* @returns True if the theme is custom.
|
||||||
|
*/
|
||||||
|
isCustomTheme(themeName: string): boolean {
|
||||||
|
return this.customThemes.has(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of available theme names.
|
* Returns a list of available theme names.
|
||||||
*/
|
*/
|
||||||
getAvailableThemes(): ThemeDisplay[] {
|
getAvailableThemes(): ThemeDisplay[] {
|
||||||
const sortedThemes = [...this.availableThemes].sort((a, b) => {
|
const builtInThemes = this.availableThemes.map((theme) => ({
|
||||||
|
name: theme.name,
|
||||||
|
type: theme.type,
|
||||||
|
isCustom: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const customThemes = Array.from(this.customThemes.values()).map(
|
||||||
|
(theme) => ({
|
||||||
|
name: theme.name,
|
||||||
|
type: theme.type,
|
||||||
|
isCustom: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allThemes = [...builtInThemes, ...customThemes];
|
||||||
|
|
||||||
|
const sortedThemes = allThemes.sort((a, b) => {
|
||||||
const typeOrder = (type: ThemeType): number => {
|
const typeOrder = (type: ThemeType): number => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'dark':
|
case 'dark':
|
||||||
return 1;
|
return 1;
|
||||||
case 'light':
|
case 'light':
|
||||||
return 2;
|
return 2;
|
||||||
default:
|
case 'ansi':
|
||||||
return 3;
|
return 3;
|
||||||
|
case 'custom':
|
||||||
|
return 4; // Custom themes at the end
|
||||||
|
default:
|
||||||
|
return 5;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -74,50 +184,33 @@ class ThemeManager {
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
return sortedThemes.map((theme) => ({
|
return sortedThemes;
|
||||||
name: theme.name,
|
|
||||||
type: theme.type,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the active theme.
|
* Gets a theme by name.
|
||||||
* @param themeName The name of the theme to activate.
|
* @param themeName The name of the theme to get.
|
||||||
* @returns True if the theme was successfully set, false otherwise.
|
* @returns The theme if found, undefined otherwise.
|
||||||
*/
|
*/
|
||||||
setActiveTheme(themeName: string | undefined): boolean {
|
getTheme(themeName: string): Theme | undefined {
|
||||||
const foundTheme = this.findThemeByName(themeName);
|
return this.findThemeByName(themeName);
|
||||||
|
|
||||||
if (foundTheme) {
|
|
||||||
this.activeTheme = foundTheme;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// If themeName is undefined, it means we want to set the default theme.
|
|
||||||
// If findThemeByName returns undefined (e.g. default theme is also not found for some reason)
|
|
||||||
// then this will return false.
|
|
||||||
if (themeName === undefined) {
|
|
||||||
this.activeTheme = DEFAULT_THEME;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findThemeByName(themeName: string | undefined): Theme | undefined {
|
findThemeByName(themeName: string | undefined): Theme | undefined {
|
||||||
if (!themeName) {
|
if (!themeName) {
|
||||||
return DEFAULT_THEME;
|
return DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
return this.availableThemes.find((theme) => theme.name === themeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// First check built-in themes
|
||||||
* Returns the currently active theme object.
|
const builtInTheme = this.availableThemes.find(
|
||||||
*/
|
(theme) => theme.name === themeName,
|
||||||
getActiveTheme(): Theme {
|
);
|
||||||
if (process.env.NO_COLOR) {
|
if (builtInTheme) {
|
||||||
return NoColorTheme;
|
return builtInTheme;
|
||||||
}
|
}
|
||||||
return this.activeTheme;
|
|
||||||
|
// Then check custom themes
|
||||||
|
return this.customThemes.get(themeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
import { isValidColor, resolveColor } from './color-utils.js';
|
||||||
|
|
||||||
export type ThemeType = 'light' | 'dark' | 'ansi';
|
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
||||||
|
|
||||||
export interface ColorsTheme {
|
export interface ColorsTheme {
|
||||||
type: ThemeType;
|
type: ThemeType;
|
||||||
|
@ -24,6 +25,11 @@ export interface ColorsTheme {
|
||||||
GradientColors?: string[];
|
GradientColors?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomTheme extends ColorsTheme {
|
||||||
|
type: 'custom';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const lightTheme: ColorsTheme = {
|
export const lightTheme: ColorsTheme = {
|
||||||
type: 'light',
|
type: 'light',
|
||||||
Background: '#FAFAFA',
|
Background: '#FAFAFA',
|
||||||
|
@ -83,173 +89,6 @@ export class Theme {
|
||||||
*/
|
*/
|
||||||
protected readonly _colorMap: Readonly<Record<string, string>>;
|
protected readonly _colorMap: Readonly<Record<string, string>>;
|
||||||
|
|
||||||
// --- Static Helper Data ---
|
|
||||||
|
|
||||||
// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
|
|
||||||
// Excludes names directly supported by Ink
|
|
||||||
private static readonly cssNameToHexMap: Readonly<Record<string, string>> = {
|
|
||||||
aliceblue: '#f0f8ff',
|
|
||||||
antiquewhite: '#faebd7',
|
|
||||||
aqua: '#00ffff',
|
|
||||||
aquamarine: '#7fffd4',
|
|
||||||
azure: '#f0ffff',
|
|
||||||
beige: '#f5f5dc',
|
|
||||||
bisque: '#ffe4c4',
|
|
||||||
blanchedalmond: '#ffebcd',
|
|
||||||
blueviolet: '#8a2be2',
|
|
||||||
brown: '#a52a2a',
|
|
||||||
burlywood: '#deb887',
|
|
||||||
cadetblue: '#5f9ea0',
|
|
||||||
chartreuse: '#7fff00',
|
|
||||||
chocolate: '#d2691e',
|
|
||||||
coral: '#ff7f50',
|
|
||||||
cornflowerblue: '#6495ed',
|
|
||||||
cornsilk: '#fff8dc',
|
|
||||||
crimson: '#dc143c',
|
|
||||||
darkblue: '#00008b',
|
|
||||||
darkcyan: '#008b8b',
|
|
||||||
darkgoldenrod: '#b8860b',
|
|
||||||
darkgray: '#a9a9a9',
|
|
||||||
darkgrey: '#a9a9a9',
|
|
||||||
darkgreen: '#006400',
|
|
||||||
darkkhaki: '#bdb76b',
|
|
||||||
darkmagenta: '#8b008b',
|
|
||||||
darkolivegreen: '#556b2f',
|
|
||||||
darkorange: '#ff8c00',
|
|
||||||
darkorchid: '#9932cc',
|
|
||||||
darkred: '#8b0000',
|
|
||||||
darksalmon: '#e9967a',
|
|
||||||
darkseagreen: '#8fbc8f',
|
|
||||||
darkslateblue: '#483d8b',
|
|
||||||
darkslategray: '#2f4f4f',
|
|
||||||
darkslategrey: '#2f4f4f',
|
|
||||||
darkturquoise: '#00ced1',
|
|
||||||
darkviolet: '#9400d3',
|
|
||||||
deeppink: '#ff1493',
|
|
||||||
deepskyblue: '#00bfff',
|
|
||||||
dimgray: '#696969',
|
|
||||||
dimgrey: '#696969',
|
|
||||||
dodgerblue: '#1e90ff',
|
|
||||||
firebrick: '#b22222',
|
|
||||||
floralwhite: '#fffaf0',
|
|
||||||
forestgreen: '#228b22',
|
|
||||||
fuchsia: '#ff00ff',
|
|
||||||
gainsboro: '#dcdcdc',
|
|
||||||
ghostwhite: '#f8f8ff',
|
|
||||||
gold: '#ffd700',
|
|
||||||
goldenrod: '#daa520',
|
|
||||||
greenyellow: '#adff2f',
|
|
||||||
honeydew: '#f0fff0',
|
|
||||||
hotpink: '#ff69b4',
|
|
||||||
indianred: '#cd5c5c',
|
|
||||||
indigo: '#4b0082',
|
|
||||||
ivory: '#fffff0',
|
|
||||||
khaki: '#f0e68c',
|
|
||||||
lavender: '#e6e6fa',
|
|
||||||
lavenderblush: '#fff0f5',
|
|
||||||
lawngreen: '#7cfc00',
|
|
||||||
lemonchiffon: '#fffacd',
|
|
||||||
lightblue: '#add8e6',
|
|
||||||
lightcoral: '#f08080',
|
|
||||||
lightcyan: '#e0ffff',
|
|
||||||
lightgoldenrodyellow: '#fafad2',
|
|
||||||
lightgray: '#d3d3d3',
|
|
||||||
lightgrey: '#d3d3d3',
|
|
||||||
lightgreen: '#90ee90',
|
|
||||||
lightpink: '#ffb6c1',
|
|
||||||
lightsalmon: '#ffa07a',
|
|
||||||
lightseagreen: '#20b2aa',
|
|
||||||
lightskyblue: '#87cefa',
|
|
||||||
lightslategray: '#778899',
|
|
||||||
lightslategrey: '#778899',
|
|
||||||
lightsteelblue: '#b0c4de',
|
|
||||||
lightyellow: '#ffffe0',
|
|
||||||
lime: '#00ff00',
|
|
||||||
limegreen: '#32cd32',
|
|
||||||
linen: '#faf0e6',
|
|
||||||
maroon: '#800000',
|
|
||||||
mediumaquamarine: '#66cdaa',
|
|
||||||
mediumblue: '#0000cd',
|
|
||||||
mediumorchid: '#ba55d3',
|
|
||||||
mediumpurple: '#9370db',
|
|
||||||
mediumseagreen: '#3cb371',
|
|
||||||
mediumslateblue: '#7b68ee',
|
|
||||||
mediumspringgreen: '#00fa9a',
|
|
||||||
mediumturquoise: '#48d1cc',
|
|
||||||
mediumvioletred: '#c71585',
|
|
||||||
midnightblue: '#191970',
|
|
||||||
mintcream: '#f5fffa',
|
|
||||||
mistyrose: '#ffe4e1',
|
|
||||||
moccasin: '#ffe4b5',
|
|
||||||
navajowhite: '#ffdead',
|
|
||||||
navy: '#000080',
|
|
||||||
oldlace: '#fdf5e6',
|
|
||||||
olive: '#808000',
|
|
||||||
olivedrab: '#6b8e23',
|
|
||||||
orange: '#ffa500',
|
|
||||||
orangered: '#ff4500',
|
|
||||||
orchid: '#da70d6',
|
|
||||||
palegoldenrod: '#eee8aa',
|
|
||||||
palegreen: '#98fb98',
|
|
||||||
paleturquoise: '#afeeee',
|
|
||||||
palevioletred: '#db7093',
|
|
||||||
papayawhip: '#ffefd5',
|
|
||||||
peachpuff: '#ffdab9',
|
|
||||||
peru: '#cd853f',
|
|
||||||
pink: '#ffc0cb',
|
|
||||||
plum: '#dda0dd',
|
|
||||||
powderblue: '#b0e0e6',
|
|
||||||
purple: '#800080',
|
|
||||||
rebeccapurple: '#663399',
|
|
||||||
rosybrown: '#bc8f8f',
|
|
||||||
royalblue: '#4169e1',
|
|
||||||
saddlebrown: '#8b4513',
|
|
||||||
salmon: '#fa8072',
|
|
||||||
sandybrown: '#f4a460',
|
|
||||||
seagreen: '#2e8b57',
|
|
||||||
seashell: '#fff5ee',
|
|
||||||
sienna: '#a0522d',
|
|
||||||
silver: '#c0c0c0',
|
|
||||||
skyblue: '#87ceeb',
|
|
||||||
slateblue: '#6a5acd',
|
|
||||||
slategray: '#708090',
|
|
||||||
slategrey: '#708090',
|
|
||||||
snow: '#fffafa',
|
|
||||||
springgreen: '#00ff7f',
|
|
||||||
steelblue: '#4682b4',
|
|
||||||
tan: '#d2b48c',
|
|
||||||
teal: '#008080',
|
|
||||||
thistle: '#d8bfd8',
|
|
||||||
tomato: '#ff6347',
|
|
||||||
turquoise: '#40e0d0',
|
|
||||||
violet: '#ee82ee',
|
|
||||||
wheat: '#f5deb3',
|
|
||||||
whitesmoke: '#f5f5f5',
|
|
||||||
yellowgreen: '#9acd32',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define the set of Ink's named colors for quick lookup
|
|
||||||
private static readonly inkSupportedNames = new Set([
|
|
||||||
'black',
|
|
||||||
'red',
|
|
||||||
'green',
|
|
||||||
'yellow',
|
|
||||||
'blue',
|
|
||||||
'cyan',
|
|
||||||
'magenta',
|
|
||||||
'white',
|
|
||||||
'gray',
|
|
||||||
'grey',
|
|
||||||
'blackbright',
|
|
||||||
'redbright',
|
|
||||||
'greenbright',
|
|
||||||
'yellowbright',
|
|
||||||
'bluebright',
|
|
||||||
'cyanbright',
|
|
||||||
'magentabright',
|
|
||||||
'whitebright',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Theme instance.
|
* Creates a new Theme instance.
|
||||||
* @param name The name of the theme.
|
* @param name The name of the theme.
|
||||||
|
@ -285,26 +124,7 @@ export class Theme {
|
||||||
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
|
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
|
||||||
*/
|
*/
|
||||||
private static _resolveColor(colorValue: string): string | undefined {
|
private static _resolveColor(colorValue: string): string | undefined {
|
||||||
const lowerColor = colorValue.toLowerCase();
|
return resolveColor(colorValue);
|
||||||
|
|
||||||
// 1. Check if it's already a hex code
|
|
||||||
if (lowerColor.startsWith('#')) {
|
|
||||||
return lowerColor; // Use hex directly
|
|
||||||
}
|
|
||||||
// 2. Check if it's an Ink supported name (lowercase)
|
|
||||||
else if (Theme.inkSupportedNames.has(lowerColor)) {
|
|
||||||
return lowerColor; // Use Ink name directly
|
|
||||||
}
|
|
||||||
// 3. Check if it's a known CSS name we can map to hex
|
|
||||||
else if (Theme.cssNameToHexMap[lowerColor]) {
|
|
||||||
return Theme.cssNameToHexMap[lowerColor]; // Use mapped hex
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Could not resolve
|
|
||||||
console.warn(
|
|
||||||
`[Theme] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -339,3 +159,230 @@ export class Theme {
|
||||||
return inkTheme;
|
return inkTheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Theme instance from a custom theme configuration.
|
||||||
|
* @param customTheme The custom theme configuration.
|
||||||
|
* @returns A new Theme instance.
|
||||||
|
*/
|
||||||
|
export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||||
|
// Generate CSS properties mappings based on the custom theme colors
|
||||||
|
const rawMappings: Record<string, CSSProperties> = {
|
||||||
|
hljs: {
|
||||||
|
display: 'block',
|
||||||
|
overflowX: 'auto',
|
||||||
|
padding: '0.5em',
|
||||||
|
background: customTheme.Background,
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-keyword': {
|
||||||
|
color: customTheme.AccentBlue,
|
||||||
|
},
|
||||||
|
'hljs-literal': {
|
||||||
|
color: customTheme.AccentBlue,
|
||||||
|
},
|
||||||
|
'hljs-symbol': {
|
||||||
|
color: customTheme.AccentBlue,
|
||||||
|
},
|
||||||
|
'hljs-name': {
|
||||||
|
color: customTheme.AccentBlue,
|
||||||
|
},
|
||||||
|
'hljs-link': {
|
||||||
|
color: customTheme.AccentBlue,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
'hljs-built_in': {
|
||||||
|
color: customTheme.AccentCyan,
|
||||||
|
},
|
||||||
|
'hljs-type': {
|
||||||
|
color: customTheme.AccentCyan,
|
||||||
|
},
|
||||||
|
'hljs-number': {
|
||||||
|
color: customTheme.AccentGreen,
|
||||||
|
},
|
||||||
|
'hljs-class': {
|
||||||
|
color: customTheme.AccentGreen,
|
||||||
|
},
|
||||||
|
'hljs-string': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-meta-string': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-regexp': {
|
||||||
|
color: customTheme.AccentRed,
|
||||||
|
},
|
||||||
|
'hljs-template-tag': {
|
||||||
|
color: customTheme.AccentRed,
|
||||||
|
},
|
||||||
|
'hljs-subst': {
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-function': {
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-title': {
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-params': {
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-formula': {
|
||||||
|
color: customTheme.Foreground,
|
||||||
|
},
|
||||||
|
'hljs-comment': {
|
||||||
|
color: customTheme.Comment,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'hljs-quote': {
|
||||||
|
color: customTheme.Comment,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'hljs-doctag': {
|
||||||
|
color: customTheme.Comment,
|
||||||
|
},
|
||||||
|
'hljs-meta': {
|
||||||
|
color: customTheme.Gray,
|
||||||
|
},
|
||||||
|
'hljs-meta-keyword': {
|
||||||
|
color: customTheme.Gray,
|
||||||
|
},
|
||||||
|
'hljs-tag': {
|
||||||
|
color: customTheme.Gray,
|
||||||
|
},
|
||||||
|
'hljs-variable': {
|
||||||
|
color: customTheme.AccentPurple,
|
||||||
|
},
|
||||||
|
'hljs-template-variable': {
|
||||||
|
color: customTheme.AccentPurple,
|
||||||
|
},
|
||||||
|
'hljs-attr': {
|
||||||
|
color: customTheme.LightBlue,
|
||||||
|
},
|
||||||
|
'hljs-attribute': {
|
||||||
|
color: customTheme.LightBlue,
|
||||||
|
},
|
||||||
|
'hljs-builtin-name': {
|
||||||
|
color: customTheme.LightBlue,
|
||||||
|
},
|
||||||
|
'hljs-section': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-emphasis': {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
'hljs-strong': {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
'hljs-bullet': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-selector-tag': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-selector-id': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-selector-class': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-selector-attr': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-selector-pseudo': {
|
||||||
|
color: customTheme.AccentYellow,
|
||||||
|
},
|
||||||
|
'hljs-addition': {
|
||||||
|
backgroundColor: customTheme.AccentGreen,
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
'hljs-deletion': {
|
||||||
|
backgroundColor: customTheme.AccentRed,
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Theme(customTheme.name, 'custom', rawMappings, customTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a custom theme configuration.
|
||||||
|
* @param customTheme The custom theme to validate.
|
||||||
|
* @returns An object with isValid boolean and error message if invalid.
|
||||||
|
*/
|
||||||
|
export function validateCustomTheme(customTheme: Partial<CustomTheme>): {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
// Check required fields
|
||||||
|
const requiredFields: Array<keyof CustomTheme> = [
|
||||||
|
'name',
|
||||||
|
'Background',
|
||||||
|
'Foreground',
|
||||||
|
'LightBlue',
|
||||||
|
'AccentBlue',
|
||||||
|
'AccentPurple',
|
||||||
|
'AccentCyan',
|
||||||
|
'AccentGreen',
|
||||||
|
'AccentYellow',
|
||||||
|
'AccentRed',
|
||||||
|
'Comment',
|
||||||
|
'Gray',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!customTheme[field]) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Missing required field: ${field}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate color format (basic hex validation)
|
||||||
|
const colorFields: Array<keyof CustomTheme> = [
|
||||||
|
'Background',
|
||||||
|
'Foreground',
|
||||||
|
'LightBlue',
|
||||||
|
'AccentBlue',
|
||||||
|
'AccentPurple',
|
||||||
|
'AccentCyan',
|
||||||
|
'AccentGreen',
|
||||||
|
'AccentYellow',
|
||||||
|
'AccentRed',
|
||||||
|
'Comment',
|
||||||
|
'Gray',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of colorFields) {
|
||||||
|
const color = customTheme[field] as string;
|
||||||
|
if (!isValidColor(color)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Invalid color format for ${field}: ${color}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate theme name
|
||||||
|
if (customTheme.name && !isValidThemeName(customTheme.name)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Invalid theme name: ${customTheme.name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a theme name is valid.
|
||||||
|
* @param name The theme name to validate.
|
||||||
|
* @returns True if the theme name is valid.
|
||||||
|
*/
|
||||||
|
function isValidThemeName(name: string): boolean {
|
||||||
|
// Theme name should be non-empty and not contain invalid characters
|
||||||
|
return name.trim().length > 0 && name.trim().length <= 50;
|
||||||
|
}
|
||||||
|
|
|
@ -100,9 +100,10 @@ export function colorizeCode(
|
||||||
language: string | null,
|
language: string | null,
|
||||||
availableHeight?: number,
|
availableHeight?: number,
|
||||||
maxWidth?: number,
|
maxWidth?: number,
|
||||||
|
theme?: Theme,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
const codeToHighlight = code.replace(/\n$/, '');
|
const codeToHighlight = code.replace(/\n$/, '');
|
||||||
const activeTheme = themeManager.getActiveTheme();
|
const activeTheme = theme || themeManager.getActiveTheme();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Render the HAST tree using the adapted theme
|
// Render the HAST tree using the adapted theme
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
import fsPromises from 'fs/promises';
|
import fsPromises from 'fs/promises';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { Dirent as FSDirent } from 'fs';
|
|
||||||
import * as nodePath from 'path';
|
import * as nodePath from 'path';
|
||||||
import { getFolderStructure } from './getFolderStructure.js';
|
import { getFolderStructure } from './getFolderStructure.js';
|
||||||
import * as gitUtils from './gitUtils.js';
|
import * as gitUtils from './gitUtils.js';
|
||||||
|
@ -30,8 +29,21 @@ vi.mock('./gitUtils.js');
|
||||||
// Import 'path' again here, it will be the mocked version
|
// Import 'path' again here, it will be the mocked version
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
interface TestDirent {
|
||||||
|
name: string;
|
||||||
|
isFile: () => boolean;
|
||||||
|
isDirectory: () => boolean;
|
||||||
|
isBlockDevice: () => boolean;
|
||||||
|
isCharacterDevice: () => boolean;
|
||||||
|
isSymbolicLink: () => boolean;
|
||||||
|
isFIFO: () => boolean;
|
||||||
|
isSocket: () => boolean;
|
||||||
|
path: string;
|
||||||
|
parentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create Dirent-like objects for mocking fs.readdir
|
// Helper to create Dirent-like objects for mocking fs.readdir
|
||||||
const createDirent = (name: string, type: 'file' | 'dir'): FSDirent => ({
|
const createDirent = (name: string, type: 'file' | 'dir'): TestDirent => ({
|
||||||
name,
|
name,
|
||||||
isFile: () => type === 'file',
|
isFile: () => type === 'file',
|
||||||
isDirectory: () => type === 'dir',
|
isDirectory: () => type === 'dir',
|
||||||
|
@ -77,7 +89,7 @@ describe('getFolderStructure', () => {
|
||||||
vi.restoreAllMocks(); // Restores spies (like fsPromises.readdir) and resets vi.fn mocks (like path.resolve)
|
vi.restoreAllMocks(); // Restores spies (like fsPromises.readdir) and resets vi.fn mocks (like path.resolve)
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockFsStructure: Record<string, FSDirent[]> = {
|
const mockFsStructure: Record<string, TestDirent[]> = {
|
||||||
'/testroot': [
|
'/testroot': [
|
||||||
createDirent('file1.txt', 'file'),
|
createDirent('file1.txt', 'file'),
|
||||||
createDirent('subfolderA', 'dir'),
|
createDirent('subfolderA', 'dir'),
|
||||||
|
|
|
@ -5,9 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Schema } from '@google/genai';
|
import { Schema } from '@google/genai';
|
||||||
import * as ajv from 'ajv';
|
import AjvPkg from 'ajv';
|
||||||
|
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
|
||||||
const ajValidator = new ajv.Ajv();
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const AjvClass = (AjvPkg as any).default || AjvPkg;
|
||||||
|
const ajValidator = new AjvClass();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple utility to validate objects against JSON Schemas
|
* Simple utility to validate objects against JSON Schemas
|
||||||
|
|
Loading…
Reference in New Issue