fix: make 'New theme' command work in Tauri app (#156)

Three root causes fixed:
1. _themes/ directory was never auto-seeded for existing vaults -
   list_themes now seeds built-in themes if the directory is missing
2. Creating a theme produced no visible feedback - now switches to
   the newly created theme after creation
3. No live reload when theme files are edited externally - added
   focus-based theme reload so edits are picked up when the window
   regains focus

Also adds seed_default_themes to run_startup_tasks for the default
vault, ensuring themes are available on first launch.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-03-01 00:32:32 +01:00
committed by GitHub
parent ba1404b808
commit 669d473a64
5 changed files with 46 additions and 8 deletions

View File

@@ -327,6 +327,7 @@ fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String,
theme::create_theme(&vault_path, source_id.as_deref())
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
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}"),

View File

@@ -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<Vec<ThemeFile>, 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<ThemeFile, String>
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<String, String> {
@@ -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]

View File

@@ -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()

View File

@@ -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()
})

View File

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