From 417e37f1d143f1c11da80c71825029123e1aea90 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 4 May 2026 04:59:50 +0200 Subject: [PATCH] refactor(paths): share note path identity --- docs/ABSTRACTIONS.md | 1 + docs/ARCHITECTURE.md | 2 +- src-tauri/src/git/history.rs | 38 ++------- src-tauri/src/vault/cache.rs | 92 +++++++++++--------- src-tauri/src/vault/mod.rs | 16 ++-- src-tauri/src/vault/path_identity.rs | 117 ++++++++++++++++++++++++++ src-tauri/src/vault/rename.rs | 19 +++-- src/hooks/useNoteActions.hook.test.ts | 26 ++++++ src/hooks/useNoteActions.ts | 21 +++-- src/hooks/useNoteCreation.test.ts | 12 +++ src/hooks/useNoteCreation.ts | 16 ++-- src/hooks/useNoteRename.test.ts | 44 +++++++++- src/hooks/useNoteRename.ts | 30 ++++--- src/hooks/useTabManagement.ts | 27 ++---- src/utils/notePathIdentity.test.ts | 38 +++++++++ src/utils/notePathIdentity.ts | 67 +++++++++++++++ src/utils/pulledVaultRefresh.ts | 19 ++--- 17 files changed, 435 insertions(+), 150 deletions(-) create mode 100644 src-tauri/src/vault/path_identity.rs create mode 100644 src/utils/notePathIdentity.test.ts create mode 100644 src/utils/notePathIdentity.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index b9afbcf7..43432529 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -278,6 +278,7 @@ Tolaria separates **display title** from the file identifier: - **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter. - **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface. - **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`. +- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive. - **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows. - **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt. - **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0a80fdea..3c715809 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -428,7 +428,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin ### Cache File -`~/.laputa/cache/.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem. +`~/.laputa/cache/.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem. `/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash. diff --git a/src-tauri/src/git/history.rs b/src-tauri/src/git/history.rs index a345fd41..1321e3e3 100644 --- a/src-tauri/src/git/history.rs +++ b/src-tauri/src/git/history.rs @@ -1,4 +1,5 @@ use super::git_command; +use crate::vault::path_identity::vault_relative_path_string; use std::path::Path; use super::GitCommit; @@ -7,14 +8,7 @@ use super::GitCommit; pub fn get_file_history(vault_path: &str, file_path: &str) -> Result, String> { let vault = Path::new(vault_path); let file = Path::new(file_path); - - let relative = file - .strip_prefix(vault) - .map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?; - - let relative_str = relative - .to_str() - .ok_or_else(|| "Invalid UTF-8 in path".to_string())?; + let relative_str = vault_relative_path_string(vault, file)?; let output = git_command() .args([ @@ -23,7 +17,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result Result Result { let vault = Path::new(vault_path); let file = Path::new(file_path); - - let relative = file - .strip_prefix(vault) - .map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?; - - let relative_str = relative - .to_str() - .ok_or_else(|| "Invalid UTF-8 in path".to_string())?; + let relative_str = vault_relative_path_string(vault, file)?; // First try tracked file diff let output = git_command() - .args(["diff", "--", relative_str]) + .args(["diff", "--", &relative_str]) .current_dir(vault) .output() .map_err(|e| format!("Failed to run git diff: {}", e))?; @@ -91,7 +78,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result Result Result { let vault = Path::new(vault_path); let file = Path::new(file_path); - - let relative = file - .strip_prefix(vault) - .map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?; - - let relative_str = relative - .to_str() - .ok_or_else(|| "Invalid UTF-8 in path".to_string())?; + let relative_str = vault_relative_path_string(vault, file)?; // Show diff between commit^ and commit for this file let output = git_command() @@ -150,7 +130,7 @@ pub fn get_file_diff_at_commit( &format!("{}^", commit_hash), commit_hash, "--", - relative_str, + &relative_str, ]) .current_dir(vault) .output() diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 75766f97..3333da5c 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -9,6 +9,10 @@ use uuid::Uuid; use crate::git::{get_all_file_dates, GitDates}; use std::collections::HashMap; +use super::path_identity::{ + normalize_path_for_identity, push_unique_relative_path, relative_path_key, + vault_relative_path_string, +}; use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- @@ -83,7 +87,7 @@ fn default_cache_version() -> u32 { /// Compute a deterministic hex hash of the vault path for use as cache filename. fn vault_path_hash(vault: &Path) -> String { let mut hasher = std::collections::hash_map::DefaultHasher::new(); - vault.to_string_lossy().as_ref().hash(&mut hasher); + normalize_path_for_identity(&vault.to_string_lossy()).hash(&mut hasher); format!("{:016x}", hasher.finish()) } @@ -148,28 +152,22 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> { /// Includes all non-hidden files (not just .md) so the cache picks up /// view files (.yml), binary assets, etc. fn collect_paths_from_diff(stdout: &str) -> Vec { - stdout - .lines() - .filter(|line| !line.is_empty() && !has_hidden_segment(line)) - .map(|line| line.to_string()) - .collect() + let mut paths = Vec::new(); + for line in stdout.lines() { + push_unique_relative_path(&mut paths, line); + } + paths } /// Extract file paths from git status --porcelain output. /// Includes all non-hidden files so incremental cache updates cover /// every file type the vault scanner recognises. fn collect_paths_from_porcelain(stdout: &str) -> Vec { - stdout - .lines() - .filter_map(parse_porcelain_line) - .filter(|(_, path)| !has_hidden_segment(path)) - .map(|(_, path)| path) - .collect() -} - -/// Return true if any path segment starts with `.` (hidden file/directory). -fn has_hidden_segment(path: &str) -> bool { - path.split('/').any(|seg| seg.starts_with('.')) + let mut paths = Vec::new(); + for (_, path) in stdout.lines().filter_map(parse_porcelain_line) { + push_unique_relative_path(&mut paths, path); + } + paths } fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec { @@ -182,9 +180,7 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec Vec { // files inside — ls-files resolves them so the cache picks up all new files. let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"]) .map(|s| { - s.lines() - .filter(|l| !l.is_empty() && !has_hidden_segment(l)) - .map(|l| l.to_string()) - .collect::>() + let mut paths = Vec::new(); + for line in s.lines() { + push_unique_relative_path(&mut paths, line); + } + paths }) .unwrap_or_default(); for path in untracked { - if !files.contains(&path) { - files.push(path); - } + push_unique_relative_path(&mut files, path); } files @@ -447,13 +442,12 @@ fn write_cache( /// Normalize an absolute path to a relative path for comparison with git output. fn to_relative_path(abs_path: &str, vault: &Path) -> String { - let vault_str = vault.to_string_lossy(); - let with_slash = format!("{}/", vault_str); - abs_path - .strip_prefix(&with_slash) - .or_else(|| abs_path.strip_prefix(vault_str.as_ref())) - .unwrap_or(abs_path) - .to_string() + vault_relative_path_string(vault, Path::new(abs_path)) + .unwrap_or_else(|_| normalize_path_for_identity(abs_path)) +} + +fn to_relative_path_key(abs_path: &str, vault: &Path) -> String { + relative_path_key(&to_relative_path(abs_path, vault)) } /// Parse files from a list of relative paths, skipping any that don't exist. @@ -535,7 +529,7 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec) -> bool { // Deduplicate by case-folded relative path let mut seen = std::collections::HashSet::new(); entries.retain(|e| { - let rel = to_relative_path(&e.path, vault).to_lowercase(); + let rel = to_relative_path_key(&e.path, vault); seen.insert(rel) }); entries.len() != before @@ -587,8 +581,9 @@ fn update_same_commit( let changed = git_uncommitted_files(vault); let mut entries = cache.entries; if !changed.is_empty() { - let changed_set: std::collections::HashSet = changed.iter().cloned().collect(); - entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault))); + let changed_set: std::collections::HashSet = + changed.iter().map(|path| relative_path_key(path)).collect(); + entries.retain(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault))); entries.extend(parse_files_at(vault, &changed, git_dates)); } // Always finalize: prune_stale_entries inside finalize_and_cache removes @@ -605,12 +600,15 @@ fn update_different_commit( ) -> Vec { let LoadedCache { cache, fingerprint } = loaded_cache; let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash); - let changed_set: std::collections::HashSet = changed_files.iter().cloned().collect(); + let changed_set: std::collections::HashSet = changed_files + .iter() + .map(|path| relative_path_key(path)) + .collect(); let mut entries: Vec = cache .entries .into_iter() - .filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault))) + .filter(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault))) .collect(); entries.extend(parse_files_at(vault, &changed_files, git_dates)); @@ -618,9 +616,10 @@ fn update_different_commit( } fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool { - let current_vault_str = vault_path.to_string_lossy(); + let current_vault_str = normalize_path_for_identity(&vault_path.to_string_lossy()); cache.version != CACHE_VERSION - || (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref()) + || (!cache.vault_path.is_empty() + && normalize_path_for_identity(&cache.vault_path) != current_vault_str) } fn scan_and_cache_full( @@ -808,6 +807,17 @@ mod tests { assert_eq!(hash1, hash2); } + #[test] + fn test_to_relative_path_normalizes_aliases_and_separators() { + assert_eq!( + to_relative_path( + "/tmp/tolaria-vault/projects\\active.md", + Path::new("/private/tmp/tolaria-vault") + ), + "projects/active.md" + ); + } + #[test] fn test_different_vaults_get_different_hashes() { let hash1 = vault_path_hash(Path::new("/Users/test/Vault1")); diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index a2b8478f..ed23402a 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -10,6 +10,7 @@ mod ignored; mod image; mod migration; mod parsing; +pub(crate) mod path_identity; mod rename; mod rename_transaction; mod title_sync; @@ -349,11 +350,7 @@ fn lookup_git_dates( vault_path: &Path, git_dates: &HashMap, ) -> Option<(u64, u64)> { - let rel = path - .strip_prefix(vault_path) - .ok()? - .to_string_lossy() - .to_string(); + let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?; git_dates.get(&rel).map(|d| (d.modified_at, d.created_at)) } @@ -462,11 +459,10 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result, String> if is_folder_tree_hidden_dir(&name) { continue; } - let rel_path = path - .strip_prefix(vault_root) - .unwrap_or(&path) - .to_string_lossy() - .replace('\\', "/"); + let rel_path = path_identity::vault_relative_path_string(vault_root, &path) + .unwrap_or_else(|_| { + path_identity::normalize_path_for_identity(&path.to_string_lossy()) + }); let children = build_tree(&path, vault_root); nodes.push(FolderNode { name, diff --git a/src-tauri/src/vault/path_identity.rs b/src-tauri/src/vault/path_identity.rs new file mode 100644 index 00000000..ec6d30dd --- /dev/null +++ b/src-tauri/src/vault/path_identity.rs @@ -0,0 +1,117 @@ +use std::path::Path; + +type PathText = str; +type RelativePathText = str; + +fn normalize_tmp_alias(path: &PathText) -> String { + if path == "/private/tmp" { + return "/tmp".to_string(); + } + if let Some(rest) = path.strip_prefix("/private/tmp/") { + return format!("/tmp/{rest}"); + } + path.to_string() +} + +fn trim_trailing_slashes(path: String) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() && path.starts_with('/') { + "/".to_string() + } else { + trimmed.to_string() + } +} + +pub(crate) fn normalize_path_for_identity(path: &PathText) -> String { + trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/"))) +} + +pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String { + path.replace('\\', "/").trim_matches('/').to_string() +} + +pub(crate) fn relative_path_key(path: &RelativePathText) -> String { + normalize_relative_path(path).to_lowercase() +} + +pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool { + normalize_relative_path(path) + .split('/') + .any(|segment| segment.starts_with('.')) +} + +pub(crate) fn push_unique_relative_path( + paths: &mut Vec, + path: impl AsRef, +) { + let normalized = normalize_relative_path(path.as_ref()); + if normalized.is_empty() || has_hidden_segment(&normalized) { + return; + } + let key = relative_path_key(&normalized); + if !paths + .iter() + .any(|existing| relative_path_key(existing) == key) + { + paths.push(normalized); + } +} + +pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result { + let vault_path = normalize_path_for_identity(&vault.to_string_lossy()); + let file_path = normalize_path_for_identity(&file.to_string_lossy()); + if file_path == vault_path { + return Ok(String::new()); + } + + let prefix = format!("{vault_path}/"); + file_path + .strip_prefix(&prefix) + .map(normalize_relative_path) + .ok_or_else(|| { + format!( + "File {} is not inside vault {}", + file.display(), + vault.display() + ) + }) +} + +pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String { + let relative = vault_relative_path_string(vault, path) + .unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy())); + relative + .strip_suffix(".md") + .unwrap_or(&relative) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() { + assert_eq!( + vault_relative_path_string( + Path::new("/private/tmp/tolaria-vault"), + Path::new("/tmp/tolaria-vault/projects\\active.md"), + ) + .unwrap(), + "projects/active.md" + ); + } + + #[test] + fn test_relative_path_key_is_case_insensitive_without_changing_output_path() { + let mut paths = vec![]; + push_unique_relative_path(&mut paths, "Projects\\Active.md"); + push_unique_relative_path(&mut paths, "projects/active.md"); + + assert_eq!(paths, vec!["Projects/Active.md"]); + assert_eq!( + relative_path_key("Projects\\Active.md"), + "projects/active.md" + ); + } +} diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 8c0f2f12..206f546b 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -7,6 +7,7 @@ use tempfile::NamedTempFile; use walkdir::WalkDir; use super::filename_rules::validate_filename_stem; +use super::path_identity::vault_relative_markdown_stem; use super::rename_transaction::RenameWorkspace; use crate::frontmatter::{update_frontmatter_content, FrontmatterValue}; @@ -211,12 +212,7 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String { /// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review"). fn to_path_stem(path: &Path, vault_root: &Path) -> String { - let relative = path.strip_prefix(vault_root).unwrap_or(path); - let normalized = relative.to_string_lossy().replace('\\', "/"); - normalized - .strip_suffix(".md") - .unwrap_or(&normalized) - .to_string() + vault_relative_markdown_stem(path, vault_root) } pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> { @@ -750,6 +746,17 @@ mod tests { assert_eq!(renames[0].new_path, "新名.md"); } + #[test] + fn test_path_stem_normalizes_tmp_aliases_and_separators() { + assert_eq!( + to_path_stem( + Path::new("/tmp/tolaria-vault/projects\\weekly-review.md"), + Path::new("/private/tmp/tolaria-vault") + ), + "projects/weekly-review" + ); + } + #[test] fn test_rename_note_basic() { let dir = TempDir::new().unwrap(); diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index c2fe77ec..3553e4fa 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -6,6 +6,7 @@ import type { VaultEntry } from '../types' import { RAPID_CREATE_NOTE_SETTLE_MS } from './useNoteCreation' import { useNoteActions } from './useNoteActions' import type { NoteActionsConfig } from './useNoteActions' +import { GITIGNORED_VISIBILITY_APPLIED_EVENT } from '../lib/gitignoredVisibilityEvents' vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) vi.mock('../mock-tauri', () => ({ @@ -163,6 +164,31 @@ describe('useNoteActions hook', () => { warnSpy.mockRestore() }) + it('keeps the active tab open when gitignored visibility reports a /tmp alias', async () => { + const activeEntry = makeEntry({ + path: '/private/tmp/tolaria-vault/active.md', + filename: 'active.md', + title: 'Active', + }) + const { result } = renderActions([activeEntry]) + + await act(async () => { + await result.current.handleSelectNote(activeEntry) + }) + + act(() => { + window.dispatchEvent(new CustomEvent(GITIGNORED_VISIBILITY_APPLIED_EVENT, { + detail: { + hide: true, + visiblePaths: ['/tmp/tolaria-vault/active.md'], + }, + })) + }) + + expect(result.current.activeTabPath).toBe('/private/tmp/tolaria-vault/active.md') + expect(result.current.tabs).toHaveLength(1) + }) + it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => { const { result } = renderHook(() => useNoteActions(makeConfig())) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 0d30574b..f96a2a1e 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -13,6 +13,7 @@ import { performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, reloadVaultAfterRename, } from './useNoteRename' import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps' +import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity' export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void @@ -89,18 +90,20 @@ interface RenameAfterTitleChangeParams { } async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise { - const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title + const oldTitle = deps.tabsRef.current.find(t => notePathsMatch(t.entry.path, path))?.entry.title const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle }) - if (result.new_path !== path) { - const newFilename = result.new_path.split('/').pop() ?? '' + if (!notePathsMatch(result.new_path, path)) { + const newFilename = notePathFilename(result.new_path) deps.onPathRenamed?.(path, result.new_path) deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) const newContent = await loadNoteContent({ path: result.new_path }) - deps.setTabs(prev => prev.map(t => t.entry.path === path + deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path) ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent } : t)) - if (deps.activeTabPathRef.current === path) deps.handleSwitchTab(result.new_path) - const otherTabPaths = deps.tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path) + if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(result.new_path) + const otherTabPaths = deps.tabsRef.current + .filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, result.new_path)) + .map(t => t.entry.path) await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent }) } await reloadVaultAfterRename(deps.reloadVault) @@ -252,7 +255,7 @@ function useGitignoredVisibilityTabCleanup({ const handleVisibilityApplied = (event: Event) => { const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail const activePath = activeTabPathRef.current - if (!hide || !activePath || visiblePaths.includes(activePath)) return + if (!hide || !activePath || visiblePaths.some((path) => notePathsMatch(path, activePath))) return closeAllTabs() setToastMessage('Closed hidden Gitignored file') } @@ -353,7 +356,7 @@ export function useNoteActions(config: NoteActionsConfig) { }) const updateTabContent = useCallback((path: string, newContent: string) => { - setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t)) + setTabs((prev) => prev.map((t) => notePathsMatch(t.entry.path, path) ? { ...t, content: newContent } : t)) }, [setTabs]) const creation = useNoteCreation(config, { openTabWithContent }) @@ -374,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) { path, key, value, - callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, + callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => findByNotePath(entries, p) }, options, }), [updateTabContent, updateEntry, setToastMessage, entries], diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index 5c05d2c1..dbf0bf84 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -14,6 +14,7 @@ import { resolveTemplate, DEFAULT_TEMPLATES, RAPID_CREATE_NOTE_SETTLE_MS, + planNewNoteCreation, useNoteCreation, } from './useNoteCreation' import type { NoteCreationConfig } from './useNoteCreation' @@ -169,6 +170,17 @@ describe('resolveNewNote', () => { expect(entry.status).toBeNull() expect(content).not.toContain('status:') }) + + it('blocks creation when macOS /tmp aliases point at the same note path', () => { + const plan = planNewNoteCreation({ + entries: [makeEntry({ path: '/private/tmp/tolaria-vault/briefing.md', filename: 'briefing.md' })], + title: 'Briefing', + type: 'Note', + vaultPath: '/tmp/tolaria-vault', + }) + + expect(plan.status).toBe('blocked') + }) }) describe('resolveNewType', () => { diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 39e73fbb..20587bca 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -6,6 +6,7 @@ import { slugifyNoteStem as slugify } from '../utils/noteSlug' import { resolveEntry } from '../utils/wikilink' import { trackEvent } from '../lib/telemetry' import { cacheNoteContent } from './useTabManagement' +import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity' export interface NewEntryParams { path: string @@ -116,7 +117,7 @@ export interface NewNoteParams { export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } { const slug = slugify(title) const status = null - const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status }) + const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status }) return { entry, content: buildNoteContent({ title, type, status, template }) } } @@ -127,7 +128,7 @@ export interface NewTypeParams { export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } { const slug = slugify(typeName) - const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) + const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: typeName, type: 'Type', status: null }) return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` } } @@ -151,17 +152,12 @@ interface ExistingTypeCreationPlan { export type NoteCreationPlan = BlockedCreationPlan | ReadyCreationPlan export type TypeCreationPlan = BlockedCreationPlan | ExistingTypeCreationPlan | ReadyCreationPlan -function normalizeComparablePath(path: string): string { - return path.replace(/\\/g, '/').toLocaleLowerCase() -} - function findPathCollision(entries: VaultEntry[], path: string): VaultEntry | undefined { - const target = normalizeComparablePath(path) - return entries.find((entry) => normalizeComparablePath(entry.path) === target) + return findByCollidingNotePath(entries, path) } function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | 'type'; title: string; path: string }): string { - const filename = path.split('/').pop() ?? path + const filename = notePathFilename(path) return `Cannot create ${noun} "${title}" because ${filename} already exists` } @@ -473,7 +469,7 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr const title = slug_to_title(slug) const template = resolveTemplate({ entries: deps.entries, typeName: noteType }) const status = null - const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status }) + const entry = buildNewEntry({ path: joinVaultPath(deps.vaultPath, `${slug}.md`), slug, title, type: noteType, status }) const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true }) const didPersist = await persistImmediateEntry(deps, entry, content) if (!didPersist) return false diff --git a/src/hooks/useNoteRename.test.ts b/src/hooks/useNoteRename.test.ts index 713f9040..4de395df 100644 --- a/src/hooks/useNoteRename.test.ts +++ b/src/hooks/useNoteRename.test.ts @@ -127,11 +127,13 @@ describe('useNoteRename hook', () => { )) const runHandleRenameNote = async ({ + path = '/vault/old.md', entries = [], renameResult = { new_path: '/vault/new.md', updated_files: 0, failed_updates: 0 }, activePath = null, onEntryRenamed = vi.fn(), }: { + path?: string entries?: VaultEntry[] renameResult?: RenameNoteResult activePath?: string | null @@ -142,7 +144,7 @@ describe('useNoteRename hook', () => { const { result } = renderUseNoteRename(entries) await act(async () => { - await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', onEntryRenamed) + await result.current.handleRenameNote(path, 'New', '/vault', onEntryRenamed) }) return { onEntryRenamed } @@ -196,6 +198,17 @@ describe('useNoteRename hook', () => { expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md') }) + it('switches active tab when macOS /tmp aliases identify the renamed note', async () => { + await runHandleRenameNote({ + path: '/tmp/vault/old.md', + entries: [makeEntry({ path: '/private/tmp/vault/old.md' })], + renameResult: { new_path: '/tmp/vault/new.md', updated_files: 0, failed_updates: 0 }, + activePath: '/private/tmp/vault/old.md', + }) + + expect(handleSwitchTab).toHaveBeenCalledWith('/tmp/vault/new.md') + }) + it('handleRenameFilename renames the file while preserving the existing title', async () => { const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' }) vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { @@ -331,4 +344,33 @@ describe('useNoteRename hook', () => { ) expect(setToastMessage).toHaveBeenCalledWith('Moved to "projects" and updated 1 note') }) + + it('normalizes folder move targets before sending them to the backend', async () => { + const entry = makeEntry({ path: '/vault/notes/project-kickoff.md', filename: 'project-kickoff.md', title: 'Project Kickoff' }) + vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { + if (cmd === 'move_note_to_folder') { + return { + new_path: '/vault/projects/active/project-kickoff.md', + updated_files: 0, + failed_updates: 0, + } + } + if (cmd === 'get_note_content') return '# Project Kickoff\n' + return '' + }) + + const { result } = renderHook(() => useNoteRename( + { entries: [entry], setToastMessage }, + { tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, + )) + + await act(async () => { + await result.current.handleMoveNoteToFolder('/vault/notes/project-kickoff.md', String.raw`/projects\active/`, '/vault', vi.fn()) + }) + + expect(mockInvoke).toHaveBeenCalledWith('move_note_to_folder', expect.objectContaining({ + folder_path: 'projects/active', + })) + expect(setToastMessage).toHaveBeenCalledWith('Moved to "active"') + }) }) diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts index 3aceafd0..6b6c0512 100644 --- a/src/hooks/useNoteRename.ts +++ b/src/hooks/useNoteRename.ts @@ -3,6 +3,13 @@ import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { slugify } from './useNoteCreation' +import { + findByNotePath, + normalizeVaultRelativePath, + notePathFilename, + notePathsMatch, + vaultRelativePathLabel, +} from '../utils/notePathIdentity' interface RenameResult { new_path: string @@ -98,12 +105,12 @@ export async function performMoveNoteToFolder({ } export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry { - const filename = newPath.split('/').pop() ?? entry.filename + const filename = notePathFilename(newPath) return { ...entry, path: newPath, filename, title: newTitle } } export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry { - const filename = newPath.split('/').pop() ?? entry.filename + const filename = notePathFilename(newPath) return { ...entry, path: newPath, filename } } @@ -156,8 +163,7 @@ export function renameToastMessage(updatedFiles: number, failedUpdates = 0): str } function folderLabel(params: { folderPath: string }): string { - const trimmed = params.folderPath.trim().replace(/^\/+|\/+$/g, '') - return trimmed.split('/').filter(Boolean).at(-1) ?? trimmed + return vaultRelativePathLabel(params.folderPath) } function moveToastMessage(folderPath: string, updatedFiles: number, failedUpdates = 0): string { @@ -195,8 +201,8 @@ interface Tab { } function findRenameEntry(entries: VaultEntry[], tabs: Tab[], path: string): VaultEntry | undefined { - return entries.find((entry) => entry.path === path) - ?? tabs.find((tab) => tab.entry.path === path)?.entry + return findByNotePath(entries, path) + ?? tabs.find((tab) => notePathsMatch(tab.entry.path, path))?.entry } function renameErrorMessage(err: unknown): string { @@ -260,9 +266,11 @@ function useRenameResultApplier( const entry = findRenameEntry(entries, currentTabs, oldPath) const newContent = await loadNoteContent({ path: result.new_path }) const newEntry = buildEntry(entry, result.new_path) - const otherTabPaths = currentTabs.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path) - setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab)) - if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path) + const otherTabPaths = currentTabs + .filter((tab) => !notePathsMatch(tab.entry.path, oldPath) && !notePathsMatch(tab.entry.path, result.new_path)) + .map((tab) => tab.entry.path) + setTabs((prev) => prev.map((tab) => notePathsMatch(tab.entry.path, oldPath) ? { entry: newEntry, content: newContent } : tab)) + if (notePathsMatch(activeTabPathRef.current, oldPath)) handleSwitchTab(result.new_path) onEntryRenamed(oldPath, newEntry, newContent) await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent }) await reloadVaultAfterRename(reloadVault) @@ -310,7 +318,7 @@ async function runRenameAction({ }): Promise { try { const result = await perform() - if (allowUnchangedResult && result.new_path === path) return result + if (allowUnchangedResult && notePathsMatch(result.new_path, path)) return result await applyRenameResult(path, result, buildEntry, onEntryRenamed, { successMessage }) return result } catch (err) { @@ -365,7 +373,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, ) => { - const normalizedFolderPath = folderPath.trim().replace(/^\/+|\/+$/g, '') + const normalizedFolderPath = normalizeVaultRelativePath(folderPath) return runRenameAction({ path, perform: () => performMoveNoteToFolder({ path, folderPath: normalizedFolderPath, vaultPath }), diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 02fff2db..6916de9c 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -20,6 +20,7 @@ import { prefetchNoteContent as prefetchNoteContentInMemory, } from './noteContentCache' import { clearParsedNoteBlockCache } from './editorParsedBlockCache' +import { notePathsMatch } from '../utils/notePathIdentity' interface Tab { entry: VaultEntry @@ -89,18 +90,6 @@ function resetRequestedPathIfStillPending( } } -function normalizeComparablePath(path: string): string { - return path - .replaceAll('\\', '/') - .replace(/^\/private\/tmp(?=\/|$)/u, '/tmp') - .replace(/\/+$/u, '') -} - -function pathsMatch(leftPath: string | null, rightPath: string | null): boolean { - if (!leftPath || !rightPath) return false - return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath) -} - function setSingleTab( tabsRef: React.MutableRefObject, setTabs: React.Dispatch>, @@ -123,8 +112,8 @@ function isAlreadyViewingPath( activeTabPathRef: React.MutableRefObject, path: string, ) { - return pathsMatch(activeTabPathRef.current, path) - || tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path)) + return notePathsMatch(activeTabPathRef.current, path) + || tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path)) } function startEntryNavigation(options: { @@ -203,8 +192,8 @@ function shouldApplyLoadedEntry(options: { if (navSeqRef.current !== seq) return false if (forceReload) return true - if (!pathsMatch(activeTabPathRef.current, path)) return true - const openTab = tabsRef.current.find((tab) => pathsMatch(tab.entry.path, path)) + if (!notePathsMatch(activeTabPathRef.current, path)) return true + const openTab = tabsRef.current.find((tab) => notePathsMatch(tab.entry.path, path)) return !openTab || openTab.content !== content } @@ -472,7 +461,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { ) => { const seq = ++beforeNavigateSeqRef.current const currentPath = activeTabPathRef.current - if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) { + if (beforeNavigate && currentPath && !notePathsMatch(currentPath, targetPath)) { try { markNoteOpenTrace(targetPath, 'beforeNavigateStart') await beforeNavigate(currentPath, targetPath) @@ -491,7 +480,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { /** Open a note — replaces the current note (single-note model). */ const handleSelectNote = useCallback(async (entry: VaultEntry) => { requestedActiveTabPathRef.current = entry.path - const alreadyViewingDirtyEntry = pathsMatch(entry.path, activeTabPathRef.current) + const alreadyViewingDirtyEntry = notePathsMatch(entry.path, activeTabPathRef.current) && !!hasUnsavedChanges?.(entry.path) if (!alreadyViewingDirtyEntry) { beginNoteOpenTrace(entry.path, 'select-note') @@ -532,7 +521,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { requestedActiveTabPathRef.current = entry.path - const replacingDifferentEntry = !pathsMatch(entry.path, activeTabPathRef.current) + const replacingDifferentEntry = !notePathsMatch(entry.path, activeTabPathRef.current) if (replacingDifferentEntry) { beginNoteOpenTrace(entry.path, 'replace-active-tab') } diff --git a/src/utils/notePathIdentity.test.ts b/src/utils/notePathIdentity.test.ts new file mode 100644 index 00000000..d2537b4c --- /dev/null +++ b/src/utils/notePathIdentity.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + findByCollidingNotePath, + joinVaultPath, + normalizeVaultRelativePath, + notePathFilename, + notePathsCollide, + notePathsMatch, + vaultRelativePathLabel, +} from './notePathIdentity' + +describe('notePathIdentity', () => { + it('matches macOS /tmp aliases and separator variants without folding case', () => { + expect(notePathsMatch('/private/tmp/vault/Project\\Active.md', '/tmp/vault/Project/Active.md')).toBe(true) + expect(notePathsMatch('/tmp/vault/Project.md', '/tmp/vault/project.md')).toBe(false) + }) + + it('uses case-insensitive comparison only for collision checks', () => { + expect(notePathsCollide('/private/tmp/vault/Project.md', '/tmp/vault/project.md')).toBe(true) + expect(findByCollidingNotePath([{ path: '/tmp/vault/project.md' }], '/private/tmp/vault/Project.md')).toEqual({ + path: '/tmp/vault/project.md', + }) + }) + + it('normalizes relative folder paths and labels', () => { + expect(normalizeVaultRelativePath(String.raw`/projects\active/`)).toBe('projects/active') + expect(vaultRelativePathLabel(String.raw`/projects\active/`)).toBe('active') + }) + + it('joins vault paths without changing Windows verbatim roots', () => { + const vaultPath = String.raw`\\?\C:\Users\alex\Tolaria` + expect(joinVaultPath(vaultPath, 'note.md')).toBe(String.raw`\\?\C:\Users\alex\Tolaria/note.md`) + }) + + it('extracts filenames from slash or backslash paths', () => { + expect(notePathFilename(String.raw`C:\Users\alex\Tolaria\note.md`)).toBe('note.md') + }) +}) diff --git a/src/utils/notePathIdentity.ts b/src/utils/notePathIdentity.ts new file mode 100644 index 00000000..318c4f11 --- /dev/null +++ b/src/utils/notePathIdentity.ts @@ -0,0 +1,67 @@ +export type NotePath = string +export type VaultPath = string +export type VaultRelativePath = string + +type PathLike = NotePath | null | undefined +type ItemWithNotePath = { path: NotePath } + +function stripWindowsExtendedPathPrefix(path: NotePath): NotePath { + return path + .replace(/^\\\\\?\\UNC\\/iu, '//') + .replace(/^\\\\\?\\/u, '') +} + +export function normalizeNotePathSeparators(path: NotePath): NotePath { + return stripWindowsExtendedPathPrefix(path).replaceAll('\\', '/') +} + +export function normalizeNotePathForIdentity(path: NotePath): NotePath { + return normalizeNotePathSeparators(path) + .replace(/^\/private\/tmp(?=\/|$)/u, '/tmp') + .replace(/\/+$/u, '') +} + +export function normalizeNotePathForCollision(path: NotePath): NotePath { + return normalizeNotePathForIdentity(path).toLocaleLowerCase() +} + +export function notePathsMatch(leftPath: PathLike, rightPath: PathLike): boolean { + if (!leftPath || !rightPath) return false + return normalizeNotePathForIdentity(leftPath) === normalizeNotePathForIdentity(rightPath) +} + +export function notePathsCollide(leftPath: PathLike, rightPath: PathLike): boolean { + if (!leftPath || !rightPath) return false + return normalizeNotePathForCollision(leftPath) === normalizeNotePathForCollision(rightPath) +} + +export function findByNotePath(items: T[], path: PathLike): T | undefined { + if (!path) return undefined + return items.find((item) => notePathsMatch(item.path, path)) +} + +export function findByCollidingNotePath(items: T[], path: PathLike): T | undefined { + if (!path) return undefined + return items.find((item) => notePathsCollide(item.path, path)) +} + +export function notePathFilename(path: NotePath): string { + const normalized = normalizeNotePathSeparators(path).replace(/\/+$/u, '') + return normalized.split('/').filter(Boolean).at(-1) ?? normalized +} + +export function normalizeVaultRelativePath(path: VaultRelativePath): VaultRelativePath { + return normalizeNotePathSeparators(path.trim()).replace(/^\/+|\/+$/gu, '') +} + +export function vaultRelativePathLabel(path: VaultRelativePath): string { + const normalized = normalizeVaultRelativePath(path) + return normalized.split('/').filter(Boolean).at(-1) ?? normalized +} + +export function joinVaultPath(vaultPath: VaultPath, relativePath: VaultRelativePath): NotePath { + const root = vaultPath.replace(/[\\/]+$/u, '') || '/' + const child = normalizeVaultRelativePath(relativePath) + if (!child) return root + return root === '/' ? `/${child}` : `${root}/${child}` +} diff --git a/src/utils/pulledVaultRefresh.ts b/src/utils/pulledVaultRefresh.ts index e1da4c34..82d92f18 100644 --- a/src/utils/pulledVaultRefresh.ts +++ b/src/utils/pulledVaultRefresh.ts @@ -1,4 +1,5 @@ import type { VaultEntry } from '../types' +import { findByNotePath, joinVaultPath, normalizeNotePathForIdentity, notePathsMatch } from './notePathIdentity' interface PulledVaultRefreshOptions { activeTabPath: string | null @@ -13,25 +14,17 @@ interface PulledVaultRefreshOptions { vaultPath: string } -function normalizePath(path: string): string { - return path - .replaceAll('\\', '/') - .replace(/^\/private\/tmp(?=\/|$)/u, '/tmp') - .replace(/\/+$/u, '') -} - function resolveUpdatedFilePath(path: string, vaultPath: string): string { - if (path.startsWith('/')) return normalizePath(path) - return normalizePath(`${vaultPath}/${path}`) + if (path.startsWith('/')) return normalizeNotePathForIdentity(path) + return normalizeNotePathForIdentity(joinVaultPath(vaultPath, path)) } function didPullUpdateActiveNote(updatedFiles: string[], vaultPath: string, activeTabPath: string): boolean { - const normalizedActivePath = normalizePath(activeTabPath) - return updatedFiles.some((path) => resolveUpdatedFilePath(path, vaultPath) === normalizedActivePath) + return updatedFiles.some((path) => notePathsMatch(resolveUpdatedFilePath(path, vaultPath), activeTabPath)) } function didActivePathChange(initialPath: string, latestPath: string): boolean { - return normalizePath(initialPath) !== normalizePath(latestPath) + return !notePathsMatch(initialPath, latestPath) } export async function refreshPulledVaultState(options: PulledVaultRefreshOptions): Promise { @@ -59,7 +52,7 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions if (didActivePathChange(activeTabPath, latestActiveTabPath)) return entries if (hasUnsavedChanges(latestActiveTabPath)) return entries - const refreshedEntry = entries.find(entry => normalizePath(entry.path) === normalizePath(latestActiveTabPath)) + const refreshedEntry = findByNotePath(entries, latestActiveTabPath) if (!refreshedEntry) { closeAllTabs() return entries