diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12bb8c4a..21085860 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -327,6 +327,7 @@ fn create_theme(vault_path: String, source_id: Option) -> Result) { match result { Ok(n) if n > 0 => log::info!("{}: {} files", label, n), @@ -353,6 +354,9 @@ fn run_startup_tasks() { vault::migrate_is_a_to_type(vp_str), ); + // Seed _themes/ with built-in themes if missing + theme::seed_default_themes(vp_str); + // Register Laputa MCP server in Claude Code and Cursor configs match mcp::register_mcp(vp_str) { Ok(status) => log::info!("MCP registration: {status}"), diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs index 01544366..4ecfce1b 100644 --- a/src-tauri/src/theme.rs +++ b/src-tauri/src/theme.rs @@ -28,8 +28,12 @@ pub struct VaultSettings { } /// List all theme files in _themes/ directory of the vault. +/// Seeds built-in themes if the directory is missing. pub fn list_themes(vault_path: &str) -> Result, String> { let themes_dir = Path::new(vault_path).join("_themes"); + if !themes_dir.is_dir() { + seed_default_themes(vault_path); + } if !themes_dir.is_dir() { return Ok(Vec::new()); } @@ -112,6 +116,22 @@ pub fn get_theme(vault_path: &str, theme_id: &str) -> Result parse_theme_file(&path) } +/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet. +/// Safe to call multiple times — only writes files that are missing. +pub fn seed_default_themes(vault_path: &str) { + let themes_dir = Path::new(vault_path).join("_themes"); + if themes_dir.is_dir() { + return; + } + if fs::create_dir_all(&themes_dir).is_err() { + return; + } + let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME); + let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME); + let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME); + log::info!("Seeded _themes/ with built-in themes"); +} + /// Create a new theme file by copying the active theme (or default). /// Returns the ID of the new theme. pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result { @@ -318,12 +338,16 @@ mod tests { } #[test] - fn test_list_themes_empty_when_no_dir() { + fn test_list_themes_seeds_defaults_when_no_dir() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("empty-vault"); fs::create_dir_all(&vault).unwrap(); let themes = list_themes(vault.to_str().unwrap()).unwrap(); - assert!(themes.is_empty()); + assert_eq!(themes.len(), 3); + let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"Default")); + assert!(names.contains(&"Dark")); + assert!(names.contains(&"Minimal")); } #[test] diff --git a/src/App.tsx b/src/App.tsx index 7bfb6fdc..a1b0125e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -286,7 +286,11 @@ function App() { onGoBack: handleGoBack, onGoForward: handleGoForward, canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward, themes: themeManager.themes, activeThemeId: themeManager.activeThemeId, - onSwitchTheme: themeManager.switchTheme, onCreateTheme: () => themeManager.createTheme(), + onSwitchTheme: themeManager.switchTheme, + onCreateTheme: async () => { + const newId = await themeManager.createTheme() + if (newId) await themeManager.switchTheme(newId) + }, }) const { status: updateStatus, actions: updateActions } = useUpdater() diff --git a/src/hooks/useThemeManager.test.ts b/src/hooks/useThemeManager.test.ts index 9cda2ae2..37712664 100644 --- a/src/hooks/useThemeManager.test.ts +++ b/src/hooks/useThemeManager.test.ts @@ -161,14 +161,15 @@ describe('useThemeManager', () => { it('handles load failure gracefully', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockInvokeFn.mockRejectedValueOnce(new Error('disk error')) + mockInvokeFn.mockRejectedValue(new Error('disk error')) const { result } = renderHook(() => useThemeManager('/vault')) - await new Promise(r => setTimeout(r, 50)) + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error)) + }) expect(result.current.themes).toHaveLength(0) expect(result.current.activeThemeId).toBeNull() - expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error)) warnSpy.mockRestore() }) diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts index 4b696e7c..41873bd5 100644 --- a/src/hooks/useThemeManager.ts +++ b/src/hooks/useThemeManager.ts @@ -12,7 +12,6 @@ function applyThemeToDom(theme: ThemeFile): void { const root = document.documentElement for (const [key, value] of Object.entries(theme.colors)) { root.style.setProperty(`--theme-${key}`, value) - // Also set the shadcn-compatible variables root.style.setProperty(`--${key}`, value) } for (const [key, value] of Object.entries(theme.typography)) { @@ -21,7 +20,6 @@ function applyThemeToDom(theme: ThemeFile): void { for (const [key, value] of Object.entries(theme.spacing)) { root.style.setProperty(`--theme-${key}`, value) } - // Map sidebar-background to --sidebar (shadcn convention) if (theme.colors['sidebar-background']) { root.style.setProperty('--sidebar', theme.colors['sidebar-background']) } @@ -89,6 +87,13 @@ export function useThemeManager(vaultPath: string | null): ThemeManager { useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme]) + // Reload themes when window regains focus (live reload for external edits) + useEffect(() => { + const onFocus = () => { loadThemes() } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [loadThemes]) + const switchTheme = useCallback(async (themeId: string) => { if (!vaultPath) return try {