mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15
This commit is contained in:
@@ -9,7 +9,7 @@ import { type ColorsTheme, Theme } from './theme.js';
|
||||
const ansiLightColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: 'white',
|
||||
Foreground: 'black',
|
||||
Foreground: '#444',
|
||||
LightBlue: 'blue',
|
||||
AccentBlue: 'blue',
|
||||
AccentPurple: 'purple',
|
||||
@@ -17,6 +17,8 @@ const ansiLightColors: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'orange',
|
||||
AccentRed: 'red',
|
||||
DiffAdded: '#E5F2E5',
|
||||
DiffRemoved: '#FFE5E5',
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
GradientColors: ['blue', 'green'],
|
||||
|
||||
@@ -17,6 +17,8 @@ const ansiColors: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'yellow',
|
||||
AccentRed: 'red',
|
||||
DiffAdded: '#003300',
|
||||
DiffRemoved: '#4D0000',
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
GradientColors: ['cyan', 'green'],
|
||||
|
||||
@@ -17,6 +17,8 @@ const atomOneDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#98c379',
|
||||
AccentYellow: '#e6c07b',
|
||||
AccentRed: '#e06c75',
|
||||
DiffAdded: '#39544E',
|
||||
DiffRemoved: '#562B2F',
|
||||
Comment: '#5c6370',
|
||||
Gray: '#5c6370',
|
||||
GradientColors: ['#61aeee', '#98c379'],
|
||||
|
||||
@@ -17,8 +17,10 @@ const ayuLightColors: ColorsTheme = {
|
||||
AccentGreen: '#86b300',
|
||||
AccentYellow: '#f2ae49',
|
||||
AccentRed: '#f07171',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#ABADB1',
|
||||
Gray: '#CCCFD3',
|
||||
Gray: '#a6aaaf',
|
||||
GradientColors: ['#399ee6', '#86b300'],
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ const ayuDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#AAD94C',
|
||||
AccentYellow: '#FFB454',
|
||||
AccentRed: '#F26D78',
|
||||
DiffAdded: '#293022',
|
||||
DiffRemoved: '#3D1215',
|
||||
Comment: '#646A71',
|
||||
Gray: '##3D4149',
|
||||
Gray: '#3D4149',
|
||||
GradientColors: ['#FFB454', '#F26D78'],
|
||||
};
|
||||
|
||||
|
||||
221
packages/cli/src/ui/themes/color-utils.test.ts
Normal file
221
packages/cli/src/ui/themes/color-utils.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
231
packages/cli/src/ui/themes/color-utils.ts
Normal file
231
packages/cli/src/ui/themes/color-utils.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const draculaColors: ColorsTheme = {
|
||||
AccentGreen: '#50fa7b',
|
||||
AccentYellow: '#f1fa8c',
|
||||
AccentRed: '#ff5555',
|
||||
DiffAdded: '#11431d',
|
||||
DiffRemoved: '#6e1818',
|
||||
Comment: '#6272a4',
|
||||
Gray: '#6272a4',
|
||||
GradientColors: ['#ff79c6', '#8be9fd'],
|
||||
|
||||
@@ -17,6 +17,8 @@ const githubDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#85E89D',
|
||||
AccentYellow: '#FFAB70',
|
||||
AccentRed: '#F97583',
|
||||
DiffAdded: '#3C4636',
|
||||
DiffRemoved: '#502125',
|
||||
Comment: '#6A737D',
|
||||
Gray: '#6A737D',
|
||||
GradientColors: ['#79B8FF', '#85E89D'],
|
||||
|
||||
@@ -17,6 +17,8 @@ const githubLightColors: ColorsTheme = {
|
||||
AccentGreen: '#008080',
|
||||
AccentYellow: '#990073',
|
||||
AccentRed: '#d14',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#998',
|
||||
Gray: '#999',
|
||||
GradientColors: ['#458', '#008080'],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { lightTheme, Theme, type ColorsTheme } from './theme.js';
|
||||
const googleCodeColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: 'white',
|
||||
Foreground: 'black',
|
||||
Foreground: '#444',
|
||||
LightBlue: '#066',
|
||||
AccentBlue: '#008',
|
||||
AccentPurple: '#606',
|
||||
@@ -17,6 +17,8 @@ const googleCodeColors: ColorsTheme = {
|
||||
AccentGreen: '#080',
|
||||
AccentYellow: '#660',
|
||||
AccentRed: '#800',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FEDEDE',
|
||||
Comment: '#5f6368',
|
||||
Gray: lightTheme.Gray,
|
||||
GradientColors: ['#066', '#606'],
|
||||
|
||||
@@ -17,12 +17,14 @@ const noColorColorsTheme: ColorsTheme = {
|
||||
AccentGreen: '',
|
||||
AccentYellow: '',
|
||||
AccentRed: '',
|
||||
DiffAdded: '',
|
||||
DiffRemoved: '',
|
||||
Comment: '',
|
||||
Gray: '',
|
||||
};
|
||||
|
||||
export const NoColorTheme: Theme = new Theme(
|
||||
'No Color',
|
||||
'NoColor',
|
||||
'dark',
|
||||
{
|
||||
hljs: {
|
||||
|
||||
@@ -17,6 +17,8 @@ const qwenDarkColors: ColorsTheme = {
|
||||
AccentGreen: '#AAD94C',
|
||||
AccentYellow: '#FFD700',
|
||||
AccentRed: '#F26D78',
|
||||
DiffAdded: '#AAD94C',
|
||||
DiffRemoved: '#F26D78',
|
||||
Comment: '#646A71',
|
||||
Gray: '#3D4149',
|
||||
GradientColors: ['#FFD700', '#da7959'],
|
||||
|
||||
@@ -17,6 +17,8 @@ const qwenLightColors: ColorsTheme = {
|
||||
AccentGreen: '#86b300',
|
||||
AccentYellow: '#f2ae49',
|
||||
AccentRed: '#f07171',
|
||||
DiffAdded: '#86b300',
|
||||
DiffRemoved: '#f07171',
|
||||
Comment: '#ABADB1',
|
||||
Gray: '#CCCFD3',
|
||||
GradientColors: ['#399ee6', '#86b300'],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shades of Purple Theme — for Highlightjs.
|
||||
* Shades of Purple Theme — for Highlight.js.
|
||||
* @author Ahmad Awais <https://twitter.com/mrahmadawais/>
|
||||
*/
|
||||
import { type ColorsTheme, Theme } from './theme.js';
|
||||
@@ -22,6 +22,8 @@ const shadesOfPurpleColors: ColorsTheme = {
|
||||
AccentGreen: '#A5FF90', // Strings and many others
|
||||
AccentYellow: '#fad000', // Title, main yellow
|
||||
AccentRed: '#ff628c', // Error/deletion accent
|
||||
DiffAdded: '#383E45',
|
||||
DiffRemoved: '#572244',
|
||||
Comment: '#B362FF', // Comment color (same as AccentPurple)
|
||||
Gray: '#726c86', // Gray color
|
||||
GradientColors: ['#4d21fc', '#847ace', '#ff628c'],
|
||||
|
||||
108
packages/cli/src/ui/themes/theme-manager.test.ts
Normal file
108
packages/cli/src/ui/themes/theme-manager.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @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: 'yellow',
|
||||
AccentRed: 'red',
|
||||
DiffAdded: 'green',
|
||||
DiffRemoved: 'red',
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
};
|
||||
|
||||
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 fall back 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,13 @@ 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 } from './theme.js';
|
||||
import {
|
||||
Theme,
|
||||
ThemeType,
|
||||
CustomTheme,
|
||||
createCustomTheme,
|
||||
validateCustomTheme,
|
||||
} from './theme.js';
|
||||
import { ANSI } from './ansi.js';
|
||||
import { ANSILight } from './ansi-light.js';
|
||||
import { NoColorTheme } from './no-color.js';
|
||||
@@ -26,6 +32,7 @@ import process from 'node:process';
|
||||
export interface ThemeDisplay {
|
||||
name: string;
|
||||
type: ThemeType;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME: Theme = QwenDark;
|
||||
@@ -33,6 +40,7 @@ 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 = [
|
||||
@@ -56,84 +64,177 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of available theme names.
|
||||
* Loads custom themes from settings.
|
||||
* @param customThemesSettings Custom themes from settings.
|
||||
*/
|
||||
getAvailableThemes(): ThemeDisplay[] {
|
||||
// Separate Qwen themes
|
||||
const qwenThemes = this.availableThemes.filter(
|
||||
(theme) => theme.name === QwenLight.name || theme.name === QwenDark.name,
|
||||
);
|
||||
const otherThemes = this.availableThemes.filter(
|
||||
(theme) => theme.name !== QwenLight.name && theme.name !== QwenDark.name,
|
||||
);
|
||||
loadCustomThemes(customThemesSettings?: Record<string, CustomTheme>): void {
|
||||
this.customThemes.clear();
|
||||
|
||||
// Sort other themes by type and then name
|
||||
const sortedOtherThemes = otherThemes.sort((a, b) => {
|
||||
const typeOrder = (type: ThemeType): number => {
|
||||
switch (type) {
|
||||
case 'dark':
|
||||
return 1;
|
||||
case 'light':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
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',
|
||||
};
|
||||
|
||||
const typeComparison = typeOrder(a.type) - typeOrder(b.type);
|
||||
if (typeComparison !== 0) {
|
||||
return typeComparison;
|
||||
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}`);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Combine Qwen themes first, then sorted others
|
||||
const sortedThemes = [...qwenThemes, ...sortedOtherThemes];
|
||||
|
||||
return sortedThemes.map((theme) => ({
|
||||
name: theme.name,
|
||||
type: theme.type,
|
||||
}));
|
||||
}
|
||||
// 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 activate.
|
||||
* @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 foundTheme = 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;
|
||||
}
|
||||
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 (fall back 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
findThemeByName(themeName: string | undefined): Theme | undefined {
|
||||
if (!themeName) {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
return this.availableThemes.find((theme) => theme.name === themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently active theme object.
|
||||
*/
|
||||
getActiveTheme(): Theme {
|
||||
if (process.env.NO_COLOR) {
|
||||
return NoColorTheme;
|
||||
// First check built-in themes
|
||||
const builtInTheme = this.availableThemes.find(
|
||||
(theme) => theme.name === themeName,
|
||||
);
|
||||
if (builtInTheme) {
|
||||
return builtInTheme;
|
||||
}
|
||||
return this.activeTheme;
|
||||
|
||||
// Then check custom themes
|
||||
return this.customThemes.get(themeName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
147
packages/cli/src/ui/themes/theme.test.ts
Normal file
147
packages/cli/src/ui/themes/theme.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as themeModule from './theme.js';
|
||||
import { themeManager } from './theme-manager.js';
|
||||
|
||||
const { validateCustomTheme } = themeModule;
|
||||
type CustomTheme = themeModule.CustomTheme;
|
||||
|
||||
describe('validateCustomTheme', () => {
|
||||
const validTheme: CustomTheme = {
|
||||
type: 'custom',
|
||||
name: 'My Custom Theme',
|
||||
Background: '#FFFFFF',
|
||||
Foreground: '#000000',
|
||||
LightBlue: '#ADD8E6',
|
||||
AccentBlue: '#0000FF',
|
||||
AccentPurple: '#800080',
|
||||
AccentCyan: '#00FFFF',
|
||||
AccentGreen: '#008000',
|
||||
AccentYellow: '#FFFF00',
|
||||
AccentRed: '#FF0000',
|
||||
DiffAdded: '#00FF00',
|
||||
DiffRemoved: '#FF0000',
|
||||
Comment: '#808080',
|
||||
Gray: '#808080',
|
||||
};
|
||||
|
||||
it('should return isValid: true for a valid theme', () => {
|
||||
const result = validateCustomTheme(validTheme);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with a missing required field', () => {
|
||||
const invalidTheme = {
|
||||
...validTheme,
|
||||
name: undefined as unknown as string,
|
||||
};
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Missing required field: name');
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with an invalid color format', () => {
|
||||
const invalidTheme = { ...validTheme, Background: 'not-a-color' };
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Invalid color format for Background: not-a-color',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with an invalid name', () => {
|
||||
const invalidTheme = { ...validTheme, name: ' ' };
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Invalid theme name: ');
|
||||
});
|
||||
|
||||
it('should return isValid: true for a theme missing optional DiffAdded and DiffRemoved colors', () => {
|
||||
const legacyTheme: Partial<CustomTheme> = { ...validTheme };
|
||||
delete legacyTheme.DiffAdded;
|
||||
delete legacyTheme.DiffRemoved;
|
||||
const result = validateCustomTheme(legacyTheme);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a warning if DiffAdded and DiffRemoved are missing', () => {
|
||||
const legacyTheme: Partial<CustomTheme> = { ...validTheme };
|
||||
delete legacyTheme.DiffAdded;
|
||||
delete legacyTheme.DiffRemoved;
|
||||
const result = validateCustomTheme(legacyTheme);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warning).toBe('Missing field(s) DiffAdded, DiffRemoved');
|
||||
});
|
||||
|
||||
it('should return a warning if only DiffRemoved is missing', () => {
|
||||
const legacyTheme: Partial<CustomTheme> = { ...validTheme };
|
||||
delete legacyTheme.DiffRemoved;
|
||||
const result = validateCustomTheme(legacyTheme);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warning).toBe('Missing field(s) DiffRemoved');
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with an invalid DiffAdded color', () => {
|
||||
const invalidTheme = { ...validTheme, DiffAdded: 'invalid' };
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Invalid color format for DiffAdded: invalid');
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with an invalid DiffRemoved color', () => {
|
||||
const invalidTheme = { ...validTheme, DiffRemoved: 'invalid' };
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('Invalid color format for DiffRemoved: invalid');
|
||||
});
|
||||
|
||||
it('should return isValid: false for a theme with a very long name', () => {
|
||||
const invalidTheme = { ...validTheme, name: 'a'.repeat(51) };
|
||||
const result = validateCustomTheme(invalidTheme);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe(`Invalid theme name: ${'a'.repeat(51)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('themeManager.loadCustomThemes', () => {
|
||||
const baseTheme: Omit<CustomTheme, 'DiffAdded' | 'DiffRemoved'> & {
|
||||
DiffAdded?: string;
|
||||
DiffRemoved?: string;
|
||||
} = {
|
||||
type: 'custom',
|
||||
name: 'Test Theme',
|
||||
Background: '#FFF',
|
||||
Foreground: '#000',
|
||||
LightBlue: '#ADD8E6',
|
||||
AccentBlue: '#00F',
|
||||
AccentPurple: '#808',
|
||||
AccentCyan: '#0FF',
|
||||
AccentGreen: '#080',
|
||||
AccentYellow: '#FF0',
|
||||
AccentRed: '#F00',
|
||||
Comment: '#888',
|
||||
Gray: '#888',
|
||||
};
|
||||
|
||||
it('should use values from DEFAULT_THEME when DiffAdded and DiffRemoved are not provided', () => {
|
||||
const legacyTheme: Partial<CustomTheme> = { ...baseTheme };
|
||||
delete legacyTheme.DiffAdded;
|
||||
delete legacyTheme.DiffRemoved;
|
||||
|
||||
themeManager.loadCustomThemes({ 'Legacy Custom Theme': legacyTheme });
|
||||
const result = themeManager.getTheme('Legacy Custom Theme')!;
|
||||
|
||||
// Should use DEFAULT_THEME (QwenDark) values for missing fields
|
||||
expect(result.colors.DiffAdded).toBe('#AAD94C');
|
||||
expect(result.colors.DiffRemoved).toBe('#F26D78');
|
||||
expect(result.colors.AccentBlue).toBe(legacyTheme.AccentBlue);
|
||||
expect(result.name).toBe(legacyTheme.name);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,9 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
type: ThemeType;
|
||||
@@ -19,11 +20,18 @@ export interface ColorsTheme {
|
||||
AccentGreen: string;
|
||||
AccentYellow: string;
|
||||
AccentRed: string;
|
||||
DiffAdded: string;
|
||||
DiffRemoved: string;
|
||||
Comment: string;
|
||||
Gray: string;
|
||||
GradientColors?: string[];
|
||||
}
|
||||
|
||||
export interface CustomTheme extends ColorsTheme {
|
||||
type: 'custom';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const lightTheme: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: '#FAFAFA',
|
||||
@@ -35,8 +43,10 @@ export const lightTheme: ColorsTheme = {
|
||||
AccentGreen: '#3CA84B',
|
||||
AccentYellow: '#D5A40A',
|
||||
AccentRed: '#DD4C4C',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#008000',
|
||||
Gray: '#B7BECC',
|
||||
Gray: '#97a0b0',
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
@@ -51,6 +61,8 @@ export const darkTheme: ColorsTheme = {
|
||||
AccentGreen: '#A6E3A1',
|
||||
AccentYellow: '#F9E2AF',
|
||||
AccentRed: '#F38BA8',
|
||||
DiffAdded: '#28350B',
|
||||
DiffRemoved: '#430000',
|
||||
Comment: '#6C7086',
|
||||
Gray: '#6C7086',
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
@@ -67,6 +79,8 @@ export const ansiTheme: ColorsTheme = {
|
||||
AccentGreen: 'green',
|
||||
AccentYellow: 'yellow',
|
||||
AccentRed: 'red',
|
||||
DiffAdded: 'green',
|
||||
DiffRemoved: 'red',
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
};
|
||||
@@ -83,173 +97,6 @@ export class Theme {
|
||||
*/
|
||||
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.
|
||||
* @param name The name of the theme.
|
||||
@@ -285,26 +132,7 @@ export class Theme {
|
||||
* @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
|
||||
*/
|
||||
private static _resolveColor(colorValue: string): string | undefined {
|
||||
const lowerColor = colorValue.toLowerCase();
|
||||
|
||||
// 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;
|
||||
return resolveColor(colorValue);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,7 +159,7 @@ export class Theme {
|
||||
inkTheme[key] = resolvedColor;
|
||||
}
|
||||
// If color is not resolvable, it's omitted from the map,
|
||||
// allowing fallback to the default foreground color.
|
||||
// this enables falling back to the default foreground color.
|
||||
}
|
||||
// We currently only care about the 'color' property for Ink rendering.
|
||||
// Other properties like background, fontStyle, etc., are ignored.
|
||||
@@ -339,3 +167,254 @@ export class Theme {
|
||||
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;
|
||||
warning?: string;
|
||||
} {
|
||||
// Check required fields
|
||||
const requiredFields: Array<keyof CustomTheme> = [
|
||||
'name',
|
||||
'Background',
|
||||
'Foreground',
|
||||
'LightBlue',
|
||||
'AccentBlue',
|
||||
'AccentPurple',
|
||||
'AccentCyan',
|
||||
'AccentGreen',
|
||||
'AccentYellow',
|
||||
'AccentRed',
|
||||
// 'DiffAdded' and 'DiffRemoved' are not required as they were added after
|
||||
// the theme format was defined.
|
||||
'Comment',
|
||||
'Gray',
|
||||
];
|
||||
|
||||
const recommendedFields: Array<keyof CustomTheme> = [
|
||||
'DiffAdded',
|
||||
'DiffRemoved',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!customTheme[field]) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Missing required field: ${field}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const field of recommendedFields) {
|
||||
if (!customTheme[field]) {
|
||||
missingFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color format (basic hex validation)
|
||||
const colorFields: Array<keyof CustomTheme> = [
|
||||
'Background',
|
||||
'Foreground',
|
||||
'LightBlue',
|
||||
'AccentBlue',
|
||||
'AccentPurple',
|
||||
'AccentCyan',
|
||||
'AccentGreen',
|
||||
'AccentYellow',
|
||||
'AccentRed',
|
||||
'DiffAdded',
|
||||
'DiffRemoved',
|
||||
'Comment',
|
||||
'Gray',
|
||||
];
|
||||
|
||||
for (const field of colorFields) {
|
||||
const color = customTheme[field] as string | undefined;
|
||||
if (color !== undefined && !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,
|
||||
warning:
|
||||
missingFields.length > 0
|
||||
? `Missing field(s) ${missingFields.join(', ')}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { type ColorsTheme, Theme } from './theme.js';
|
||||
const xcodeColors: ColorsTheme = {
|
||||
type: 'light',
|
||||
Background: '#fff',
|
||||
Foreground: 'black',
|
||||
Foreground: '#444',
|
||||
LightBlue: '#0E0EFF',
|
||||
AccentBlue: '#1c00cf',
|
||||
AccentPurple: '#aa0d91',
|
||||
@@ -17,6 +17,8 @@ const xcodeColors: ColorsTheme = {
|
||||
AccentGreen: '#007400',
|
||||
AccentYellow: '#836C28',
|
||||
AccentRed: '#c41a16',
|
||||
DiffAdded: '#C6EAD8',
|
||||
DiffRemoved: '#FEDEDE',
|
||||
Comment: '#007400',
|
||||
Gray: '#c0c0c0',
|
||||
GradientColors: ['#1c00cf', '#007400'],
|
||||
|
||||
Reference in New Issue
Block a user