mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0
This commit is contained in:
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const atomOneDarkColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
@@ -143,5 +142,4 @@ export const AtomOneDark: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
atomOneDarkColors,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { lightSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const ayuLightColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
@@ -135,5 +134,4 @@ export const AyuLight: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
ayuLightColors,
|
||||
lightSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const ayuDarkColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
@@ -109,5 +108,4 @@ export const AyuDark: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
ayuDarkColors,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { lightTheme, Theme } from './theme.js';
|
||||
import { lightSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
export const DefaultLight: Theme = new Theme(
|
||||
'Default Light',
|
||||
@@ -104,5 +103,4 @@ export const DefaultLight: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
lightTheme,
|
||||
lightSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { darkTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
export const DefaultDark: Theme = new Theme(
|
||||
'Default',
|
||||
@@ -147,5 +146,4 @@ export const DefaultDark: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
darkTheme,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const draculaColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
@@ -120,5 +119,4 @@ export const Dracula: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
draculaColors,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const githubDarkColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
@@ -143,5 +142,4 @@ export const GitHubDark: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
githubDarkColors,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { lightSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const githubLightColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
@@ -145,5 +144,4 @@ export const GitHubLight: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
githubLightColors,
|
||||
lightSemanticColors,
|
||||
);
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { lightTheme, Theme, type ColorsTheme } from './theme.js';
|
||||
import { lightSemanticColors } from './semantic-tokens.js';
|
||||
import { type ColorsTheme, Theme, lightTheme } from './theme.js';
|
||||
|
||||
const googleCodeColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
@@ -142,5 +141,4 @@ export const GoogleCode: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
googleCodeColors,
|
||||
lightSemanticColors,
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Theme, ColorsTheme } from './theme.js';
|
||||
import { SemanticColors } from './semantic-tokens.js';
|
||||
import type { ColorsTheme } from './theme.js';
|
||||
import { Theme } from './theme.js';
|
||||
import type { SemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const noColorColorsTheme: ColorsTheme = {
|
||||
type: 'ansi',
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* @author Ahmad Awais <https://twitter.com/mrahmadawais/>
|
||||
*/
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { darkSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const shadesOfPurpleColors: ColorsTheme = {
|
||||
type: 'dark',
|
||||
@@ -348,5 +347,4 @@ export const ShadesOfPurple = new Theme(
|
||||
},
|
||||
},
|
||||
shadesOfPurpleColors,
|
||||
darkSemanticColors,
|
||||
);
|
||||
|
||||
@@ -9,9 +9,22 @@ if (process.env['NO_COLOR'] !== undefined) {
|
||||
delete process.env['NO_COLOR'];
|
||||
}
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
|
||||
import { CustomTheme } from './theme.js';
|
||||
import type { CustomTheme } from './theme.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import type * as osActual from 'node:os';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
const validCustomTheme: CustomTheme = {
|
||||
type: 'custom',
|
||||
@@ -38,6 +51,10 @@ describe('ThemeManager', () => {
|
||||
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should load valid custom themes', () => {
|
||||
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
|
||||
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
|
||||
@@ -96,4 +113,69 @@ describe('ThemeManager', () => {
|
||||
process.env['NO_COLOR'] = original;
|
||||
}
|
||||
});
|
||||
|
||||
describe('when loading a theme from a file', () => {
|
||||
const mockThemePath = './my-theme.json';
|
||||
const mockTheme: CustomTheme = {
|
||||
...validCustomTheme,
|
||||
name: 'My File Theme',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
||||
});
|
||||
|
||||
it('should load a theme from a valid file path', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
|
||||
|
||||
const result = themeManager.setActiveTheme('/home/user/my-theme.json');
|
||||
|
||||
expect(result).toBe(true);
|
||||
const activeTheme = themeManager.getActiveTheme();
|
||||
expect(activeTheme.name).toBe('My File Theme');
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('my-theme.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not load a theme if the file does not exist', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
const result = themeManager.setActiveTheme(mockThemePath);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||
});
|
||||
|
||||
it('should not load a theme from a file with invalid JSON', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid json');
|
||||
|
||||
const result = themeManager.setActiveTheme(mockThemePath);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||
});
|
||||
|
||||
it('should not load a theme from an untrusted file path and log a message', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const result = themeManager.setActiveTheme('/untrusted/my-theme.json');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('is outside your home directory'),
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,14 +17,12 @@ 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 {
|
||||
Theme,
|
||||
ThemeType,
|
||||
CustomTheme,
|
||||
createCustomTheme,
|
||||
validateCustomTheme,
|
||||
} from './theme.js';
|
||||
import { SemanticColors } from './semantic-tokens.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';
|
||||
@@ -132,10 +130,22 @@ class ThemeManager {
|
||||
if (process.env['NO_COLOR']) {
|
||||
return NoColorTheme;
|
||||
}
|
||||
// Ensure the active theme is always valid (fall back to default if not)
|
||||
if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
|
||||
this.activeTheme = DEFAULT_THEME;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -229,6 +239,73 @@ class ThemeManager {
|
||||
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;
|
||||
@@ -242,8 +319,18 @@ class ThemeManager {
|
||||
return builtInTheme;
|
||||
}
|
||||
|
||||
// Then check custom themes
|
||||
return this.customThemes.get(themeName);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { SemanticColors } from './semantic-tokens.js';
|
||||
import type { SemanticColors } from './semantic-tokens.js';
|
||||
import { resolveColor } from './color-utils.js';
|
||||
|
||||
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
||||
@@ -141,6 +141,7 @@ export class Theme {
|
||||
* to Ink-compatible color strings (hex or name).
|
||||
*/
|
||||
protected readonly _colorMap: Readonly<Record<string, string>>;
|
||||
readonly semanticColors: SemanticColors;
|
||||
|
||||
/**
|
||||
* Creates a new Theme instance.
|
||||
@@ -152,8 +153,37 @@ export class Theme {
|
||||
readonly type: ThemeType,
|
||||
rawMappings: Record<string, CSSProperties>,
|
||||
readonly colors: ColorsTheme,
|
||||
readonly semanticColors: SemanticColors,
|
||||
semanticColors?: SemanticColors,
|
||||
) {
|
||||
this.semanticColors = semanticColors ?? {
|
||||
text: {
|
||||
primary: this.colors.Foreground,
|
||||
secondary: this.colors.Gray,
|
||||
link: this.colors.AccentBlue,
|
||||
accent: this.colors.AccentPurple,
|
||||
},
|
||||
background: {
|
||||
primary: this.colors.Background,
|
||||
diff: {
|
||||
added: this.colors.DiffAdded,
|
||||
removed: this.colors.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: this.colors.Gray,
|
||||
focused: this.colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
comment: this.colors.Comment,
|
||||
symbol: this.colors.Gray,
|
||||
gradient: this.colors.GradientColors,
|
||||
},
|
||||
status: {
|
||||
error: this.colors.AccentRed,
|
||||
success: this.colors.AccentGreen,
|
||||
warning: this.colors.AccentYellow,
|
||||
},
|
||||
};
|
||||
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
|
||||
|
||||
// Determine the default foreground color
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
import { lightSemanticColors } from './semantic-tokens.js';
|
||||
|
||||
const xcodeColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
@@ -150,5 +149,4 @@ export const XCode: Theme = new Theme(
|
||||
},
|
||||
},
|
||||
xcodeColors,
|
||||
lightSemanticColors,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user