Files
qwen-code/packages/cli/src/ui/themes/theme-manager.ts
2025-09-11 16:26:56 +08:00

339 lines
9.5 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AyuDark } from './ayu.js';
import { AyuLight } from './ayu-light.js';
import { AtomOneDark } from './atom-one-dark.js';
import { Dracula } from './dracula.js';
import { GitHubDark } from './github-dark.js';
import { GitHubLight } from './github-light.js';
import { GoogleCode } from './googlecode.js';
import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
import { QwenLight } from './qwen-light.js';
import { QwenDark } from './qwen-dark.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Theme, ThemeType, CustomTheme } from './theme.js';
import { createCustomTheme, validateCustomTheme } from './theme.js';
import type { SemanticColors } from './semantic-tokens.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
import process from 'node:process';
export interface ThemeDisplay {
name: string;
type: ThemeType;
isCustom?: boolean;
}
export const DEFAULT_THEME: Theme = QwenDark;
class ThemeManager {
private readonly availableThemes: Theme[];
private activeTheme: Theme;
private customThemes: Map<string, Theme> = new Map();
constructor() {
this.availableThemes = [
AyuDark,
AyuLight,
AtomOneDark,
Dracula,
DefaultLight,
DefaultDark,
GitHubDark,
GitHubLight,
GoogleCode,
QwenLight,
QwenDark,
ShadesOfPurple,
XCode,
ANSI,
ANSILight,
];
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) {
if (validation.warning) {
console.warn(`Theme "${name}": ${validation.warning}`);
}
const themeWithDefaults: CustomTheme = {
...DEFAULT_THEME.colors,
...customThemeConfig,
name: customThemeConfig.name || name,
type: 'custom',
};
try {
const theme = createCustomTheme(themeWithDefaults);
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;
}
if (this.activeTheme) {
const isBuiltIn = this.availableThemes.some(
(t) => t.name === this.activeTheme.name,
);
const isCustom = [...this.customThemes.values()].includes(
this.activeTheme,
);
if (isBuiltIn || isCustom) {
return this.activeTheme;
}
}
// Fallback to default if no active theme or if it's no longer valid.
this.activeTheme = DEFAULT_THEME;
return this.activeTheme;
}
/**
* Gets the semantic colors for the active theme.
* @returns The semantic colors.
*/
getSemanticColors(): SemanticColors {
return this.getActiveTheme().semanticColors;
}
/**
* 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.
*/
getAvailableThemes(): ThemeDisplay[] {
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,
}),
);
// Separate Qwen themes
const qwenThemes = builtInThemes.filter(
(theme) => theme.name === QwenLight.name || theme.name === QwenDark.name,
);
const otherBuiltInThemes = builtInThemes.filter(
(theme) => theme.name !== QwenLight.name && theme.name !== QwenDark.name,
);
// Sort other themes by type and then name
const sortedOtherThemes = [...otherBuiltInThemes, ...customThemes].sort(
(a, b) => {
const typeOrder = (type: ThemeType): number => {
switch (type) {
case 'dark':
return 1;
case 'light':
return 2;
case 'ansi':
return 3;
case 'custom':
return 4; // Custom themes at the end
default:
return 5;
}
};
const typeComparison = typeOrder(a.type) - typeOrder(b.type);
if (typeComparison !== 0) {
return typeComparison;
}
return a.name.localeCompare(b.name);
},
);
// Combine Qwen themes first, then sorted others
return [...qwenThemes, ...sortedOtherThemes];
}
/**
* Gets a theme by name.
* @param themeName The name of the theme to get.
* @returns The theme if found, undefined otherwise.
*/
getTheme(themeName: string): Theme | undefined {
return this.findThemeByName(themeName);
}
private isPath(themeName: string): boolean {
return (
themeName.endsWith('.json') ||
themeName.startsWith('.') ||
path.isAbsolute(themeName)
);
}
private loadThemeFromFile(themePath: string): Theme | undefined {
try {
// realpathSync resolves the path and throws if it doesn't exist.
const canonicalPath = fs.realpathSync(path.resolve(themePath));
// 1. Check cache using the canonical path.
if (this.customThemes.has(canonicalPath)) {
return this.customThemes.get(canonicalPath);
}
// 2. Perform security check.
const homeDir = path.resolve(os.homedir());
if (!canonicalPath.startsWith(homeDir)) {
console.warn(
`Theme file at "${themePath}" is outside your home directory. ` +
`Only load themes from trusted sources.`,
);
return undefined;
}
// 3. Read, parse, and validate the theme file.
const themeContent = fs.readFileSync(canonicalPath, 'utf-8');
const customThemeConfig = JSON.parse(themeContent) as CustomTheme;
const validation = validateCustomTheme(customThemeConfig);
if (!validation.isValid) {
console.warn(
`Invalid custom theme from file "${themePath}": ${validation.error}`,
);
return undefined;
}
if (validation.warning) {
console.warn(`Theme from "${themePath}": ${validation.warning}`);
}
// 4. Create and cache the theme.
const themeWithDefaults: CustomTheme = {
...DEFAULT_THEME.colors,
...customThemeConfig,
name: customThemeConfig.name || canonicalPath,
type: 'custom',
};
const theme = createCustomTheme(themeWithDefaults);
this.customThemes.set(canonicalPath, theme); // Cache by canonical path
return theme;
} catch (error) {
// Any error in the process (file not found, bad JSON, etc.) is caught here.
// We can return undefined silently for file-not-found, and warn for others.
if (
!(error instanceof Error && 'code' in error && error.code === 'ENOENT')
) {
console.warn(`Could not load theme from file "${themePath}":`, error);
}
return undefined;
}
}
findThemeByName(themeName: string | undefined): Theme | undefined {
if (!themeName) {
return DEFAULT_THEME;
}
// First check built-in themes
const builtInTheme = this.availableThemes.find(
(theme) => theme.name === themeName,
);
if (builtInTheme) {
return builtInTheme;
}
// Then check custom themes that have been loaded from settings, or file paths
if (this.isPath(themeName)) {
return this.loadThemeFromFile(themeName);
}
if (this.customThemes.has(themeName)) {
return this.customThemes.get(themeName);
}
// If it's not a built-in, not in cache, and not a valid file path,
// it's not a valid theme.
return undefined;
}
}
// Export an instance of the ThemeManager
export const themeManager = new ThemeManager();