feat: introduced a token-based theming system across the UI
* feat: added themes system * feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold * feat: added Checkbox component and update theming - Add reusable Checkbox component for toggles across UI - Replace several inputs with Checkbox in settings and commands panels - Add DiffIcon and apply surface/border theming to key UI areas * feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format * refactor: remove unused permission logic from ChatInput - Remove unused permission rules parsing logic from ChatInput - Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations - Remove forceOpaque helper in vscode theme adapter * fix: guard VSCode theme loading in MarkdownRenderer * feat: add custom user themes loading and reload - Load user themes from ~/.config/openchamber/themes at runtime - Expose /api/config/themes to fetch custom themes - Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
committed by
GitHub
parent
5bb2c56bc0
commit
ddedc02687
@@ -65,6 +65,163 @@ const normalizeDirectoryPath = (value) => {
|
||||
};
|
||||
|
||||
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
|
||||
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
|
||||
|
||||
const MAX_THEME_JSON_BYTES = 512 * 1024;
|
||||
|
||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const isValidThemeColor = (value) => isNonEmptyString(value);
|
||||
|
||||
const normalizeThemeJson = (raw) => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null;
|
||||
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null;
|
||||
if (!metadata || !colors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = metadata.id;
|
||||
const name = metadata.name;
|
||||
const variant = metadata.variant;
|
||||
if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primary = colors.primary;
|
||||
const surface = colors.surface;
|
||||
const interactive = colors.interactive;
|
||||
const status = colors.status;
|
||||
const syntax = colors.syntax;
|
||||
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
|
||||
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
|
||||
|
||||
if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimal fields required by CSSVariableGenerator and diff/syntax rendering.
|
||||
const required = [
|
||||
primary.base,
|
||||
primary.foreground,
|
||||
surface.background,
|
||||
surface.foreground,
|
||||
surface.muted,
|
||||
surface.mutedForeground,
|
||||
surface.elevated,
|
||||
surface.elevatedForeground,
|
||||
surface.subtle,
|
||||
interactive.border,
|
||||
interactive.selection,
|
||||
interactive.selectionForeground,
|
||||
interactive.focusRing,
|
||||
interactive.hover,
|
||||
status.error,
|
||||
status.errorForeground,
|
||||
status.errorBackground,
|
||||
status.errorBorder,
|
||||
status.warning,
|
||||
status.warningForeground,
|
||||
status.warningBackground,
|
||||
status.warningBorder,
|
||||
status.success,
|
||||
status.successForeground,
|
||||
status.successBackground,
|
||||
status.successBorder,
|
||||
status.info,
|
||||
status.infoForeground,
|
||||
status.infoBackground,
|
||||
status.infoBorder,
|
||||
syntaxBase.background,
|
||||
syntaxBase.foreground,
|
||||
syntaxBase.keyword,
|
||||
syntaxBase.string,
|
||||
syntaxBase.number,
|
||||
syntaxBase.function,
|
||||
syntaxBase.variable,
|
||||
syntaxBase.type,
|
||||
syntaxBase.comment,
|
||||
syntaxBase.operator,
|
||||
syntaxHighlights.diffAdded,
|
||||
syntaxHighlights.diffRemoved,
|
||||
syntaxHighlights.lineNumber,
|
||||
];
|
||||
|
||||
if (!required.every(isValidThemeColor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(metadata.tags)
|
||||
? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...raw,
|
||||
metadata: {
|
||||
...metadata,
|
||||
id: id.trim(),
|
||||
name: name.trim(),
|
||||
description: typeof metadata.description === 'string' ? metadata.description : '',
|
||||
version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0',
|
||||
variant,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readCustomThemesFromDisk = async () => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(OPENCHAMBER_USER_THEMES_DIR, { withFileTypes: true });
|
||||
const themes = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
||||
|
||||
const filePath = path.join(OPENCHAMBER_USER_THEMES_DIR, entry.name);
|
||||
try {
|
||||
const stat = await fsPromises.stat(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
if (stat.size > MAX_THEME_JSON_BYTES) {
|
||||
console.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = await fsPromises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(rawText);
|
||||
const normalized = normalizeThemeJson(parsed);
|
||||
if (!normalized) {
|
||||
console.warn(`[themes] Skip ${entry.name}: invalid theme JSON`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = normalized.metadata.id;
|
||||
if (seen.has(id)) {
|
||||
console.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
themes.push(normalized);
|
||||
} catch (error) {
|
||||
console.warn(`[themes] Failed to read ${entry.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return themes;
|
||||
} catch (error) {
|
||||
// Missing dir is fine.
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
console.warn('[themes] Failed to list custom themes dir:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const isPathWithinRoot = (resolvedPath, rootPath) => {
|
||||
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
||||
@@ -1092,13 +1249,58 @@ const migrateSettingsFromLegacyLastDirectory = async (current) => {
|
||||
return { settings: merged, changed: true };
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyThemePreferences = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
|
||||
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
|
||||
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
|
||||
|
||||
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
|
||||
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
|
||||
|
||||
if (hasLight && hasDark) {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const defaultLight = 'flexoki-light';
|
||||
const defaultDark = 'flexoki-dark';
|
||||
|
||||
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
|
||||
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
|
||||
|
||||
if (!hasLight) {
|
||||
if (themeId && themeVariant === 'light') {
|
||||
nextLightThemeId = themeId;
|
||||
} else {
|
||||
nextLightThemeId = defaultLight;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDark) {
|
||||
if (themeId && themeVariant === 'dark') {
|
||||
nextDarkThemeId = themeId;
|
||||
} else {
|
||||
nextDarkThemeId = defaultDark;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergePersistedSettings(settings, {
|
||||
...settings,
|
||||
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
|
||||
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
|
||||
});
|
||||
|
||||
return { settings: merged, changed: true };
|
||||
};
|
||||
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
const current = await readSettingsFromDisk();
|
||||
const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
|
||||
if (changed) {
|
||||
await writeSettingsToDisk(settings);
|
||||
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
|
||||
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
|
||||
if (migration1.changed || migration2.changed) {
|
||||
await writeSettingsToDisk(migration2.settings);
|
||||
}
|
||||
return settings;
|
||||
return migration2.settings;
|
||||
};
|
||||
|
||||
const getOrCreateVapidKeys = async () => {
|
||||
@@ -3338,6 +3540,16 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/themes', async (_req, res) => {
|
||||
try {
|
||||
const customThemes = await readCustomThemesFromDisk();
|
||||
res.json({ themes: customThemes });
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom themes:', error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/config/settings', async (req, res) => {
|
||||
console.log(`[API:PUT /api/config/settings] Received request`);
|
||||
console.log(`[API:PUT /api/config/settings] Request body:`, JSON.stringify(req.body, null, 2));
|
||||
|
||||
Reference in New Issue
Block a user