diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4bc3e91..3c7b70d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -500,6 +500,12 @@ fn ensure_vault_themes(vault_path: String) -> Result<(), String> { theme::ensure_vault_themes(&vault_path) } +#[tauri::command] +fn restore_default_themes(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + theme::restore_default_themes(&vault_path) +} + fn log_startup_result(label: &str, result: Result) { match result { Ok(n) if n > 0 => log::info!("{}: {} files", label, n), @@ -699,7 +705,8 @@ pub fn run() { set_active_theme, create_theme, create_vault_theme, - ensure_vault_themes + ensure_vault_themes, + restore_default_themes ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs index 84ad7791..865ae950 100644 --- a/src-tauri/src/theme.rs +++ b/src-tauri/src/theme.rs @@ -180,6 +180,7 @@ pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> { let defaults: &[(&str, &str)] = &[ ("default.md", DEFAULT_VAULT_THEME), ("dark.md", DARK_VAULT_THEME), + ("minimal.md", MINIMAL_VAULT_THEME), ]; for (name, content) in defaults { let path = theme_dir.join(name); @@ -191,6 +192,34 @@ pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> { Ok(()) } +/// Restore default themes for a vault: seeds both `_themes/` (JSON) and +/// `theme/` (markdown notes). Per-file idempotent — never overwrites files +/// that already have content. Returns an error on read-only filesystems. +pub fn restore_default_themes(vault_path: &str) -> Result { + // Seed _themes/ JSON files (per-file idempotent) + let themes_dir = Path::new(vault_path).join("_themes"); + fs::create_dir_all(&themes_dir) + .map_err(|e| format!("Failed to create _themes directory: {e}"))?; + let json_defaults: &[(&str, &str)] = &[ + ("default.json", DEFAULT_THEME), + ("dark.json", DARK_THEME), + ("minimal.json", MINIMAL_THEME), + ]; + for (name, content) in json_defaults { + let path = themes_dir.join(name); + let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0); + if needs_write { + fs::write(&path, content) + .map_err(|e| format!("Failed to write _themes/{name}: {e}"))?; + } + } + + // Seed theme/ markdown notes (reuses ensure_vault_themes for consistency) + ensure_vault_themes(vault_path)?; + + Ok("Default themes restored".to_string()) +} + /// Create a new vault theme note in `theme/` directory. /// Returns the absolute path to the newly created theme note. pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result { @@ -949,6 +978,7 @@ mod tests { assert!(vault.join("theme").is_dir()); assert!(vault.join("theme").join("default.md").exists()); assert!(vault.join("theme").join("dark.md").exists()); + assert!(vault.join("theme").join("minimal.md").exists()); } #[test] @@ -979,4 +1009,67 @@ mod tests { let content = fs::read_to_string(theme_dir.join("default.md")).unwrap(); assert!(content.contains("#123456")); } + + #[test] + fn test_restore_default_themes_creates_both_dirs() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + let msg = restore_default_themes(vp).unwrap(); + assert_eq!(msg, "Default themes restored"); + // _themes/ JSON files + assert!(vault.join("_themes").join("default.json").exists()); + assert!(vault.join("_themes").join("dark.json").exists()); + assert!(vault.join("_themes").join("minimal.json").exists()); + // theme/ markdown notes + assert!(vault.join("theme").join("default.md").exists()); + assert!(vault.join("theme").join("dark.md").exists()); + assert!(vault.join("theme").join("minimal.md").exists()); + } + + #[test] + fn test_restore_default_themes_is_idempotent() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + restore_default_themes(vp).unwrap(); + // Modify a theme file to verify it isn't overwritten + let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n"; + fs::write(vault.join("theme").join("default.md"), custom).unwrap(); + + restore_default_themes(vp).unwrap(); + let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap(); + assert!( + content.contains("#CUSTOM"), + "must not overwrite existing content" + ); + } + + #[test] + fn test_restore_default_themes_fills_partial_state() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + // Create _themes/ with only default.json, theme/ with only default.md + let themes_dir = vault.join("_themes"); + let theme_dir = vault.join("theme"); + fs::create_dir_all(&themes_dir).unwrap(); + fs::create_dir_all(&theme_dir).unwrap(); + fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap(); + fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap(); + let vp = vault.to_str().unwrap(); + + restore_default_themes(vp).unwrap(); + // Missing files should now exist + assert!(themes_dir.join("dark.json").exists()); + assert!(themes_dir.join("minimal.json").exists()); + assert!(theme_dir.join("dark.md").exists()); + assert!(theme_dir.join("minimal.md").exists()); + // Existing files should be unchanged + let content = fs::read_to_string(theme_dir.join("default.md")).unwrap(); + assert!(content.contains("Light theme with warm")); + } } diff --git a/src/App.tsx b/src/App.tsx index 19d18655..62733fbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -345,6 +345,19 @@ function App() { // 'available' → UpdateBanner handles it automatically }, [updateActions, updateStatus.state, setToastMessage]) + const handleRestoreDefaultThemes = useCallback(async () => { + if (!resolvedPath) return + try { + const tauriInvoke = isTauri() ? invoke : mockInvoke + const msg = await tauriInvoke('restore_default_themes', { vaultPath: resolvedPath }) + await vault.reloadVault() + await themeManager.reloadThemes() + setToastMessage(msg) + } catch (err) { + setToastMessage(`Failed to restore themes: ${err}`) + } + }, [resolvedPath, vault, themeManager, setToastMessage]) + const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs, @@ -396,6 +409,7 @@ function App() { onCheckForUpdates: handleCheckForUpdates, onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath), onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted, + onRestoreDefaultThemes: handleRestoreDefaultThemes, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, mcpStatus, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index baf6fc57..684e7710 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -62,6 +62,7 @@ interface AppCommandsConfig { onCheckForUpdates?: () => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void + onRestoreDefaultThemes?: () => void isGettingStartedHidden?: boolean vaultCount?: number mcpStatus?: string @@ -172,6 +173,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onCheckForUpdates: config.onCheckForUpdates, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, + onRestoreDefaultThemes: config.onRestoreDefaultThemes, isGettingStartedHidden: config.isGettingStartedHidden, vaultCount: config.vaultCount, mcpStatus: config.mcpStatus, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 91b9ed52..e4c987cf 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -703,6 +703,41 @@ describe('useCommandRegistry', () => { expect(cmd!.keywords).toContain('demo') }) }) + + describe('restore-default-themes command', () => { + it('has restore-default-themes command in Appearance group', () => { + const onRestoreDefaultThemes = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onRestoreDefaultThemes }))) + const cmd = result.current.find(c => c.id === 'restore-default-themes') + expect(cmd).toBeDefined() + expect(cmd!.label).toBe('Restore Default Themes') + expect(cmd!.group).toBe('Appearance') + expect(cmd!.enabled).toBe(true) + }) + + it('is disabled when onRestoreDefaultThemes is not provided', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const cmd = result.current.find(c => c.id === 'restore-default-themes') + expect(cmd).toBeDefined() + expect(cmd!.enabled).toBe(false) + }) + + it('calls onRestoreDefaultThemes when executed', () => { + const onRestoreDefaultThemes = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onRestoreDefaultThemes }))) + result.current.find(c => c.id === 'restore-default-themes')!.execute() + expect(onRestoreDefaultThemes).toHaveBeenCalled() + }) + + it('has relevant keywords for discoverability', () => { + const onRestoreDefaultThemes = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onRestoreDefaultThemes }))) + const cmd = result.current.find(c => c.id === 'restore-default-themes') + expect(cmd!.keywords).toContain('theme') + expect(cmd!.keywords).toContain('restore') + expect(cmd!.keywords).toContain('default') + }) + }) }) describe('pluralizeType', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 333b8c02..531843ad 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -60,6 +60,7 @@ interface CommandRegistryConfig { onOpenTheme?: (themeId: string) => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void + onRestoreDefaultThemes?: () => void isGettingStartedHidden?: boolean vaultCount?: number } @@ -194,7 +195,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onCheckForUpdates, onCreateType, - onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, + onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, } = config @@ -248,6 +249,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction // Appearance ...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme), + { id: 'restore-default-themes', label: 'Restore Default Themes', group: 'Appearance', keywords: ['theme', 'reset', 'restore', 'default', 'fix', 'missing'], enabled: !!onRestoreDefaultThemes, execute: () => onRestoreDefaultThemes?.() }, // Settings { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, @@ -271,7 +273,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, - vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, + vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, ]) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 07f46aea..b53c40e5 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -313,6 +313,7 @@ line-height-base: 1.6 return path }, ensure_vault_themes: (): null => null, + restore_default_themes: (): string => 'Default themes restored', } export function addMockEntry(_entry: VaultEntry, content: string): void {