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:
Bohdan Triapitsyn
2026-02-01 18:29:34 +02:00
committed by GitHub
parent 5bb2c56bc0
commit ddedc02687
147 changed files with 9853 additions and 2435 deletions
+57 -12
View File
@@ -5,8 +5,8 @@ import type { SupportedLanguages } from '@pierre/diffs';
import { useOptionalThemeSystem } from './useThemeSystem';
import { workerFactory } from '@/lib/diff/workerFactory';
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { useGitStore } from '@/stores/useGitStore';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
@@ -31,14 +31,14 @@ const PRELOAD_LANGS: SupportedLanguages[] = [
const WARMUP_MAX_FILES = 10;
// Matches cache key logic in `packages/ui/src/components/views/PierreDiffViewer.tsx`
function getPierreCacheKey(fileName: string, original: string, modified: string): string {
function getPierreCacheKey(fileName: string, original: string, modified: string, themeKey: string): string {
const sampleOriginal = original.length > 100
? `${original.slice(0, 50)}${original.slice(-50)}`
: original;
const sampleModified = modified.length > 100
? `${modified.slice(0, 50)}${modified.slice(-50)}`
: modified;
return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
return `${themeKey}::${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
}
interface DiffWorkerProviderProps {
@@ -57,7 +57,11 @@ function scheduleWarmupWork(cb: (deadline?: IdleDeadlineLike) => void): () => vo
}
// Component that warms up the worker pool and precomputes diff ASTs
const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const WorkerPoolWarmup: React.FC<{
children: React.ReactNode;
themeKey: string;
renderTheme: { light: string; dark: string };
}> = ({ children, themeKey, renderTheme }) => {
const workerPool = useWorkerPool();
const activeDirectory = useGitStore((state) => state.activeDirectory);
const lastStatusChange = useGitStore((state) => {
@@ -72,6 +76,20 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
const didDummyWarmupRef = useRef(false);
const warmedStatusRef = useRef(new Map<string, number>());
useEffect(() => {
warmedStatusRef.current.clear();
}, [themeKey]);
useEffect(() => {
if (!workerPool) {
return;
}
// Important: WorkerPoolContextProvider uses a singleton and does not react to
// prop changes. Update the worker pool render options explicitly.
void workerPool.setRenderOptions({ theme: renderTheme });
}, [renderTheme, workerPool]);
useEffect(() => {
if (!workerPool || didDummyWarmupRef.current) return;
@@ -128,7 +146,7 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
index += 1;
const language = getLanguageFromExtension(filePath) || 'text';
const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified);
const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified, themeKey);
const oldFile: FileContents = {
name: filePath,
@@ -171,7 +189,7 @@ const WorkerPoolWarmup: React.FC<{ children: React.ReactNode }> = ({ children })
cancelled = true;
cancelScheduled?.();
};
}, [workerPool, activeDirectory, lastStatusChange, diffCacheSize]);
}, [workerPool, activeDirectory, lastStatusChange, diffCacheSize, themeKey]);
return <>{children}</>;
};
@@ -180,16 +198,40 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
ensureFlexokiThemesRegistered();
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id;
const lightTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ??
fallbackLight;
const darkTheme =
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
fallbackDark;
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
const highlighterOptions = useMemo(() => ({
theme: {
dark: flexokiThemeNames.dark,
light: flexokiThemeNames.light,
dark: darkTheme.metadata.id,
light: lightTheme.metadata.id,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
langs: PRELOAD_LANGS,
}), [isDark]);
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id]);
const workerThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`;
const renderTheme = useMemo(
() => ({
light: lightTheme.metadata.id,
dark: darkTheme.metadata.id,
}),
[darkTheme.metadata.id, lightTheme.metadata.id],
);
return (
<WorkerPoolContextProvider
@@ -200,7 +242,10 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
}}
highlighterOptions={highlighterOptions}
>
<WorkerPoolWarmup>
<WorkerPoolWarmup
themeKey={workerThemeKey}
renderTheme={renderTheme}
>
{children}
</WorkerPoolWarmup>
</WorkerPoolContextProvider>