lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
477 lines (476 loc) • 18.2 kB
JavaScript
/**
* Theme Manager
* Import and apply ZSH themes (Oh-My-Zsh, Powerlevel10k, custom)
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import chalk from 'chalk';
export class ThemeManager {
themesPath;
customThemesPath;
currentTheme = null;
constructor() {
this.themesPath = path.join(os.homedir(), '.oh-my-zsh', 'themes');
this.customThemesPath = path.join(os.homedir(), '.lsh', 'themes');
// Ensure custom themes directory exists
if (!fs.existsSync(this.customThemesPath)) {
fs.mkdirSync(this.customThemesPath, { recursive: true });
}
}
/**
* List available themes
*/
listThemes() {
const ohmyzsh = [];
const custom = [];
// Oh-My-Zsh themes
if (fs.existsSync(this.themesPath)) {
ohmyzsh.push(...fs.readdirSync(this.themesPath)
.filter(f => f.endsWith('.zsh-theme'))
.map(f => f.replace('.zsh-theme', '')));
}
// Custom themes
if (fs.existsSync(this.customThemesPath)) {
custom.push(...fs.readdirSync(this.customThemesPath)
.filter(f => f.endsWith('.lsh-theme'))
.map(f => f.replace('.lsh-theme', '')));
}
// Built-in LSH themes
const builtin = [
'default',
'minimal',
'powerline',
'simple',
'git',
'robbyrussell', // Popular Oh-My-Zsh theme
'agnoster',
];
return { ohmyzsh, custom, builtin };
}
/**
* Import Oh-My-Zsh theme
*/
async importOhMyZshTheme(themeName) {
const themePath = path.join(this.themesPath, `${themeName}.zsh-theme`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${themeName}`);
}
const themeContent = fs.readFileSync(themePath, 'utf8');
const parsed = this.parseZshTheme(themeName, themeContent);
// Convert to LSH format and save
this.saveAsLshTheme(parsed);
return parsed;
}
/**
* Parse ZSH theme file
*/
parseZshTheme(name, content) {
const theme = {
name,
colors: new Map(),
prompts: { left: '' },
variables: new Map(),
hooks: [],
dependencies: [],
};
const lines = content.split('\n');
for (const line of lines) {
let trimmed = line.trim();
// Skip comments and empty lines
if (trimmed.startsWith('#') || trimmed === '') {
continue;
}
// Strip inline comments (but preserve # inside quotes)
const inlineCommentMatch = trimmed.match(/^([^#]*?["'][^"']*["'])\s*#/);
if (inlineCommentMatch) {
trimmed = inlineCommentMatch[1].trim();
}
else if (trimmed.includes('#')) {
// Simple inline comment without quotes
const parts = trimmed.split('#');
if (parts[0].includes('=')) {
trimmed = parts[0].trim();
}
}
// Parse color definitions
const colorMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=["']?%\{.*?%F\{(\d+|[a-z]+)\}.*?\}["']?/);
if (colorMatch) {
theme.colors.set(colorMatch[1], colorMatch[2]);
continue;
}
// Parse PROMPT
const promptMatch = trimmed.match(/^PROMPT=["'](.+)["']$/);
if (promptMatch) {
theme.prompts.left = promptMatch[1];
continue;
}
// Parse RPROMPT
const rpromptMatch = trimmed.match(/^RPROMPT=["'](.+)["']$/);
if (rpromptMatch) {
theme.prompts.right = rpromptMatch[1];
continue;
}
// Parse PS2 (continuation)
const ps2Match = trimmed.match(/^PS2=["'](.+)["']$/);
if (ps2Match) {
theme.prompts.continuation = ps2Match[1];
continue;
}
// Parse PS3 (select)
const ps3Match = trimmed.match(/^PS3=["'](.+)["']$/);
if (ps3Match) {
theme.prompts.select = ps3Match[1];
continue;
}
// Parse git formats (custom formats)
if (trimmed.includes('FMT_BRANCH')) {
const fmtMatch = trimmed.match(/FMT_BRANCH=["'](.+)["']$/);
if (fmtMatch) {
if (!theme.gitFormats)
theme.gitFormats = {};
theme.gitFormats.branch = fmtMatch[1];
}
}
if (trimmed.includes('FMT_UNSTAGED')) {
const fmtMatch = trimmed.match(/FMT_UNSTAGED=["'](.+)["']$/);
if (fmtMatch) {
if (!theme.gitFormats)
theme.gitFormats = {};
theme.gitFormats.unstaged = fmtMatch[1];
}
}
if (trimmed.includes('FMT_STAGED')) {
const fmtMatch = trimmed.match(/FMT_STAGED=["'](.+)["']$/);
if (fmtMatch) {
if (!theme.gitFormats)
theme.gitFormats = {};
theme.gitFormats.staged = fmtMatch[1];
}
}
// Parse Oh-My-Zsh git prompt variables
const gitPromptVars = {
'ZSH_THEME_GIT_PROMPT_PREFIX': 'prefix',
'ZSH_THEME_GIT_PROMPT_SUFFIX': 'suffix',
'ZSH_THEME_GIT_PROMPT_DIRTY': 'dirty',
'ZSH_THEME_GIT_PROMPT_CLEAN': 'clean',
'ZSH_THEME_GIT_PROMPT_UNTRACKED': 'untracked',
};
for (const [varName, formatKey] of Object.entries(gitPromptVars)) {
if (trimmed.startsWith(varName)) {
const match = trimmed.match(new RegExp(`${varName}=["'](.+?)["']$`));
if (match) {
if (!theme.gitFormats)
theme.gitFormats = {};
// Construct branch format from prefix/suffix if we have them
if (formatKey === 'prefix' || formatKey === 'suffix') {
const prefix = formatKey === 'prefix' ? match[1] : theme.gitFormats.prefix || '';
const suffix = formatKey === 'suffix' ? match[1] : theme.gitFormats.suffix || '';
if (prefix || suffix) {
theme.gitFormats.branch = `${prefix}%b${suffix}`;
}
// Store the raw values too
theme.gitFormats[formatKey] = match[1];
}
else {
theme.gitFormats[formatKey] = match[1];
}
}
}
}
// Parse variables (skip prompts, formats, and git theme variables)
const varMatch = trimmed.match(/^([A-Z_][A-Z0-9_]*)=["']?(.+?)["']?$/);
if (varMatch &&
!trimmed.includes('PROMPT') &&
!trimmed.includes('FMT_') &&
!trimmed.startsWith('ZSH_THEME_GIT_PROMPT_') &&
!trimmed.startsWith('PS2') &&
!trimmed.startsWith('PS3')) {
theme.variables.set(varMatch[1], varMatch[2]);
}
// Parse hooks
if (trimmed.includes('add-zsh-hook')) {
const hookMatch = trimmed.match(/add-zsh-hook\s+(\w+)\s+(\w+)/);
if (hookMatch) {
theme.hooks.push(`${hookMatch[1]}:${hookMatch[2]}`);
}
}
// Detect dependencies
if (trimmed.includes('vcs_info')) {
if (!theme.dependencies.includes('vcs_info')) {
theme.dependencies.push('vcs_info');
}
}
if (trimmed.includes('git_branch') || trimmed.includes('git_prompt_info') || trimmed.includes('$(git ')) {
if (!theme.dependencies.includes('git')) {
theme.dependencies.push('git');
}
}
if (trimmed.includes('virtualenv_prompt_info')) {
if (!theme.dependencies.includes('virtualenv')) {
theme.dependencies.push('virtualenv');
}
}
if (trimmed.includes('ruby_prompt_info')) {
if (!theme.dependencies.includes('ruby')) {
theme.dependencies.push('ruby');
}
}
}
// Post-processing: Check prompts for dependencies
const allPrompts = [
theme.prompts.left,
theme.prompts.right,
theme.prompts.continuation,
theme.prompts.select
].filter(Boolean).join(' ');
if (allPrompts.includes('git_branch') || allPrompts.includes('git_prompt_info') || allPrompts.includes('$(git ')) {
if (!theme.dependencies.includes('git')) {
theme.dependencies.push('git');
}
}
if (allPrompts.includes('virtualenv_prompt_info')) {
if (!theme.dependencies.includes('virtualenv')) {
theme.dependencies.push('virtualenv');
}
}
if (allPrompts.includes('vcs_info')) {
if (!theme.dependencies.includes('vcs_info')) {
theme.dependencies.push('vcs_info');
}
}
return theme;
}
/**
* Convert ZSH prompt format to LSH format
*/
convertPromptToLsh(zshPrompt, colors) {
let lshPrompt = zshPrompt;
// Convert color variables
for (const [name, color] of colors.entries()) {
// Map ZSH color codes to chalk colors
const chalkColor = this.mapColorToChalk(color);
lshPrompt = lshPrompt.replace(new RegExp(`\\$\\{${name}\\}`, 'g'), chalkColor);
}
// Convert ZSH prompt escapes to LSH equivalents
const conversions = [
[/%n/g, process.env.USER || 'user'], // username
[/%m/g, os.hostname().split('.')[0]], // hostname (short)
[/%M/g, os.hostname()], // hostname (full)
[/%~|%d/g, '$(pwd | sed "s|^$HOME|~|")'], // current directory
[/%\//g, '$(pwd)'], // current directory (full)
[/%c/g, '$(basename "$(pwd)")'], // current directory (basename)
[/%D/g, '$(date +"%m/%d/%y")'], // date
[/%T/g, '$(date +"%H:%M")'], // time (24h)
[/%t/g, '$(date +"%I:%M %p")'], // time (12h)
[/%#/g, '\\$'], // # for root, $ for user
[/%\{.*?reset_color.*?\}/g, chalk.reset('')], // reset color
[/%\{.*?\}/g, ''], // remove other ZSH escapes
];
for (const [pattern, replacement] of conversions) {
lshPrompt = lshPrompt.replace(pattern, replacement);
}
// Handle git info
if (lshPrompt.includes('$vcs_info_msg_0_')) {
lshPrompt = lshPrompt.replace(/\$vcs_info_msg_0_/g, '$(git_prompt_info)');
}
// Handle virtualenv
if (lshPrompt.includes('$(virtualenv_prompt_info)')) {
lshPrompt = lshPrompt.replace(/\$\(virtualenv_prompt_info\)/g, '$([ -n "$VIRTUAL_ENV" ] && echo " ($(basename $VIRTUAL_ENV))")');
}
return lshPrompt;
}
/**
* Map ZSH color code to chalk color
*/
mapColorToChalk(zshColor) {
// 256 color codes
const colorMap = {
'81': 'cyan',
'166': 'yellow',
'135': 'magenta',
'161': 'red',
'118': 'green',
'208': 'yellow',
'39': 'blue',
'214': 'yellow',
// Standard colors
'black': 'black',
'red': 'red',
'green': 'green',
'yellow': 'yellow',
'blue': 'blue',
'magenta': 'magenta',
'cyan': 'cyan',
'white': 'white',
};
const mapped = colorMap[zshColor] || 'white';
return `\\[\\033[${this.getAnsiCode(mapped)}m\\]`;
}
/**
* Get ANSI code for color name
*/
getAnsiCode(color) {
const codes = {
'black': '30',
'red': '31',
'green': '32',
'yellow': '33',
'blue': '34',
'magenta': '35',
'cyan': '36',
'white': '37',
'reset': '0',
'bold': '1',
'dim': '2',
'italic': '3',
'underline': '4',
};
return codes[color] || '37';
}
/**
* Save theme in LSH format
*/
saveAsLshTheme(theme) {
try {
const lshThemePath = path.join(this.customThemesPath, `${theme.name}.lsh-theme`);
const lshThemeContent = {
name: theme.name,
prompts: {
left: theme.prompts.left,
right: theme.prompts.right,
continuation: theme.prompts.continuation,
select: theme.prompts.select,
},
colors: Object.fromEntries(theme.colors),
gitFormats: theme.gitFormats,
variables: Object.fromEntries(theme.variables),
hooks: theme.hooks,
dependencies: theme.dependencies,
};
fs.writeFileSync(lshThemePath, JSON.stringify(lshThemeContent, null, 2), 'utf8');
}
catch (error) {
// Gracefully handle save errors (e.g., permission issues, invalid paths)
console.error(`Failed to save theme ${theme.name}:`, error);
}
}
/**
* Apply theme
*/
applyTheme(theme) {
this.currentTheme = theme;
// Generate prompt setting commands
const commands = [];
// Set main prompt
if (theme.prompts.left) {
const lshPrompt = this.convertPromptToLsh(theme.prompts.left, theme.colors);
commands.push(`export LSH_PROMPT='${lshPrompt}'`);
}
// Set right prompt
if (theme.prompts.right) {
const lshRPrompt = this.convertPromptToLsh(theme.prompts.right, theme.colors);
commands.push(`export LSH_RPROMPT='${lshRPrompt}'`);
}
return commands.join('\n');
}
/**
* Get built-in theme
*/
getBuiltinTheme(name) {
const themes = {
'default': {
name: 'default',
colors: new Map([['blue', '34'], ['green', '32'], ['reset', '0']]),
prompts: {
left: chalk.blue('%n@%m') + ':' + chalk.green('%~') + '$ ',
},
variables: new Map(),
hooks: [],
dependencies: [],
},
'minimal': {
name: 'minimal',
colors: new Map([['cyan', '36'], ['reset', '0']]),
prompts: {
left: chalk.cyan('%~') + ' ❯ ',
},
variables: new Map(),
hooks: [],
dependencies: [],
},
'powerline': {
name: 'powerline',
colors: new Map([
['blue', '34'],
['white', '37'],
['green', '32'],
['yellow', '33'],
]),
prompts: {
left: chalk.bgBlue.white(' %n ') + '' + chalk.bgGreen.black(' %~ ') + '' + ' ',
},
variables: new Map(),
hooks: [],
dependencies: ['git'],
},
'robbyrussell': {
name: 'robbyrussell',
colors: new Map([['cyan', '36'], ['red', '31'], ['green', '32']]),
prompts: {
left: chalk.cyan('➜ ') + chalk.cyan('%~') + ' $(git_prompt_info)',
},
gitFormats: {
branch: chalk.red(' git:') + chalk.green('(%b)'),
},
variables: new Map(),
hooks: [],
dependencies: ['git'],
},
'simple': {
name: 'simple',
colors: new Map([['reset', '0']]),
prompts: {
left: '%~ $ ',
},
variables: new Map(),
hooks: [],
dependencies: [],
},
};
if (!themes[name]) {
throw new Error(`Theme not found: ${name}`);
}
return themes[name];
}
/**
* Preview theme without applying
*/
previewTheme(theme) {
console.log(chalk.bold(`\n📦 Theme: ${theme.name}\n`));
// Show prompt
console.log(chalk.dim('Prompt:'));
const examplePrompt = this.convertPromptToLsh(theme.prompts.left, theme.colors)
.replace('$(pwd | sed "s|^$HOME|~|")', '~/projects/my-app')
.replace('$(git_prompt_info)', ' git:(main)');
console.log(' ' + examplePrompt);
if (theme.prompts.right) {
console.log(chalk.dim('\nRight Prompt:'));
console.log(' ' + this.convertPromptToLsh(theme.prompts.right, theme.colors));
}
// Show colors
if (theme.colors.size > 0) {
console.log(chalk.dim('\nColors:'));
for (const [name, code] of theme.colors.entries()) {
const color = this.mapColorToChalk(code);
console.log(` ${name}: ${color}${name}${chalk.reset('')}`);
}
}
// Show dependencies
if (theme.dependencies.length > 0) {
console.log(chalk.dim('\nDependencies:'));
theme.dependencies.forEach(dep => console.log(` - ${dep}`));
}
console.log('');
}
}