From 01fdadf425e8d4b092e72ab59f6a2dda6e20dab8 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 1 Apr 2026 15:29:24 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20address=20folder=20tree=20QA=20feedback?= =?UTF-8?q?=20=E2=80=94=20recursive=20filtering,=20folder=20creation,=20pa?= =?UTF-8?q?th=20display,=20remove=20flatten=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/commands/vault.rs | 24 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/vault/migration.rs | 530 -------------------- src-tauri/src/vault/mod.rs | 2 +- src/App.tsx | 30 +- src/components/FlatVaultMigrationBanner.tsx | 37 -- src/components/FolderTree.tsx | 50 +- src/components/NoteItem.tsx | 3 + src/components/Sidebar.tsx | 5 +- src/hooks/useFlatVaultMigration.ts | 71 --- src/utils/noteListHelpers.test.ts | 7 +- src/utils/noteListHelpers.ts | 8 +- tests/smoke/flatten-vault-structure.spec.ts | 48 -- 13 files changed, 88 insertions(+), 730 deletions(-) delete mode 100644 src/components/FlatVaultMigrationBanner.tsx delete mode 100644 src/hooks/useFlatVaultMigration.ts delete mode 100644 tests/smoke/flatten-vault-structure.spec.ts diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 883c6399..a59ab5dc 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -89,15 +89,15 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result { } #[tauri::command] -pub fn flatten_vault(vault_path: String) -> Result { +pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result { let vault_path = expand_tilde(&vault_path); - vault::flatten_vault(&vault_path) -} - -#[tauri::command] -pub fn vault_health_check(vault_path: String) -> Result { - 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 { 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:")); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18c87c29..309b0a9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/vault/migration.rs b/src-tauri/src/vault/migration.rs index 35558be1..983ce553 100644 --- a/src-tauri/src/vault/migration.rs +++ b/src-tauri/src/vault/migration.rs @@ -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 { 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 { - 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 { - 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 = 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 = 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 = 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: ®ex::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, - /// Files whose filename doesn't match slugify(title). - pub title_mismatches: Vec, -} - -/// 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 { - 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::::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()); - } } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index a7cf4b79..22e5c39e 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -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, }; diff --git a/src/App.tsx b/src/App.tsx index c3da020a..663e83f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,8 +47,6 @@ import { useVaultBridge } from './hooks/useVaultBridge' import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { UpdateBanner } from './components/UpdateBanner' -import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner' -import { useFlatVaultMigration } from './hooks/useFlatVaultMigration' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' import type { SidebarSelection, InboxPeriod } from './types' @@ -119,7 +117,6 @@ function App() { useVaultConfig(resolvedPath) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() useTelemetry(settings, settingsLoaded) - const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) const autoSync = useAutoSync({ @@ -275,6 +272,20 @@ function App() { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) }, []) + const handleCreateFolder = useCallback(async (name: string) => { + try { + if (isTauri()) { + await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name }) + } else { + await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name }) + } + await vault.reloadVault() + setToastMessage(`Created folder "${name}"`) + } catch (e) { + setToastMessage(`Failed to create folder: ${e}`) + } + }, [resolvedPath, vault, setToastMessage]) + const handleRemoveNoteIconCommand = useCallback(() => { if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) }, [notes.activeTabPath, handleRemoveNoteIcon]) @@ -477,7 +488,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -547,17 +558,6 @@ function App() { /> - {flatVaultMigration.needsMigration && ( - { - const count = await flatVaultMigration.migrate() - setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`) - }} - onDismiss={flatVaultMigration.dismiss} - /> - )} handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> diff --git a/src/components/FlatVaultMigrationBanner.tsx b/src/components/FlatVaultMigrationBanner.tsx deleted file mode 100644 index 0b185636..00000000 --- a/src/components/FlatVaultMigrationBanner.tsx +++ /dev/null @@ -1,37 +0,0 @@ -interface FlatVaultMigrationBannerProps { - strayFileCount: number - isMigrating: boolean - onMigrate: () => void - onDismiss: () => void -} - -/** - * Banner shown when the vault has files in non-protected subfolders. - * Offers to flatten them to the vault root. - */ -export function FlatVaultMigrationBanner({ strayFileCount, isMigrating, onMigrate, onDismiss }: FlatVaultMigrationBannerProps) { - return ( -
- - {strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders. - Flatten to vault root for consistent scanning. - - - -
- ) -} diff --git a/src/components/FolderTree.tsx b/src/components/FolderTree.tsx index 20743e93..29c4c14b 100644 --- a/src/components/FolderTree.tsx +++ b/src/components/FolderTree.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, memo } from 'react' +import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react' import type { FolderNode, SidebarSelection } from '../types' import { cn } from '@/lib/utils' @@ -7,6 +7,7 @@ interface FolderTreeProps { folders: FolderNode[] selection: SidebarSelection onSelect: (selection: SidebarSelection) => void + onCreateFolder?: (name: string) => void } function FolderItem({ @@ -71,15 +72,31 @@ function FolderItem({ ) } -export const FolderTree = memo(function FolderTree({ folders, selection, onSelect }: FolderTreeProps) { +export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) { const [sectionCollapsed, setSectionCollapsed] = useState(false) const [expanded, setExpanded] = useState>({}) + const [isCreating, setIsCreating] = useState(false) + const [newFolderName, setNewFolderName] = useState('') + const inputRef = useRef(null) const toggleFolder = useCallback((path: string) => { setExpanded((prev) => ({ ...prev, [path]: !prev[path] })) }, []) - if (folders.length === 0) return null + useEffect(() => { + if (isCreating) inputRef.current?.focus() + }, [isCreating]) + + const handleCreateFolder = () => { + const name = newFolderName.trim() + if (name && onCreateFolder) { + onCreateFolder(name) + } + setIsCreating(false) + setNewFolderName('') + } + + if (folders.length === 0 && !isCreating) return null return (
@@ -93,7 +110,14 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec {sectionCollapsed ? : } FOLDERS
- + {onCreateFolder && ( + { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }} + data-testid="create-folder-btn" + /> + )} {/* Tree */} @@ -110,6 +134,24 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec onSelect={onSelect} /> ))} + {isCreating && ( +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder() + if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') } + }} + onBlur={handleCreateFolder} + placeholder="Folder name" + data-testid="new-folder-input" + /> +
+ )} )} diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index db26c557..0f0bac04 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -129,6 +129,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig {entry.title} + {entry.path.includes('/') && ( +
{entry.path}
+ )} {entry.snippet && (
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7880ecdb..3ee5c693 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -35,6 +35,7 @@ interface SidebarProps { onRenameSection?: (typeName: string, label: string) => void onToggleTypeVisibility?: (typeName: string) => void folders?: FolderNode[] + onCreateFolder?: (name: string) => void inboxCount?: number onCollapse?: () => void } @@ -207,7 +208,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, onToggleTypeVisibility, - folders = [], inboxCount = 0, onCollapse, + folders = [], onCreateFolder, inboxCount = 0, onCollapse, }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) @@ -317,7 +318,7 @@ export const Sidebar = memo(function Sidebar({ {/* Folder tree */} - + { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} /> diff --git a/src/hooks/useFlatVaultMigration.ts b/src/hooks/useFlatVaultMigration.ts deleted file mode 100644 index 96c03951..00000000 --- a/src/hooks/useFlatVaultMigration.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri } from '../mock-tauri' - -interface HealthReport { - stray_files: string[] - title_mismatches: { path: string; filename: string; title: string; expected_filename: string }[] -} - -interface FlatVaultMigration { - /** True if stray files were detected in non-protected subfolders. */ - needsMigration: boolean - /** List of stray file paths (relative to vault root). */ - strayFiles: string[] - /** Dismiss the migration prompt without migrating. */ - dismiss: () => void - /** Run flatten_vault and reload. Returns the count of files moved. */ - migrate: () => Promise - /** True while migration is running. */ - isMigrating: boolean -} - -/** - * Detects if the vault has files in non-protected subfolders and offers - * to flatten them to the vault root. Runs once on vault load. - */ -export function useFlatVaultMigration( - vaultPath: string, - entriesLoaded: boolean, - reloadVault: () => Promise, -): FlatVaultMigration { - const [strayFiles, setStrayFiles] = useState([]) - const [dismissed, setDismissed] = useState(false) - const [isMigrating, setIsMigrating] = useState(false) - - useEffect(() => { - if (!entriesLoaded || !vaultPath || !isTauri()) return - let cancelled = false - invoke('vault_health_check', { vaultPath }) - .then((report) => { - if (!cancelled && report.stray_files.length > 0) { - setStrayFiles(report.stray_files) - } - }) - .catch(() => { /* non-critical */ }) - return () => { cancelled = true } - }, [vaultPath, entriesLoaded]) - - const dismiss = useCallback(() => setDismissed(true), []) - - const migrate = useCallback(async () => { - setIsMigrating(true) - try { - const count = await invoke('flatten_vault', { vaultPath }) - setStrayFiles([]) - setDismissed(true) - await reloadVault() - return count - } finally { - setIsMigrating(false) - } - }, [vaultPath, reloadVault]) - - return { - needsMigration: strayFiles.length > 0 && !dismissed, - strayFiles, - dismiss, - migrate, - isMigrating, - } -} diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index 2a22388d..c884af72 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -715,7 +715,12 @@ describe('filterEntries — folder selection', () => { expect(result.find(e => e.title === 'Site')).toBeUndefined() }) - it('filters by parent folder (non-recursive — direct children only)', () => { + it('filters recursively — includes notes from subfolders', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'projects' }) + expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site']) + }) + + it('filters direct children', () => { const result = filterEntries(entries, { kind: 'folder', path: 'areas' }) expect(result.map(e => e.title)).toEqual(['Health']) }) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index c032336a..a0dcf89d 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -314,12 +314,8 @@ function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): Vault } function isInFolder(entryPath: string, folderRelPath: string): boolean { - const sep = '/' - const suffix = sep + folderRelPath + sep - const dirEnd = entryPath.lastIndexOf(sep) - if (dirEnd < 0) return false - const entryDir = entryPath.slice(0, dirEnd + 1) - return entryDir.endsWith(suffix) + const needle = '/' + folderRelPath + '/' + return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/') } function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] { diff --git a/tests/smoke/flatten-vault-structure.spec.ts b/tests/smoke/flatten-vault-structure.spec.ts deleted file mode 100644 index c201a6f8..00000000 --- a/tests/smoke/flatten-vault-structure.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { test, expect } from '@playwright/test' - -test.describe('Flat vault structure', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - }) - - test('new note is created at vault root (no type folder in path)', async ({ page }) => { - // Create a new note via Ctrl+N (mock Cmd+N) - await page.locator('body').click() - await page.keyboard.press('Control+n') - - // Wait for the editor to appear (note was created) - await page.waitForTimeout(500) - - // Check that no toast says "Note moved" — type change should not move file - const movedToast = page.locator('text=Note moved') - await expect(movedToast).not.toBeVisible() - }) - - test('changing type via frontmatter does NOT show move toast', async ({ page }) => { - // Create a note first - await page.locator('body').click() - await page.keyboard.press('Control+n') - await page.waitForTimeout(500) - - // Verify no "Note moved" toast appears (since move_note_to_type_folder is removed) - const movedToast = page.locator('text=Note moved') - await expect(movedToast).not.toBeVisible() - }) - - test('app loads without errors', async ({ page }) => { - // Verify the app loaded — check that the main container exists - const main = page.locator('#root') - await expect(main).toBeVisible() - - // No console errors about move_note_to_type_folder - const errors: string[] = [] - page.on('console', msg => { - if (msg.type() === 'error') errors.push(msg.text()) - }) - await page.waitForTimeout(1000) - - const moveErrors = errors.filter(e => e.includes('move_note_to_type_folder')) - expect(moveErrors).toHaveLength(0) - }) -})