fix: address folder tree QA feedback — recursive filtering, folder creation, path display, remove flatten banner

1. Folder selection now recursively includes notes from subfolders
2. + button creates a new folder with inline rename (Tauri command + UI)
3. Note list items show full path when note is in a subdirectory
4. Remove flat vault migration banner and all related code (Rust commands,
   hook, component, smoke test) — subfolders are now first-class

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-01 15:29:24 +02:00
parent 1488f6eb08
commit 01fdadf425
13 changed files with 88 additions and 730 deletions

View File

@@ -89,15 +89,15 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
}
#[tauri::command]
pub fn flatten_vault(vault_path: String) -> Result<usize, String> {
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::flatten_vault(&vault_path)
}
#[tauri::command]
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
let vault_path = expand_tilde(&vault_path);
vault::vault_health_check(&vault_path)
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
#[tauri::command]
@@ -227,7 +227,6 @@ pub async fn search_vault(
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::flatten_vault(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
@@ -362,7 +361,7 @@ mod tests {
}
#[test]
fn test_repair_vault_flattens_type_folders() {
fn test_repair_vault_migrates_is_a_to_type() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let note_dir = vault_path.join("note");
@@ -371,9 +370,8 @@ mod tests {
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert!(vault_path.join("hello.md").exists());
assert!(!note_dir.join("hello.md").exists());
let content = std::fs::read_to_string(vault_path.join("hello.md")).unwrap();
assert!(note_dir.join("hello.md").exists());
let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap();
assert!(content.contains("type: Note"));
assert!(!content.contains("is_a:"));
}

View File

@@ -155,8 +155,7 @@ pub fn run() {
commands::batch_delete_notes,
commands::empty_trash,
commands::migrate_is_a_to_type,
commands::flatten_vault,
commands::vault_health_check,
commands::create_vault_folder,
commands::batch_archive_notes,
commands::batch_trash_notes,
commands::get_settings,

View File

@@ -1,6 +1,3 @@
use regex::Regex;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
@@ -118,311 +115,6 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
Ok(migrated)
}
/// Folders that are NOT flattened — they contain assets, not notes.
const KEEP_FOLDERS: &[&str] = &["attachments", "assets"];
/// Determine a unique filename at `dest_dir`, appending -2, -3, etc. on collision.
fn unique_filename(dest_dir: &Path, filename: &str, taken: &HashSet<String>) -> String {
if !dest_dir.join(filename).exists() && !taken.contains(filename) {
return filename.to_string();
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = format!("{}-{}{}", stem, counter, ext);
if !dest_dir.join(&candidate).exists() && !taken.contains(&candidate) {
return candidate;
}
counter += 1;
}
}
/// Flatten vault structure: move all notes from type-based subfolders to the vault root.
/// Skips `type/`, `config/`, `attachments/`, and `_themes/` folders.
/// Updates path-based wikilinks to title-based after moving.
/// Returns the number of files moved.
pub fn flatten_vault(vault_path: &str) -> Result<usize, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
// Collect all .md files in subfolders (not already at root, not in KEEP_FOLDERS)
let mut to_move: Vec<(std::path::PathBuf, String)> = Vec::new();
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
// Skip files already at vault root
if path.parent() == Some(vault) {
continue;
}
// Check if this file is inside a KEEP_FOLDER
let rel = path.strip_prefix(vault).unwrap_or(path);
let top_folder = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_default();
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) {
continue;
}
// Hidden folders (e.g. .laputa, .git)
if top_folder.starts_with('.') {
continue;
}
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
to_move.push((path.to_path_buf(), filename));
}
if to_move.is_empty() {
return Ok(0);
}
// Build a map of old path → (new filename, old relative stem) for wikilink updates
let mut taken: HashSet<String> = HashSet::new();
// Pre-populate with files already at root
if let Ok(entries) = fs::read_dir(vault) {
for e in entries.flatten() {
if e.path().is_file() {
if let Some(name) = e.file_name().to_str() {
taken.insert(name.to_string());
}
}
}
}
let vault_prefix = format!("{}/", vault.to_string_lossy());
let mut moves: Vec<(std::path::PathBuf, std::path::PathBuf, String)> = Vec::new(); // (old, new, old_rel_stem)
for (old_path, filename) in &to_move {
let new_name = unique_filename(vault, filename, &taken);
taken.insert(new_name.clone());
let new_path = vault.join(&new_name);
let old_rel = old_path
.to_string_lossy()
.strip_prefix(&vault_prefix)
.unwrap_or(&old_path.to_string_lossy())
.strip_suffix(".md")
.unwrap_or(&old_path.to_string_lossy())
.to_string();
moves.push((old_path.clone(), new_path, old_rel));
}
// Move all files
let mut moved = 0;
for (old, new, _) in &moves {
match fs::rename(old, new) {
Ok(()) => moved += 1,
Err(e) => log::warn!(
"Failed to move {} → {}: {}",
old.display(),
new.display(),
e
),
}
}
// Update path-based wikilinks across the vault.
// Replace [[folder/slug]] with [[slug]] (title-based).
// Build a single regex matching any old path stem.
let path_stems: Vec<&str> = moves.iter().map(|(_, _, stem)| stem.as_str()).collect();
if !path_stems.is_empty() {
let escaped: Vec<String> = path_stems.iter().map(|s| regex::escape(s)).collect();
let pattern_str = format!(r"\[\[({})\]\]", escaped.join("|"));
if let Ok(re) = Regex::new(&pattern_str) {
// Collect all .md files in vault (now at root + keep folders)
let all_md: Vec<std::path::PathBuf> = WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
})
.map(|e| e.into_path())
.collect();
for md_path in &all_md {
let content = match fs::read_to_string(md_path) {
Ok(c) => c,
Err(_) => continue,
};
if !re.is_match(&content) {
continue;
}
let replaced = re.replace_all(&content, |caps: &regex::Captures| {
let matched = caps.get(1).map(|m| m.as_str()).unwrap_or("");
// Extract just the filename stem (last segment)
let slug = matched.rsplit('/').next().unwrap_or(matched);
format!("[[{}]]", slug)
});
if replaced != content {
let _ = fs::write(md_path, replaced.as_ref());
}
}
}
}
// Clean up empty directories (only type-folder directories, not KEEP_FOLDERS)
for (old, _, _) in &moves {
if let Some(parent) = old.parent() {
if parent != vault {
let _ = fs::remove_dir(parent); // only succeeds if empty
}
}
}
Ok(moved)
}
/// Result of a vault health check.
#[derive(Debug, Serialize, Default)]
pub struct VaultHealthReport {
/// Files in non-protected subfolders (won't be scanned by scan_vault).
pub stray_files: Vec<String>,
/// Files whose filename doesn't match slugify(title).
pub title_mismatches: Vec<TitleMismatch>,
}
/// A single filename-title mismatch.
#[derive(Debug, Serialize)]
pub struct TitleMismatch {
pub path: String,
pub filename: String,
pub title: String,
pub expected_filename: String,
}
/// Slugify a title to produce the expected filename stem.
fn slugify(text: &str) -> String {
let result: String = text
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let trimmed = result.trim_matches('-').to_string();
// Collapse consecutive dashes
let mut prev_dash = false;
let collapsed: String = trimmed
.chars()
.filter(|&c| {
if c == '-' {
if prev_dash {
return false;
}
prev_dash = true;
} else {
prev_dash = false;
}
true
})
.collect();
if collapsed.is_empty() {
"untitled".to_string()
} else {
collapsed
}
}
/// Check vault health: detect stray files and filename-title mismatches.
pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let mut report = VaultHealthReport::default();
// 1. Detect stray files in non-protected subfolders
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
// Skip root files (they're fine)
if path.parent() == Some(vault) {
continue;
}
let rel = path.strip_prefix(vault).unwrap_or(path);
let top_folder = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_default();
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) || top_folder.starts_with('.') {
continue;
}
report.stray_files.push(rel.to_string_lossy().to_string());
}
// 2. Detect filename-title mismatches (root .md files only)
if let Ok(dir_entries) = fs::read_dir(vault) {
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
for dir_entry in dir_entries.flatten() {
let path = dir_entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed = matter.parse(&content);
let fm_title = parsed.data.as_ref().and_then(|pod| {
if let gray_matter::Pod::Hash(ref map) = pod {
if let Some(gray_matter::Pod::String(s)) = map.get("title") {
return Some(s.as_str());
}
}
None
});
let title = super::parsing::extract_title(fm_title, &content, &filename);
let expected_stem = slugify(&title);
let expected_filename = format!("{}.md", expected_stem);
let current_stem = filename.strip_suffix(".md").unwrap_or(&filename);
if current_stem != expected_stem {
report.title_mismatches.push(TitleMismatch {
path: path.to_string_lossy().to_string(),
filename: filename.clone(),
title,
expected_filename,
});
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -561,226 +253,4 @@ mod tests {
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0, "non-markdown files should be ignored");
}
// --- flatten_vault ---
fn write_nested_file(
dir: &std::path::Path,
rel_path: &str,
content: &str,
) -> std::path::PathBuf {
let path = dir.join(rel_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_flatten_vault_moves_notes_to_root() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
write_nested_file(
vault,
"project/my-proj.md",
"---\ntype: Project\n---\n# My Proj\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 2);
assert!(vault.join("hello.md").exists());
assert!(vault.join("my-proj.md").exists());
assert!(!vault.join("note/hello.md").exists());
assert!(!vault.join("project/my-proj.md").exists());
}
#[test]
fn test_flatten_vault_skips_protected_folders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "attachments/image.md", "# Image note\n");
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("hello.md").exists());
assert!(vault.join("attachments/image.md").exists());
}
#[test]
fn test_flatten_vault_handles_filename_collision() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Root Hello\n");
write_nested_file(
vault,
"note/hello.md",
"---\ntype: Note\n---\n# Note Hello\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("hello.md").exists());
assert!(vault.join("hello-2.md").exists());
// Root file unchanged
let root_content = fs::read_to_string(vault.join("hello.md")).unwrap();
assert!(root_content.contains("Root Hello"));
// Moved file gets suffixed name
let moved_content = fs::read_to_string(vault.join("hello-2.md")).unwrap();
assert!(moved_content.contains("Note Hello"));
}
#[test]
fn test_flatten_vault_updates_path_wikilinks() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(
vault,
"note/hello.md",
"---\ntype: Note\n---\n# Hello\n\nSee [[project/my-proj]] for details.\n",
);
write_nested_file(
vault,
"project/my-proj.md",
"---\ntype: Project\n---\n# My Proj\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 2);
let content = fs::read_to_string(vault.join("hello.md")).unwrap();
assert!(content.contains("[[my-proj]]"));
assert!(!content.contains("[[project/my-proj]]"));
}
#[test]
fn test_flatten_vault_noop_when_already_flat() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Hello\n");
write_file(vault, "world.md", "---\ntype: Note\n---\n# World\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_flatten_vault_nested_subfolders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/sub/deep.md", "---\ntype: Note\n---\n# Deep\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("deep.md").exists());
}
#[test]
fn test_flatten_vault_cleans_empty_directories() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
flatten_vault(vault.to_str().unwrap()).unwrap();
assert!(
!vault.join("note").exists(),
"empty folder should be removed"
);
}
// --- slugify ---
#[test]
fn test_slugify_basic() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(slugify("My Note (v2)!"), "my-note-v2");
assert_eq!(slugify("Sprint Retrospective"), "sprint-retrospective");
}
#[test]
fn test_slugify_empty() {
assert_eq!(slugify(""), "untitled");
}
#[test]
fn test_slugify_unicode() {
assert_eq!(slugify("Café Résumé"), "caf-r-sum");
}
// --- vault_health_check ---
#[test]
fn test_health_check_detects_stray_files() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "root-note.md", "---\ntype: Note\n---\n# Root Note\n");
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
write_nested_file(
vault,
"old-folder/stray.md",
"---\ntype: Note\n---\n# Stray\n",
);
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert_eq!(report.stray_files.len(), 1);
assert!(report.stray_files[0].contains("stray.md"));
}
#[test]
fn test_health_check_no_stray_when_flat() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "my-note.md", "# My Note\n");
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.stray_files.is_empty());
}
#[test]
fn test_health_check_detects_title_mismatch() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
// Filename is "wrong-name.md" but title frontmatter says "My Actual Title"
write_file(
vault,
"wrong-name.md",
"---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n",
);
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert_eq!(report.title_mismatches.len(), 1);
assert_eq!(report.title_mismatches[0].filename, "wrong-name.md");
assert_eq!(
report.title_mismatches[0].expected_filename,
"my-actual-title.md"
);
}
#[test]
fn test_health_check_no_mismatch_when_correct() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "my-note.md", "---\ntype: Note\n---\n# My Note\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.title_mismatches.is_empty());
}
#[test]
fn test_health_check_skips_hidden_folders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "root.md", "# Root\n");
write_nested_file(vault, ".git/config.md", "# Git Config\n");
write_nested_file(vault, ".laputa/cache.md", "# Cache\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.stray_files.is_empty());
}
}

View File

@@ -17,7 +17,7 @@ pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport};
pub use migration::migrate_is_a_to_type;
pub use rename::{
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
};