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:
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user