fix: vault cache misses files in new directories, breaking theme restore

Root cause: git_uncommitted_files() used `git status --porcelain` which
reports new untracked directories as `?? theme/` instead of listing
individual files inside. The .md filter skipped directory entries, so
files in newly-created directories (theme/default.md, etc.) were
invisible to the cache system.

Fix: additionally use `git ls-files --others --exclude-standard` which
lists individual untracked files, resolving directories to their
contents.

Also:
- Add type/theme.md definition (icon: palette) to restore and getting-started vault
- Seed theme/*.md vault notes in getting-started vault
- Fix mock create_vault_theme to add entries for dev mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-06 15:38:54 +01:00
parent edcb306c7f
commit 6f6e7d7cfe
7 changed files with 199 additions and 13 deletions

View File

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

View File

@@ -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";

View File

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

View File

@@ -104,9 +104,26 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// 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]

View File

@@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
.map(|s| collect_md_paths_from_diff(&s))
.unwrap_or_default();
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
// Include uncommitted changes (modified, staged, and untracked files).
let uncommitted = git_uncommitted_files(vault);
// Also include modified-but-unstaged files via status --porcelain.
let modified = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default();
for path in uncommitted.into_iter().chain(modified) {
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
@@ -97,9 +92,33 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
}
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
// Modified/staged tracked files from git status --porcelain
let mut files: Vec<String> = 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::<Vec<_>>()
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
}
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -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"));
}
}

View File

@@ -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<String, String>
.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<String, String>
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]