diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f80b8e2..fafe1b3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,6 +49,8 @@ fn run_startup_tasks() { theme::seed_default_themes(vp_str); // Seed theme/ with built-in vault theme notes if missing theme::seed_vault_themes(vp_str); + // Seed type/theme.md so the Theme type has an icon in the sidebar + let _ = theme::ensure_theme_type_definition(vp_str); // Register Laputa MCP server in Claude Code and Cursor configs match mcp::register_mcp(vp_str) { diff --git a/src-tauri/src/theme/defaults.rs b/src-tauri/src/theme/defaults.rs index 64749fea..8ece8d76 100644 --- a/src-tauri/src/theme/defaults.rs +++ b/src-tauri/src/theme/defaults.rs @@ -329,3 +329,15 @@ editor-max-width: 680\n\ # Minimal Theme\n\ \n\ High contrast, minimal chrome. Monospace typography throughout.\n"; + +/// Type definition for the Theme note type. +pub const THEME_TYPE_DEFINITION: &str = "---\n\ +Is A: Type\n\ +icon: palette\n\ +color: purple\n\ +order: 50\n\ +---\n\ +\n\ +# Theme\n\ +\n\ +A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n"; diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs index 727d9d09..4d2d019b 100644 --- a/src-tauri/src/theme/mod.rs +++ b/src-tauri/src/theme/mod.rs @@ -10,7 +10,8 @@ use std::path::Path; pub use create::{create_theme, create_vault_theme}; pub use defaults::*; pub use seed::{ - ensure_vault_themes, restore_default_themes, seed_default_themes, seed_vault_themes, + ensure_theme_type_definition, ensure_vault_themes, restore_default_themes, + seed_default_themes, seed_vault_themes, }; /// A theme file parsed from _themes/*.json in the vault. diff --git a/src-tauri/src/theme/seed.rs b/src-tauri/src/theme/seed.rs index a9fa8377..0037e6a1 100644 --- a/src-tauri/src/theme/seed.rs +++ b/src-tauri/src/theme/seed.rs @@ -104,9 +104,26 @@ pub fn restore_default_themes(vault_path: &str) -> Result { // Seed theme/ markdown notes (reuses ensure_vault_themes for consistency) ensure_vault_themes(vault_path)?; + // Seed type/theme.md so the Theme type has an icon and label in the sidebar + ensure_theme_type_definition(vault_path)?; + Ok("Default themes restored".to_string()) } +/// Create `type/theme.md` if it doesn't exist (gives the Theme type a sidebar icon/color). +pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> { + let type_dir = Path::new(vault_path).join("type"); + fs::create_dir_all(&type_dir) + .map_err(|e| format!("Failed to create type directory: {e}"))?; + let path = type_dir.join("theme.md"); + let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0); + if needs_write { + fs::write(&path, THEME_TYPE_DEFINITION) + .map_err(|e| format!("Failed to write type/theme.md: {e}"))?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -244,6 +261,46 @@ mod tests { assert!(vault.join("theme").join("default.md").exists()); assert!(vault.join("theme").join("dark.md").exists()); assert!(vault.join("theme").join("minimal.md").exists()); + assert!( + vault.join("type").join("theme.md").exists(), + "restore must create type/theme.md" + ); + let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap(); + assert!(type_content.contains("Is A: Type")); + assert!(type_content.contains("icon: palette")); + } + + #[test] + fn test_ensure_theme_type_definition_creates_file() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let vp = vault.to_str().unwrap(); + + ensure_theme_type_definition(vp).unwrap(); + let path = vault.join("type").join("theme.md"); + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("Is A: Type")); + assert!(content.contains("icon: palette")); + } + + #[test] + fn test_ensure_theme_type_definition_is_idempotent() { + let dir = TempDir::new().unwrap(); + let vault = dir.path().join("vault"); + let type_dir = vault.join("type"); + fs::create_dir_all(&type_dir).unwrap(); + let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n"; + fs::write(type_dir.join("theme.md"), custom).unwrap(); + let vp = vault.to_str().unwrap(); + + ensure_theme_type_definition(vp).unwrap(); + let content = fs::read_to_string(type_dir.join("theme.md")).unwrap(); + assert!( + content.contains("swatches"), + "existing content must be preserved" + ); } #[test] diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 8f0d3bc9..e85bb928 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec Vec Vec { - run_git(vault, &["status", "--porcelain"]) + // Modified/staged tracked files from git status --porcelain + let mut files: Vec = run_git(vault, &["status", "--porcelain"]) .map(|s| collect_md_paths_from_porcelain(&s)) - .unwrap_or_default() + .unwrap_or_default(); + + // Untracked files via ls-files (lists individual files, not just directories). + // git status --porcelain shows `?? dir/` for new directories, hiding individual + // files inside — ls-files resolves them so the cache picks up all new .md files. + let untracked = run_git( + vault, + &["ls-files", "--others", "--exclude-standard"], + ) + .map(|s| { + s.lines() + .filter(|l| !l.is_empty() && l.ends_with(".md")) + .map(|l| l.to_string()) + .collect::>() + }) + .unwrap_or_default(); + + for path in untracked { + if !files.contains(&path) { + files.push(path); + } + } + + files } fn load_cache(vault: &Path) -> Option { @@ -541,4 +560,66 @@ mod tests { assert!(titles.contains(&"Existing")); assert!(titles.contains(&"New Note")); } + + #[test] + fn test_update_same_commit_new_files_in_new_subdirectory() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + + std::process::Command::new("git") + .args(["init"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(vault) + .output() + .unwrap(); + + create_test_file(vault, "existing.md", "# Existing\n"); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(vault) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(vault) + .output() + .unwrap(); + + // Prime cache + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + + // Create files in a new subdirectory (simulates restore_default_themes) + create_test_file( + vault, + "theme/default.md", + "---\nIs A: Theme\n---\n# Default Theme\n", + ); + create_test_file( + vault, + "theme/dark.md", + "---\nIs A: Theme\n---\n# Dark Theme\n", + ); + + // Cache same commit — files in new subdirectory must appear + let entries2 = scan_vault_cached(vault).unwrap(); + assert_eq!( + entries2.len(), + 3, + "must pick up files in new untracked subdirectory" + ); + let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect(); + assert!(titles.contains(&"Existing")); + assert!(titles.contains(&"Default Theme")); + assert!(titles.contains(&"Dark Theme")); + } } diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index e63c1efc..39135c34 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -133,6 +133,10 @@ const SAMPLE_FILES: &[SampleFile] = &[ rel_path: "type/topic.md", content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n", }, + SampleFile { + rel_path: "type/theme.md", + content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n", + }, SampleFile { rel_path: "note/welcome-to-laputa.md", content: r#"--- @@ -394,7 +398,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result .map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?; } - // Seed built-in themes + // Seed built-in themes (both JSON legacy and vault-based markdown notes) let themes_dir = vault_dir.join("_themes"); fs::create_dir_all(&themes_dir) .map_err(|e| format!("Failed to create _themes directory: {e}"))?; @@ -405,6 +409,16 @@ pub fn create_getting_started_vault(target_path: &str) -> Result fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME) .map_err(|e| format!("Failed to write minimal theme: {e}"))?; + let theme_notes_dir = vault_dir.join("theme"); + fs::create_dir_all(&theme_notes_dir) + .map_err(|e| format!("Failed to create theme directory: {e}"))?; + fs::write(theme_notes_dir.join("default.md"), crate::theme::DEFAULT_VAULT_THEME) + .map_err(|e| format!("Failed to write default vault theme: {e}"))?; + fs::write(theme_notes_dir.join("dark.md"), crate::theme::DARK_VAULT_THEME) + .map_err(|e| format!("Failed to write dark vault theme: {e}"))?; + fs::write(theme_notes_dir.join("minimal.md"), crate::theme::MINIMAL_VAULT_THEME) + .map_err(|e| format!("Failed to write minimal vault theme: {e}"))?; + crate::git::init_repo(target_path)?; Ok(vault_dir @@ -507,8 +521,8 @@ mod tests { create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); let entries = crate::vault::scan_vault(&vault_path).unwrap(); - // SAMPLE_FILES + AGENTS.md - assert_eq!(entries.len(), SAMPLE_FILES.len() + 1); + // SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md) + assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3); } #[test] @@ -583,12 +597,21 @@ mod tests { let vault_path = dir.path().join("theme-vault"); create_getting_started_vault(vault_path.to_str().unwrap()).unwrap(); + // JSON legacy themes assert!(vault_path.join("_themes/default.json").exists()); assert!(vault_path.join("_themes/dark.json").exists()); assert!(vault_path.join("_themes/minimal.json").exists()); let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap(); assert_eq!(themes.len(), 3); + + // Vault-based theme notes + assert!(vault_path.join("theme/default.md").exists()); + assert!(vault_path.join("theme/dark.md").exists()); + assert!(vault_path.join("theme/minimal.md").exists()); + + // Theme type definition + assert!(vault_path.join("type/theme.md").exists()); } #[test] diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 74990a03..44289ce5 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -319,6 +319,16 @@ line-height-base: 1.6 # ${displayName} ` + const now = Date.now() / 1000 + MOCK_ENTRIES.push({ + path, filename: `${slug}.md`, title: displayName, isA: 'Theme', + aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, + cadence: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`, + wordCount: 10, relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + outgoingLinks: [], properties: {}, + }) syncWindowContent() return path },