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:
@@ -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")
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
14
src/App.tsx
14
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<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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user