fix: make note renames crash-safe
This commit is contained in:
@@ -239,7 +239,7 @@ Tolaria separates **display title** from the file identifier:
|
||||
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **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 update the filename and wikilinks across the vault. The editor body remains the title editing surface.
|
||||
- **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.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
|
||||
@@ -382,6 +382,8 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
`~/.laputa/cache/<vault-hash>.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: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
|
||||
|
||||
`<vault>/.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.
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
```mermaid
|
||||
@@ -584,7 +586,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` — stage crash-safe file renames, update `title` frontmatter, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
@@ -617,7 +619,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
|
||||
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
|
||||
| `create_vault_folder` | Create a folder relative to the active vault root |
|
||||
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
|
||||
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
|
||||
|
||||
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0075"
|
||||
title: "Crash-safe note rename transactions"
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's note rename path used a simple "write new file, then delete old file" flow. That was easy to implement, but it had three integrity problems called out by issue #205: it could leave a visible duplicate note if the app crashed between those steps, destination-path selection depended on check-then-use races, and backlink rewrite failures were collapsed into a generic updated-files count that made partial success look clean.
|
||||
|
||||
Rename is a core vault integrity operation. The app needs a flow that preserves a trustworthy visible state even when the process dies mid-rename, and it needs to surface any partial backlink rewrite failures clearly enough that users are not told everything updated when some linked files still need manual repair.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now stages note renames through a hidden per-vault transaction directory and recovers unfinished transactions on the next vault scan.**
|
||||
|
||||
- A rename that changes the file path first writes a transaction manifest plus a hidden backup path inside `<vault>/.tolaria-rename-txn/`.
|
||||
- Tolaria moves the old note into that hidden backup, persists the new file with a no-clobber destination write, and then deletes the backup/manifest only after the new note exists.
|
||||
- If the process crashes before the new note is committed, the next `scan_vault` restores the hidden backup back to the original path before listing entries.
|
||||
- Manual filename renames keep their explicit conflict semantics, but the final destination is now claimed with a no-clobber write instead of relying on an existence check.
|
||||
- Backlink rewrites now return both the number of successful updates and the number of failed updates so the UI can warn about partial success instead of reporting a clean rename.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Hidden transaction directory + scan-time recovery** (chosen): keeps in-flight rename artifacts out of the visible vault model, gives Tolaria a deterministic recovery point after crashes, and lets the final destination use no-clobber persistence.
|
||||
- **Rename in place without transaction metadata**: simpler, but it cannot recover a half-finished rename reliably after process death and still leaves either duplicate or missing-note windows.
|
||||
- **Best-effort duplicate cleanup with no recovery path**: lowest implementation cost, but it leaves the user-visible vault state dependent on exact crash timing and does not meet the trustworthiness goal for rename operations.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every vault can now contain a hidden `.tolaria-rename-txn/` directory managed by Tolaria; scan and folder UI continue to ignore it because hidden directories are already excluded.
|
||||
- Rename results are richer: the frontend must treat `failed_updates > 0` as a warning state even when the rename itself succeeded.
|
||||
- Future changes to vault scanning or note rename behavior must preserve transaction recovery before entry listing, otherwise crash safety regresses.
|
||||
- The rename path no longer silently overwrites a destination discovered via a stale existence check; title-driven renames retry with suffixed filenames, while explicit filename renames fail cleanly on collision.
|
||||
@@ -37,6 +37,6 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -9,6 +9,7 @@ mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
mod rename;
|
||||
mod rename_transaction;
|
||||
mod title_sync;
|
||||
mod trash;
|
||||
mod views;
|
||||
@@ -415,6 +416,14 @@ pub fn scan_vault(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
|
||||
log::warn!(
|
||||
"Failed to recover pending rename transactions in {}: {}",
|
||||
vault_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::rename_transaction::RenameWorkspace;
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
/// Result of a rename operation
|
||||
@@ -14,6 +16,14 @@ pub struct RenameResult {
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wiki link replacements)
|
||||
pub updated_files: usize,
|
||||
/// Number of linked-note rewrites that failed and need manual attention
|
||||
pub failed_updates: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WikilinkUpdateSummary {
|
||||
updated_files: usize,
|
||||
failed_updates: usize,
|
||||
}
|
||||
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
|
||||
@@ -95,10 +105,10 @@ fn update_wikilinks_in_vault(
|
||||
old_targets: &[&str],
|
||||
new_target: &str,
|
||||
exclude_path: &Path,
|
||||
) -> usize {
|
||||
) -> WikilinkUpdateSummary {
|
||||
let re = match build_wikilink_pattern(old_targets) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
None => return WikilinkUpdateSummary::default(),
|
||||
};
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
@@ -107,23 +117,29 @@ fn replace_wikilinks_in_files(
|
||||
files: Vec<std::path::PathBuf>,
|
||||
re: &Regex,
|
||||
replacement: &str,
|
||||
) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
|
||||
.count()
|
||||
) -> WikilinkUpdateSummary {
|
||||
let mut summary = WikilinkUpdateSummary::default();
|
||||
for path in files.iter() {
|
||||
match rewrite_wikilinks_in_file(path, re, replacement) {
|
||||
Ok(true) => summary.updated_files += 1,
|
||||
Ok(false) => {}
|
||||
Err(_) => summary.failed_updates += 1,
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> Result<bool, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
|
||||
return false;
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
fs::write(path, &new_content).is_ok()
|
||||
fs::write(path, &new_content)
|
||||
.map(|_| true)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
/// Extract the value of the `title:` frontmatter field from raw content.
|
||||
@@ -168,28 +184,26 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
/// `exclude` is the source file being renamed — it should not be treated as a collision.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() || dest == exclude {
|
||||
return dest;
|
||||
}
|
||||
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 = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
|
||||
if !candidate.exists() || candidate == exclude {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
super::rename_transaction::recover_pending_rename_transactions(vault)
|
||||
}
|
||||
|
||||
fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(), String> {
|
||||
staged
|
||||
.persist(target_path)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to replace {}: {}", target_path.display(), e.error))
|
||||
}
|
||||
|
||||
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
|
||||
RenameResult {
|
||||
new_path,
|
||||
updated_files: summary.updated_files,
|
||||
failed_updates: summary.failed_updates,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +223,66 @@ fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
struct LoadedNote {
|
||||
content: String,
|
||||
filename: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn unchanged_result(path: &str) -> RenameResult {
|
||||
RenameResult {
|
||||
new_path: path.to_string(),
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_new_title(new_title: &str) -> Result<&str, String> {
|
||||
let trimmed = new_title.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
|
||||
if old_file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("File does not exist: {}", old_path))
|
||||
}
|
||||
|
||||
fn load_note_for_title_rename(
|
||||
old_file: &Path,
|
||||
old_path: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<LoadedNote, String> {
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &filename);
|
||||
|
||||
Ok(LoadedNote {
|
||||
content,
|
||||
filename,
|
||||
title: old_title_hint.unwrap_or(&extracted_title).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_title_only_update(
|
||||
workspace: &RenameWorkspace,
|
||||
old_file: &Path,
|
||||
updated_content: &str,
|
||||
old_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
|
||||
Ok(unchanged_result(old_path))
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
@@ -223,69 +297,47 @@ pub fn rename_note(
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
let new_title = new_title.trim();
|
||||
if new_title.is_empty() {
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let old_title = old_title_hint.unwrap_or(&extracted_title);
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
let new_title = validate_new_title(new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
|
||||
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
let expected_filename = format!("{}.md", title_to_slug(new_title));
|
||||
let title_unchanged = old_title == new_title;
|
||||
let filename_matches = old_filename == expected_filename;
|
||||
let title_unchanged = loaded.title == new_title;
|
||||
let filename_matches = loaded.filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
return Ok(unchanged_result(old_path));
|
||||
}
|
||||
|
||||
// Update content only if the title actually changed
|
||||
let updated_content = if title_unchanged {
|
||||
content.clone()
|
||||
loaded.content.clone()
|
||||
} else {
|
||||
update_note_title_in_content(&content, new_title)
|
||||
update_note_title_in_content(&loaded.content, new_title)
|
||||
};
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
|
||||
if filename_matches {
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
|
||||
}
|
||||
|
||||
// Compute new path, handling collisions with numeric suffix
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
if old_file != new_file {
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
|
||||
}
|
||||
|
||||
// Update wikilinks across the vault
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.rename_with_candidates(
|
||||
workspace.stage_note_content(&updated_content)?,
|
||||
&expected_filename,
|
||||
parent_dir,
|
||||
)?;
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
updated_files,
|
||||
})
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
@@ -297,6 +349,8 @@ pub fn rename_note_filename(
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
@@ -313,40 +367,22 @@ pub fn rename_note_filename(
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
return Ok(unchanged_result(old_path));
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
if new_file.exists() && new_file != old_file {
|
||||
return Err("A note with that name already exists".to_string());
|
||||
}
|
||||
|
||||
fs::rename(old_file, &new_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
old_path,
|
||||
new_file.to_string_lossy(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path,
|
||||
updated_files,
|
||||
})
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
@@ -455,8 +491,8 @@ pub fn update_wikilinks_for_renames(
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += updated;
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += summary.updated_files;
|
||||
}
|
||||
|
||||
Ok(total_updated)
|
||||
@@ -477,6 +513,29 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
struct RenameTestRequest<'a> {
|
||||
path: &'a str,
|
||||
content: &'a str,
|
||||
new_title: &'a str,
|
||||
old_title_hint: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn rename_test_note_file(
|
||||
vault: &Path,
|
||||
request: RenameTestRequest<'_>,
|
||||
) -> (std::path::PathBuf, RenameResult) {
|
||||
create_test_file(vault, request.path, request.content);
|
||||
let old_path = vault.join(request.path);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
request.new_title,
|
||||
request.old_title_hint,
|
||||
)
|
||||
.unwrap();
|
||||
(old_path, result)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
@@ -552,25 +611,6 @@ mod tests {
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_same_title_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_empty_title_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -588,54 +628,81 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
|
||||
);
|
||||
fn test_rename_note_noop_variants() {
|
||||
for old_title_hint in [None, Some("My Note")] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let (old_path, result) = rename_test_note_file(
|
||||
vault,
|
||||
RenameTestRequest {
|
||||
path: "note/my-note.md",
|
||||
content: "# My Note\n\nContent.\n",
|
||||
new_title: "My Note",
|
||||
old_title_hint,
|
||||
},
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
assert_eq!(result.failed_updates, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
);
|
||||
fn test_rename_note_updates_legacy_wikilink_targets() {
|
||||
struct WikilinkRewriteCase<'a> {
|
||||
ref_content: &'a str,
|
||||
note_content: &'a str,
|
||||
new_title: &'a str,
|
||||
expected_link: &'a str,
|
||||
removed_link: Option<&'a str>,
|
||||
}
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cases = [
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
|
||||
note_content: "# Weekly Review\n",
|
||||
new_title: "Sprint Retro",
|
||||
expected_link: "[[note/sprint-retro|my review]]",
|
||||
removed_link: None,
|
||||
},
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
note_content: "# Weekly Review\n",
|
||||
new_title: "Sprint Retro",
|
||||
expected_link: "[[note/sprint-retro]]",
|
||||
removed_link: Some("[[weekly-review]]"),
|
||||
},
|
||||
WikilinkRewriteCase {
|
||||
ref_content: "See [[Weekly Review]] for details.\n",
|
||||
note_content: "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
new_title: "Sprint Retrospective",
|
||||
expected_link: "[[note/sprint-retrospective]]",
|
||||
removed_link: Some("[[Weekly Review]]"),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro]]"));
|
||||
assert!(!ref_content.contains("[[weekly-review]]"));
|
||||
for case in cases {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/ref.md", case.ref_content);
|
||||
let (_old_path, result) = rename_test_note_file(
|
||||
vault,
|
||||
RenameTestRequest {
|
||||
path: "note/weekly-review.md",
|
||||
content: case.note_content,
|
||||
new_title: case.new_title,
|
||||
old_title_hint: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains(case.expected_link));
|
||||
if let Some(removed_link) = case.removed_link {
|
||||
assert!(!ref_content.contains(removed_link));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -909,36 +976,6 @@ mod tests {
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_without_hint_backward_compatible() {
|
||||
// Existing behavior: no hint, extracts title from H1
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"See [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_does_not_modify_h1() {
|
||||
// H1 is body content — rename should only update frontmatter title, not H1
|
||||
@@ -975,22 +1012,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_hint_same_as_new_title_noop() {
|
||||
// If old_title_hint == new_title, should be a noop
|
||||
fn test_replace_wikilinks_in_files_reports_failed_updates() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
create_test_file(vault, "note/ref.md", "See [[Old Note]] for details.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
Some("My Note"),
|
||||
let pattern = build_wikilink_pattern(&["Old Note"]).unwrap();
|
||||
let summary = replace_wikilinks_in_files(
|
||||
vec![vault.join("note/ref.md"), vault.join("note/missing.md")],
|
||||
&pattern,
|
||||
"note/new-note",
|
||||
);
|
||||
|
||||
assert_eq!(summary.updated_files, 1);
|
||||
assert_eq!(summary.failed_updates, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_pending_rename_transactions_restores_backup_when_new_file_is_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let old_path = vault.join("note/original.md");
|
||||
let new_path = vault.join("note/renamed.md");
|
||||
|
||||
create_test_file(vault, "note/original.md", "# Original\n");
|
||||
|
||||
let txn_dir = vault.join(".tolaria-rename-txn");
|
||||
fs::create_dir_all(&txn_dir).unwrap();
|
||||
|
||||
let backup_path = txn_dir.join("rename-backup.bak");
|
||||
let manifest_path = txn_dir.join("rename-transaction.json");
|
||||
fs::rename(&old_path, &backup_path).unwrap();
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
serde_json::json!({
|
||||
"old_path": old_path.to_string_lossy().to_string(),
|
||||
"new_path": new_path.to_string_lossy().to_string(),
|
||||
"backup_path": backup_path.to_string_lossy().to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
recover_pending_rename_transactions(vault).unwrap();
|
||||
|
||||
assert!(old_path.exists());
|
||||
assert!(!new_path.exists());
|
||||
assert!(!backup_path.exists());
|
||||
assert!(!manifest_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
287
src-tauri/src/vault/rename_transaction.rs
Normal file
287
src-tauri/src/vault/rename_transaction.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RenameTransaction {
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
backup_path: String,
|
||||
}
|
||||
|
||||
pub(super) struct RenameWorkspace {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RenameWorkspace {
|
||||
pub(super) fn new(vault: &Path) -> Result<Self, String> {
|
||||
let dir = vault.join(".tolaria-rename-txn");
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create rename transaction dir {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
|
||||
let mut staged = NamedTempFile::new_in(&self.dir)
|
||||
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
|
||||
staged
|
||||
.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
|
||||
staged
|
||||
.as_file_mut()
|
||||
.sync_all()
|
||||
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
pub(super) fn operation<'a>(
|
||||
&self,
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
) -> RenameOperation<'a> {
|
||||
RenameOperation {
|
||||
old_path,
|
||||
old_file,
|
||||
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
|
||||
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct CommittedRename {
|
||||
new_file: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CommittedRename {
|
||||
pub(super) fn new_file(&self) -> &Path {
|
||||
&self.new_file
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommittedRename {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.backup_path);
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RenameOperation<'a> {
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
backup_path: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<'a> RenameOperation<'a> {
|
||||
pub(super) fn rename_with_candidates(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
desired_filename: &str,
|
||||
parent_dir: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
let mut staged = staged;
|
||||
for attempt in 0.. {
|
||||
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
|
||||
self.prepare(&candidate)?;
|
||||
|
||||
match staged.persist_noclobber(&candidate) {
|
||||
Ok(_) => return Ok(self.committed(candidate)),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
staged = err.file;
|
||||
self.rollback()?;
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
return Err(format!(
|
||||
"Failed to create {}: {}",
|
||||
candidate.display(),
|
||||
err.error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub(super) fn rename_exact(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
new_file: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
self.prepare(new_file)?;
|
||||
match staged.persist_noclobber(new_file) {
|
||||
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
self.rollback()?;
|
||||
Err("A note with that name already exists".to_string())
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
Err(format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
self.old_path,
|
||||
new_file.to_string_lossy(),
|
||||
err.error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, new_file: &Path) -> Result<(), String> {
|
||||
self.write_manifest(new_file)?;
|
||||
self.move_into_backup()
|
||||
}
|
||||
|
||||
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
|
||||
let transaction = RenameTransaction {
|
||||
old_path: self.old_path.to_string(),
|
||||
new_path: new_file.to_string_lossy().to_string(),
|
||||
backup_path: self.backup_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let data = serde_json::to_string(&transaction)
|
||||
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
|
||||
fs::write(&self.manifest_path, data).map_err(|e| {
|
||||
format!(
|
||||
"Failed to write rename transaction {}: {}",
|
||||
self.manifest_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn move_into_backup(&self) -> Result<(), String> {
|
||||
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to move {} into rename backup {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn rollback(&self) -> Result<(), String> {
|
||||
if self.backup_path.exists() {
|
||||
if let Some(parent) = self.old_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore {} from {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn committed(&self, new_file: PathBuf) -> CommittedRename {
|
||||
CommittedRename {
|
||||
new_file,
|
||||
manifest_path: self.manifest_path.clone(),
|
||||
backup_path: self.backup_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_filename(filename: &str, attempt: usize) -> 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();
|
||||
if attempt == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
format!("{}-{}{}", stem, attempt + 1, ext)
|
||||
}
|
||||
|
||||
fn transaction_dir(vault: &Path) -> PathBuf {
|
||||
vault.join(".tolaria-rename-txn")
|
||||
}
|
||||
|
||||
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
let txn_dir = transaction_dir(vault);
|
||||
if !txn_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&txn_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read rename transaction dir {}: {}",
|
||||
txn_dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
for entry in entries {
|
||||
let path = entry
|
||||
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
|
||||
.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(data) = fs::read_to_string(&path) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
recover_rename_transaction(&path, transaction)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recover_rename_transaction(
|
||||
manifest_path: &Path,
|
||||
transaction: RenameTransaction,
|
||||
) -> Result<(), String> {
|
||||
let old_path = Path::new(&transaction.old_path);
|
||||
let new_path = Path::new(&transaction.new_path);
|
||||
let backup_path = Path::new(&transaction.backup_path);
|
||||
|
||||
if !backup_path.exists() {
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if new_path.exists() || old_path.exists() {
|
||||
let _ = fs::remove_file(backup_path);
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = old_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(backup_path, old_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to recover {} from {}: {}",
|
||||
old_path.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitle
|
||||
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent })
|
||||
}
|
||||
await reloadVaultAfterRename(deps.reloadVault)
|
||||
deps.setToastMessage(renameToastMessage(result.updated_files))
|
||||
deps.setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0))
|
||||
}
|
||||
|
||||
function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value is string {
|
||||
|
||||
@@ -62,15 +62,23 @@ describe('buildRenamedEntry', () => {
|
||||
|
||||
describe('renameToastMessage', () => {
|
||||
it('returns "Renamed" when no files updated', () => {
|
||||
expect(renameToastMessage(0)).toBe('Renamed')
|
||||
expect(renameToastMessage(0, 0)).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('returns singular when 1 file updated', () => {
|
||||
expect(renameToastMessage(1)).toBe('Updated 1 note')
|
||||
expect(renameToastMessage(1, 0)).toBe('Updated 1 note')
|
||||
})
|
||||
|
||||
it('returns plural when multiple files updated', () => {
|
||||
expect(renameToastMessage(3)).toBe('Updated 3 notes')
|
||||
expect(renameToastMessage(3, 0)).toBe('Updated 3 notes')
|
||||
})
|
||||
|
||||
it('surfaces failed linked-note rewrites even when some updates succeeded', () => {
|
||||
expect(renameToastMessage(2, 1)).toBe('Updated 2 notes, but 1 linked note needs manual updates')
|
||||
})
|
||||
|
||||
it('surfaces failed linked-note rewrites when none of them updated cleanly', () => {
|
||||
expect(renameToastMessage(0, 2)).toBe('Renamed, but 2 linked notes need manual updates')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,30 +89,66 @@ describe('useNoteRename hook', () => {
|
||||
const updateTabContent = vi.fn()
|
||||
const activeTabPathRef = { current: null as string | null }
|
||||
|
||||
type RenameNoteResult = {
|
||||
new_path: string
|
||||
updated_files: number
|
||||
failed_updates: number
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
activeTabPathRef.current = null
|
||||
})
|
||||
|
||||
it('handleRenameNote calls rename_note and updates toast', async () => {
|
||||
const entry = makeEntry({ path: '/vault/old.md', title: 'Old' })
|
||||
const stubRenameNote = (
|
||||
renameResult: RenameNoteResult,
|
||||
content = '# New\n',
|
||||
) => {
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/vault/new.md', updated_files: 2 }
|
||||
if (cmd === 'get_note_content') return '# New\n'
|
||||
if (cmd === 'rename_note') return renameResult
|
||||
if (cmd === 'get_note_content') return content
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [entry], setToastMessage },
|
||||
const renderUseNoteRename = (entries: VaultEntry[] = []) =>
|
||||
renderHook(() => useNoteRename(
|
||||
{ entries, setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
const onEntryRenamed = vi.fn()
|
||||
const runHandleRenameNote = async ({
|
||||
entries = [],
|
||||
renameResult = { new_path: '/vault/new.md', updated_files: 0, failed_updates: 0 },
|
||||
activePath = null,
|
||||
onEntryRenamed = vi.fn(),
|
||||
}: {
|
||||
entries?: VaultEntry[]
|
||||
renameResult?: RenameNoteResult
|
||||
activePath?: string | null
|
||||
onEntryRenamed?: ReturnType<typeof vi.fn>
|
||||
} = {}) => {
|
||||
activeTabPathRef.current = activePath
|
||||
stubRenameNote(renameResult)
|
||||
|
||||
const { result } = renderUseNoteRename(entries)
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', onEntryRenamed)
|
||||
})
|
||||
|
||||
return { onEntryRenamed }
|
||||
}
|
||||
|
||||
it('handleRenameNote calls rename_note and updates toast', async () => {
|
||||
const entry = makeEntry({ path: '/vault/old.md', title: 'Old' })
|
||||
const onEntryRenamed = vi.fn()
|
||||
await runHandleRenameNote({
|
||||
entries: [entry],
|
||||
renameResult: { new_path: '/vault/new.md', updated_files: 2, failed_updates: 0 },
|
||||
onEntryRenamed,
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
old_path: '/vault/old.md',
|
||||
new_title: 'New',
|
||||
@@ -115,20 +159,7 @@ describe('useNoteRename hook', () => {
|
||||
})
|
||||
|
||||
it('handleRenameNote passes null old_title when entry not found', async () => {
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/vault/new.md', updated_files: 0 }
|
||||
if (cmd === 'get_note_content') return '# New\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', vi.fn())
|
||||
})
|
||||
await runHandleRenameNote()
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({ old_title: null }))
|
||||
})
|
||||
@@ -149,20 +180,9 @@ describe('useNoteRename hook', () => {
|
||||
})
|
||||
|
||||
it('switches active tab when renamed note is active', async () => {
|
||||
activeTabPathRef.current = '/vault/old.md'
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/vault/new.md', updated_files: 0 }
|
||||
if (cmd === 'get_note_content') return '# New\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [makeEntry({ path: '/vault/old.md' })], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', vi.fn())
|
||||
await runHandleRenameNote({
|
||||
entries: [makeEntry({ path: '/vault/old.md' })],
|
||||
activePath: '/vault/old.md',
|
||||
})
|
||||
|
||||
expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md')
|
||||
@@ -171,7 +191,7 @@ describe('useNoteRename hook', () => {
|
||||
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) => {
|
||||
if (cmd === 'rename_note_filename') return { new_path: '/vault/manual-name.md', updated_files: 1 }
|
||||
if (cmd === 'rename_note_filename') return { new_path: '/vault/manual-name.md', updated_files: 1, failed_updates: 0 }
|
||||
if (cmd === 'get_note_content') return '# Project Kickoff\n'
|
||||
return ''
|
||||
})
|
||||
@@ -202,6 +222,18 @@ describe('useNoteRename hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note')
|
||||
})
|
||||
|
||||
it('warns when rename succeeds but some backlink rewrites fail', async () => {
|
||||
const entry = makeEntry({ path: '/vault/old.md', title: 'Old' })
|
||||
await runHandleRenameNote({
|
||||
entries: [entry],
|
||||
renameResult: { new_path: '/vault/new.md', updated_files: 1, failed_updates: 2 },
|
||||
})
|
||||
|
||||
expect(setToastMessage).toHaveBeenCalledWith(
|
||||
'Updated 1 note, but 2 linked notes need manual updates',
|
||||
)
|
||||
})
|
||||
|
||||
it('handleRenameFilename surfaces backend conflict errors', async () => {
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('A note with that name already exists'))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { slugify } from './useNoteCreation'
|
||||
interface RenameResult {
|
||||
new_path: string
|
||||
updated_files: number
|
||||
failed_updates?: number
|
||||
}
|
||||
|
||||
export { slugify }
|
||||
@@ -86,7 +87,14 @@ export async function loadNoteContent({ path }: LoadNoteContentRequest): Promise
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
}
|
||||
|
||||
export function renameToastMessage(updatedFiles: number): string {
|
||||
export function renameToastMessage(updatedFiles: number, failedUpdates = 0): string {
|
||||
if (failedUpdates > 0) {
|
||||
const noteLabel = `${failedUpdates} linked note${failedUpdates > 1 ? 's' : ''}`
|
||||
if (updatedFiles === 0) {
|
||||
return `Renamed, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
|
||||
}
|
||||
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
|
||||
}
|
||||
if (updatedFiles === 0) return 'Renamed'
|
||||
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
|
||||
}
|
||||
@@ -166,7 +174,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
onEntryRenamed(oldPath, newEntry, newContent)
|
||||
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent })
|
||||
await reloadVaultAfterRename(reloadVault)
|
||||
setToastMessage(renameToastMessage(result.updated_files))
|
||||
setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0))
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, reloadVault, setToastMessage])
|
||||
|
||||
const handleRenameNote = useCallback(async (
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('mockHandlers coverage', () => {
|
||||
expect(result).toEqual({
|
||||
new_path: `${vaultPath}/new-title.md`,
|
||||
updated_files: 1,
|
||||
failed_updates: 0,
|
||||
})
|
||||
expect(updatedContent[`${vaultPath}/new-title.md`]).toContain('title: New Title')
|
||||
expect(updatedContent[referencePath]).toBe('See [[new-title]] and [[new-title]].')
|
||||
@@ -60,6 +61,7 @@ describe('mockHandlers coverage', () => {
|
||||
})).toEqual({
|
||||
new_path: notePath,
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('mockHandlers additional coverage', () => {
|
||||
})).toEqual({
|
||||
new_path: `${vaultPath}/weekly-notes.md`,
|
||||
updated_files: 1,
|
||||
failed_updates: 0,
|
||||
})
|
||||
|
||||
const content = mockHandlers.get_all_content() as Record<string, string>
|
||||
|
||||
@@ -219,7 +219,7 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
|
||||
if (oldTitle === args.new_title && newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
|
||||
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
|
||||
@@ -229,7 +229,7 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
function handleRenameNoteFilename(args: {
|
||||
@@ -248,7 +248,7 @@ function handleRenameNoteFilename(args: {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
if (oldFilename === newFilename) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
@@ -266,7 +266,7 @@ function handleRenameNoteFilename(args: {
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
|
||||
Reference in New Issue
Block a user