118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { useStore } from '../store';
|
|
|
|
type Theme = 'dark' | 'light' | 'system';
|
|
type ResolvedTheme = 'dark' | 'light';
|
|
|
|
const THEME_CACHE_KEY = 'claude-devtools-theme-cache';
|
|
|
|
/**
|
|
* Hook to manage theme state and application.
|
|
* - Fetches theme preference from config on mount
|
|
* - Listens to system theme changes when set to 'system'
|
|
* - Applies theme class to document root
|
|
* - Caches theme in localStorage for flash prevention
|
|
*/
|
|
export function useTheme(): {
|
|
theme: Theme;
|
|
resolvedTheme: ResolvedTheme;
|
|
isDark: boolean;
|
|
isLight: boolean;
|
|
} {
|
|
const { appConfig, fetchConfig } = useStore(
|
|
useShallow((s) => ({
|
|
appConfig: s.appConfig,
|
|
fetchConfig: s.fetchConfig,
|
|
}))
|
|
);
|
|
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => {
|
|
// Initialize from cache to prevent flash
|
|
try {
|
|
const cached = localStorage.getItem(THEME_CACHE_KEY);
|
|
if (cached === 'light' || cached === 'dark') return cached;
|
|
} catch {
|
|
// localStorage may not be available
|
|
}
|
|
// No cache — detect system preference for flash-free first launch
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
});
|
|
|
|
// Fetch config on mount if not loaded.
|
|
// The centralized init chain also calls fetchConfig — configLoading guard
|
|
// in the store action prevents duplicate IPC calls.
|
|
const configLoading = useStore((s) => s.configLoading);
|
|
useEffect(() => {
|
|
if (!appConfig && !configLoading) {
|
|
void fetchConfig();
|
|
}
|
|
}, [appConfig, configLoading, fetchConfig]);
|
|
|
|
// Get configured theme
|
|
const configuredTheme: Theme = appConfig?.general?.theme ?? 'system';
|
|
|
|
// Get system theme preference
|
|
const getSystemTheme = useCallback((): ResolvedTheme => {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}, []);
|
|
|
|
// Resolve 'system' theme and listen for changes
|
|
useEffect(() => {
|
|
const updateTheme = (): void => {
|
|
const resolved = configuredTheme === 'system' ? getSystemTheme() : configuredTheme;
|
|
setResolvedTheme(resolved);
|
|
|
|
// Cache for flash prevention
|
|
try {
|
|
localStorage.setItem(THEME_CACHE_KEY, resolved);
|
|
} catch {
|
|
// localStorage may not be available
|
|
}
|
|
};
|
|
|
|
updateTheme();
|
|
|
|
// Listen to system theme changes when in 'system' mode
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const handleChange = (): void => {
|
|
if (configuredTheme === 'system') {
|
|
updateTheme();
|
|
}
|
|
};
|
|
|
|
mediaQuery.addEventListener('change', handleChange);
|
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
}, [configuredTheme, getSystemTheme]);
|
|
|
|
// Apply theme class to document root
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
const body = document.body;
|
|
|
|
body.classList.add('theme-transitioning');
|
|
|
|
// Remove existing theme classes
|
|
root.classList.remove('dark', 'light');
|
|
|
|
// Add new theme class
|
|
root.classList.add(resolvedTheme);
|
|
|
|
const timer = window.setTimeout(() => {
|
|
body.classList.remove('theme-transitioning');
|
|
}, 250);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
body.classList.remove('theme-transitioning');
|
|
};
|
|
}, [resolvedTheme]);
|
|
|
|
return {
|
|
theme: configuredTheme,
|
|
resolvedTheme,
|
|
isDark: resolvedTheme === 'dark',
|
|
isLight: resolvedTheme === 'light',
|
|
};
|
|
}
|