feat: Add 'Restore Default Themes' command for vaults missing theme structure

- New restore_default_themes() Rust fn: seeds both _themes/ and theme/ dirs
- Per-file idempotent: never overwrites existing files with content
- Fixed ensure_vault_themes() to include minimal.md (was missing)
- New 'Restore Default Themes' command in Cmd+K Appearance group
- 3 new Rust tests + 4 new frontend tests
- 1676 frontend tests passed
This commit is contained in:
Test
2026-03-05 11:18:53 +01:00
parent c78018b92a
commit c371e26dcb
7 changed files with 157 additions and 3 deletions

View File

@@ -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<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
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")

View File

@@ -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<String, String> {
// 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<String, String> {
@@ -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"));
}
}

View File

@@ -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<string>('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,

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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,
])

View File

@@ -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 {