From fd4e6f66f004b1fb7ce21da668c8a82aae9d3ca6 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 04:31:16 +0000 Subject: [PATCH] Add Move/Copy/Symlink rename actions, with undo support for all three - renamr-core::rename: new RenameAction enum (Move/Copy/Symlink, default Move, mirroring net.filebot.cli.CmdlineOperations's --action modes). execute_renames() now takes an action applied to the whole batch. Copy uses fs::copy (source untouched); Symlink uses std::os::unix::fs::symlink / std::os::windows::fs::symlink_file behind a cfg(unix)/cfg(windows) split. Same conflict policy (skip, never overwrite) and auto-created destination folders apply to all three actions. 8 new tests. - renamr-core::history: HistoryBatch now records which action produced it (#[serde(default)] so old history.json entries - all implicitly Move - still deserialize). revert_one() branches on it: Move still moves the file back; Copy/ Symlink undo by deleting the copy/symlink (using symlink_metadata so a broken link is still recognized as present and removable) - the original was never touched by those actions, so there's nothing to move back. 3 new tests. - src-tauri: execute_rename command takes the chosen action and threads it through to both execute_renames and record_batch. - Frontend: a Move/Copy/Symlink segmented selector in the "Confirm Rename" modal (remembered across modal opens, defaults to Move), action-aware subtitle/button copy, and a status message using the right verb (Renamed/Copied/Symlinked). Only Move reconciles a loaded item's id/name/bindings to the new path afterward, since Copy/ Symlink leave the source file exactly where it was. History panel now shows a Copy/ Symlink badge on non-Move batches and explains what Undo does for each. Co-Authored-By: Claude Sonnet 5 --- README.md | 37 +++-- crates/renamr-core/src/history/mod.rs | 200 +++++++++++++++++++---- crates/renamr-core/src/rename/mod.rs | 220 +++++++++++++++++++++++--- src-tauri/src/history.rs | 15 +- src-tauri/src/lib.rs | 25 +-- src/components/RenamePanel.css | 38 +++++ src/components/RenamePanel.tsx | 138 +++++++++++----- 7 files changed, 546 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index aac56a6..88381dc 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ native file/folder loading and drag-and-drop, local filename-based season/episod movie title/year* detection ("Match") is working, real online metadata lookups against TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy title disambiguation, so a search that returns several similarly-named results doesn't -just take TMDB's top-ranked hit — and the actual on-disk rename operation is wired up +just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (skip-on-conflict, with a confirmation step showing the full old-path/new-path plan -before anything touches disk, and a per-row ✕ to drop an individual file from that plan -at the last step). Rename can also organize files into subfolders — -including ones that don't exist yet — when the naming scheme includes a `/`. Every -completed rename is logged to a history panel that can undo it, moving files back to -their original paths. A single loaded batch can freely mix TV episodes and movies — -each file only fills in the fields that apply to it. Full media-metadata bindings are +before anything touches disk, a per-row ✕ to drop an individual file from that plan at +the last step, and a Move/Copy/Symlink selector for what to actually do at each +target). It can also organize files into subfolders — including ones that don't exist +yet — when the naming scheme includes a `/`. Every completed batch is logged to a +history panel that can undo it (moving files back for Move, or just removing what was +created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies +— each file only fills in the fields that apply to it. Full media-metadata bindings are not yet ported. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next @@ -115,12 +116,12 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup and movie search + lookup, both disambiguated via `similarity/`, tested against a mocked HTTP server via `mockito`), the on-disk - rename engine (`rename/`) — - skip-on-conflict, can also organize into (auto-created) - subfolders when the naming scheme includes `/` — and a rename - history/undo log (`history/`, a partial `HistoryDialog` port). - Not yet ported: full media metadata bindings, other web - datasource clients (TVDB/AniDB/etc). + rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink + actions, can also organize into (auto-created) subfolders when + the naming scheme includes `/`) — and a rename history/undo log + (`history/`, a partial `HistoryDialog` port, undo-aware of which + action produced each batch). Not yet ported: full media metadata + bindings, other web datasource clients (TVDB/AniDB/etc). No Tauri dependency, so it builds and unit-tests standalone. src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend (naming-scheme rendering, filename matching for both TV and @@ -227,9 +228,13 @@ ported from, for anyone picking this up: UI's Rename button behind a confirmation step) including the `move`-into-subfolder `--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing folders automatically, always scoped to the file's - own directory tree. Not yet done: the `copy`/`symlink` `--action` variants, the - `--conflict` variants beyond skip (auto-number, override), and the CLI flag surface - itself. + own directory tree. The `copy`/`symlink` `--action` variants are done too — an action + selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy + and Symlink both leave the source file untouched and produce a new file/link at the + computed target, and both are undoable from the History panel the same as Move (undo + for Copy/Symlink just removes what was created, since there's nothing to move back). + Not yet done: the `--conflict` variants beyond skip (auto-number, override), and the + CLI flag surface itself. 7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview, local TV/movie matching, confirmed rename with per-item exclude, rename history/undo) is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are diff --git a/crates/renamr-core/src/history/mod.rs b/crates/renamr-core/src/history/mod.rs index 8691b2d..18a6833 100644 --- a/crates/renamr-core/src/history/mod.rs +++ b/crates/renamr-core/src/history/mod.rs @@ -9,6 +9,7 @@ //! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same //! split as `rename` (pure logic here) vs. the Tauri command that calls it. +use crate::rename::RenameAction; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; @@ -22,7 +23,8 @@ pub struct HistoryEntry { } /// One executed rename operation - everything renamed together in a single "Rename N -/// Files" confirmation - undoable as a unit. +/// Files" confirmation - undoable as a unit. All entries in a batch share one +/// [`RenameAction`], since the UI picks a single action per confirmation, not per file. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HistoryBatch { @@ -31,6 +33,11 @@ pub struct HistoryBatch { /// (no date/time dependency needed in this crate) so the frontend can format it /// however it likes, e.g. `new Date(timestamp).toLocaleString()`. pub timestamp: u64, + /// What was actually done to produce `entries.new_path` from `entries.source`. + /// `#[serde(default)]` so a `history.json` written before this field existed (every + /// batch back then was necessarily a Move) still deserializes cleanly. + #[serde(default)] + pub action: RenameAction, pub entries: Vec, } @@ -38,13 +45,18 @@ pub struct HistoryBatch { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "status", rename_all = "camelCase")] pub enum RevertOutcome { - /// Successfully moved back to its original path. + /// For a Move batch: successfully moved back to its original path. For a Copy or + /// Symlink batch: the copy/symlink that was created has been removed - the original + /// file was never touched in the first place, so `restored_path` here just names it + /// for the caller rather than describing something that was actually moved. Reverted { restored_path: String }, /// A different file already exists at the original path; left untouched - the same - /// skip-never-overwrite policy `rename::execute_renames` uses. + /// skip-never-overwrite policy `rename::execute_renames` uses. Only possible for a + /// Move revert; Copy/Symlink reverts never touch the original path at all. SkippedConflict { existing_path: String }, - /// The renamed file isn't where the history says it should be anymore (moved, - /// deleted, or already reverted some other way), or the OS reported an error. + /// The renamed/copied/linked file isn't where the history says it should be + /// anymore (moved, deleted, or already reverted some other way), or the OS reported + /// an error. Failed { error: String }, } @@ -63,51 +75,76 @@ fn same_file(a: &Path, b: &Path) -> bool { } } -fn revert_one(entry: &HistoryEntry) -> RevertOutcome { +fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome { let current = Path::new(&entry.new_path); let original = Path::new(&entry.source); - if !current.exists() { - return RevertOutcome::Failed { - error: "the renamed file is no longer at its recorded location".to_string(), - }; - } + match action { + RenameAction::Move => { + if !current.exists() { + return RevertOutcome::Failed { + error: "the renamed file is no longer at its recorded location".to_string(), + }; + } - if original.exists() && !same_file(original, current) { - return RevertOutcome::SkippedConflict { - existing_path: original.to_string_lossy().to_string(), - }; - } + if original.exists() && !same_file(original, current) { + return RevertOutcome::SkippedConflict { + existing_path: original.to_string_lossy().to_string(), + }; + } - // The original's parent directory should still exist (nothing in Renamr deletes - // directories), but recreate it defensively in case it was removed by hand since - - // cheap, and `create_dir_all` is a no-op when it's already there. - if let Some(parent) = original.parent() { - if let Err(e) = fs::create_dir_all(parent) { - return RevertOutcome::Failed { - error: format!("couldn't recreate the original folder: {e}"), - }; + // The original's parent directory should still exist (nothing in Renamr + // deletes directories), but recreate it defensively in case it was removed + // by hand since - cheap, and `create_dir_all` is a no-op when it's already + // there. + if let Some(parent) = original.parent() { + if let Err(e) = fs::create_dir_all(parent) { + return RevertOutcome::Failed { + error: format!("couldn't recreate the original folder: {e}"), + }; + } + } + + match fs::rename(current, original) { + Ok(()) => RevertOutcome::Reverted { + restored_path: original.to_string_lossy().to_string(), + }, + Err(e) => RevertOutcome::Failed { error: e.to_string() }, + } } - } + RenameAction::Copy | RenameAction::Symlink => { + // Copy/Symlink never touched `original` in the first place - the file that + // was created lives only at `current`, so undoing is just removing it. + // `symlink_metadata` (rather than `exists`, which follows links) is used so + // a broken symlink - its target moved or deleted separately - still counts + // as present and removable, instead of being reported as already gone. + if fs::symlink_metadata(current).is_err() { + return RevertOutcome::Failed { + error: "the copy/symlink is no longer at its recorded location".to_string(), + }; + } - match fs::rename(current, original) { - Ok(()) => RevertOutcome::Reverted { - restored_path: original.to_string_lossy().to_string(), - }, - Err(e) => RevertOutcome::Failed { error: e.to_string() }, + match fs::remove_file(current) { + Ok(()) => RevertOutcome::Reverted { + restored_path: original.to_string_lossy().to_string(), + }, + Err(e) => RevertOutcome::Failed { error: e.to_string() }, + } + } } } -/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`], -/// entries are processed sequentially (not in parallel) so a collision between two -/// entries in the same batch resolves deterministically rather than racing. +/// Reverts every entry in a batch, in order, all under the batch's recorded +/// [`RenameAction`]. Like [`crate::rename::execute_renames`], entries are processed +/// sequentially (not in parallel) so a collision between two entries in the same batch +/// resolves deterministically rather than racing. pub fn revert_batch(batch: &HistoryBatch) -> Vec { batch .entries .iter() .map(|entry| RevertResult { source: entry.new_path.clone(), - outcome: revert_one(entry), + outcome: revert_one(entry, batch.action), }) .collect() } @@ -152,6 +189,7 @@ mod tests { let batch = HistoryBatch { id: "b1".to_string(), + action: RenameAction::Move, timestamp: 0, entries: vec![HistoryEntry { source: original.to_string_lossy().to_string(), @@ -185,6 +223,7 @@ mod tests { let batch = HistoryBatch { id: "b1".to_string(), + action: RenameAction::Move, timestamp: 0, entries: vec![HistoryEntry { source: original.to_string_lossy().to_string(), @@ -209,6 +248,7 @@ mod tests { let batch = HistoryBatch { id: "b1".to_string(), + action: RenameAction::Move, timestamp: 0, entries: vec![HistoryEntry { source: original.to_string_lossy().to_string(), @@ -236,6 +276,7 @@ mod tests { let batch = HistoryBatch { id: "b1".to_string(), + action: RenameAction::Move, timestamp: 0, entries: vec![HistoryEntry { source: original.to_string_lossy().to_string(), @@ -257,6 +298,7 @@ mod tests { HistoryBatch { id: "empty".to_string(), timestamp: 0, + action: RenameAction::Move, entries: vec![], }, ); @@ -272,6 +314,7 @@ mod tests { HistoryBatch { id: format!("batch-{i}"), timestamp: i as u64, + action: RenameAction::Move, entries: vec![HistoryEntry { source: "a".to_string(), new_path: "b".to_string(), @@ -286,4 +329,93 @@ mod tests { // The oldest batches (0..5) were dropped to stay under the cap. assert!(!history.iter().any(|b| b.id == "batch-0")); } + + #[test] + fn reverting_a_copy_batch_deletes_the_copy_and_leaves_the_original_untouched() { + let dir = temp_dir(); + let original = dir.join("original.txt"); + fs::write(&original, "hello").unwrap(); + let copy = dir.join("copied.txt"); + fs::write(©, "hello").unwrap(); + + let batch = HistoryBatch { + id: "b1".to_string(), + timestamp: 0, + action: RenameAction::Copy, + entries: vec![HistoryEntry { + source: original.to_string_lossy().to_string(), + new_path: copy.to_string_lossy().to_string(), + }], + }; + + let results = revert_batch(&batch); + match &results[0].outcome { + RevertOutcome::Reverted { restored_path } => { + assert_eq!(Path::new(restored_path), original.as_path()); + } + other => panic!("expected Reverted, got {other:?}"), + } + // The copy is gone... + assert!(!copy.exists()); + // ...but the original was never touched in the first place. + assert!(original.exists()); + assert_eq!(fs::read_to_string(&original).unwrap(), "hello"); + + fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn reverting_a_symlink_batch_removes_the_link_not_the_file_it_points_to() { + let dir = temp_dir(); + let original = dir.join("original.txt"); + fs::write(&original, "hello").unwrap(); + let link = dir.join("linked.txt"); + std::os::unix::fs::symlink(&original, &link).unwrap(); + + let batch = HistoryBatch { + id: "b1".to_string(), + timestamp: 0, + action: RenameAction::Symlink, + entries: vec![HistoryEntry { + source: original.to_string_lossy().to_string(), + new_path: link.to_string_lossy().to_string(), + }], + }; + + let results = revert_batch(&batch); + assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. })); + // The symlink is gone... + assert!(fs::symlink_metadata(&link).is_err()); + // ...but the real file it pointed at is completely unaffected. + assert!(original.exists()); + assert_eq!(fs::read_to_string(&original).unwrap(), "hello"); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn reverting_a_copy_batch_fails_gracefully_when_the_copy_is_already_gone() { + let dir = temp_dir(); + let original = dir.join("original.txt"); + fs::write(&original, "hello").unwrap(); + let copy = dir.join("copied.txt"); // never created + + let batch = HistoryBatch { + id: "b1".to_string(), + timestamp: 0, + action: RenameAction::Copy, + entries: vec![HistoryEntry { + source: original.to_string_lossy().to_string(), + new_path: copy.to_string_lossy().to_string(), + }], + }; + + let results = revert_batch(&batch); + assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. })); + // The original should still be completely untouched. + assert!(original.exists()); + + fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/renamr-core/src/rename/mod.rs b/crates/renamr-core/src/rename/mod.rs index cc6faf5..3e4c8e6 100644 --- a/crates/renamr-core/src/rename/mod.rs +++ b/crates/renamr-core/src/rename/mod.rs @@ -12,9 +12,11 @@ //! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing //! intermediate directories are created automatically. Conflict policy is fixed at //! **skip**: if the computed target already exists and isn't the same underlying file, -//! that item is left alone and reported as skipped rather than overwritten. There is no -//! undo yet, so the UI is expected to show the caller a confirmation of the full plan -//! before calling [`execute_renames`]. +//! that item is left alone and reported as skipped rather than overwritten. The UI is +//! expected to show the caller a confirmation of the full plan (and let them choose a +//! [`RenameAction`]) before calling [`execute_renames`]; every batch that action +//! actually touches a file for is also recorded to the rename-history log one layer up +//! (`renamr_core::history`), so it can be undone from there. use serde::{Deserialize, Serialize}; use std::fs; @@ -32,6 +34,33 @@ pub struct RenamePlanItem { pub new_name: String, } +/// What to actually do at the target path once it's been resolved and cleared of +/// conflicts - a port of the handful of `--action` modes `net.filebot.cli +/// .CmdlineOperations` supports (`move`, the default, plus `copy`/`symlink`; `keeplink` +/// and `test` are not ported). Applies to a whole batch at once - the UI picks one +/// action per "Rename" confirmation, not per file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RenameAction { + /// Renames/moves the file in place - the only action Renamr had before this. The + /// source file no longer exists afterward; the item now lives at the target path. + Move, + /// Copies the file's contents to the target path, leaving the source untouched. + Copy, + /// Creates a symlink at the target path pointing back at the (untouched) source + /// file. Unix: `std::os::unix::fs::symlink`. Windows: `symlink_file` (creating a + /// file symlink there normally requires Developer Mode or an elevated process - + /// surfaced to the caller as an ordinary `Failed` outcome if it's rejected, not a + /// special case). + Symlink, +} + +impl Default for RenameAction { + fn default() -> Self { + RenameAction::Move + } +} + /// What happened to one planned rename. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "status", rename_all = "camelCase")] @@ -90,7 +119,25 @@ fn same_file(a: &Path, b: &Path) -> bool { } } -fn rename_one(source: &Path, new_name: &str) -> RenameOutcome { +#[cfg(unix)] +fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(source, target) +} + +#[cfg(windows)] +fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(source, target) +} + +#[cfg(not(any(unix, windows)))] +fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "symlinks aren't supported on this platform", + )) +} + +fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutcome { if let Err(reason) = validate_relative_target(new_name) { return RenameOutcome::Failed { error: format!("\"{new_name}\" is not a valid target: {reason}"), @@ -108,6 +155,9 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome { let target = parent.join(new_name); if target == source { + // Nothing meaningful to do for any action - Move has nothing to move, and + // Copy/Symlink onto the source's own path would just be a no-op self-reference + // at best (or an outright error at worst, e.g. `fs::copy` onto the same path). return RenameOutcome::Unchanged; } @@ -119,7 +169,7 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome { // `new_name` may include subdirectory components (organizing files into folders) - // make sure the destination directory tree exists before attempting the actual - // rename/move. `create_dir_all` is a no-op (Ok) when the directory already exists. + // operation. `create_dir_all` is a no-op (Ok) when the directory already exists. if let Some(target_parent) = target.parent() { if let Err(e) = fs::create_dir_all(target_parent) { return RenameOutcome::Failed { @@ -128,7 +178,13 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome { } } - match fs::rename(source, &target) { + let result = match action { + RenameAction::Move => fs::rename(source, &target), + RenameAction::Copy => fs::copy(source, &target).map(|_bytes| ()), + RenameAction::Symlink => create_symlink(source, &target), + }; + + match result { Ok(()) => RenameOutcome::Renamed { new_path: target.to_string_lossy().to_string(), }, @@ -138,16 +194,17 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome { } } -/// Executes a batch of planned renames in order. Items are processed sequentially (not -/// in parallel) so that two items whose computed names collide with each other resolve -/// the same way an on-disk collision would: the first one through wins, the second is -/// reported as a skipped conflict rather than silently overwriting it. -pub fn execute_renames(items: &[RenamePlanItem]) -> Vec { +/// Executes a batch of planned renames in order, all under the same [`RenameAction`]. +/// Items are processed sequentially (not in parallel) so that two items whose computed +/// names collide with each other resolve the same way an on-disk collision would: the +/// first one through wins, the second is reported as a skipped conflict rather than +/// silently overwriting it. +pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec { items .iter() .map(|item| RenameResult { source: item.source.clone(), - outcome: rename_one(Path::new(&item.source), &item.new_name), + outcome: rename_one(Path::new(&item.source), &item.new_name, action), }) .collect() } @@ -178,7 +235,7 @@ mod tests { new_name: "new.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert_eq!(results.len(), 1); match &results[0].outcome { RenameOutcome::Renamed { new_path } => { @@ -200,7 +257,7 @@ mod tests { new_name: "same.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert_eq!(results[0].outcome, RenameOutcome::Unchanged); assert!(file.exists()); @@ -217,7 +274,7 @@ mod tests { new_name: "b.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); match &results[0].outcome { RenameOutcome::SkippedConflict { existing_path } => { assert!(existing_path.ends_with("b.txt")); @@ -248,7 +305,7 @@ mod tests { }, ]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. })); assert!(matches!( results[1].outcome, @@ -268,7 +325,7 @@ mod tests { new_name: "../escaped.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. })); assert!(file.exists()); @@ -283,7 +340,7 @@ mod tests { new_name: "Breaking Bad/Ozymandias.mkv".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); match &results[0].outcome { RenameOutcome::Renamed { new_path } => { let expected = dir.join("Breaking Bad").join("Ozymandias.mkv"); @@ -305,7 +362,7 @@ mod tests { new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv"); match &results[0].outcome { RenameOutcome::Renamed { new_path } => { @@ -329,7 +386,7 @@ mod tests { new_name: "Sub/b.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert!(matches!( results[0].outcome, RenameOutcome::SkippedConflict { .. } @@ -348,7 +405,7 @@ mod tests { new_name: "Sub\\b.txt".to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. })); assert!(file.exists()); @@ -364,7 +421,7 @@ mod tests { source: file.to_string_lossy().to_string(), new_name: bad_name.to_string(), }]; - let results = execute_renames(&items); + let results = execute_renames(&items, RenameAction::Move); assert!( matches!(results[0].outcome, RenameOutcome::Failed { .. }), "expected {bad_name:?} to be rejected" @@ -374,4 +431,123 @@ mod tests { fs::remove_dir_all(&dir).ok(); } + + #[test] + fn copy_action_leaves_the_source_file_in_place() { + let (dir, file) = temp_file_setup("original.txt", "hello"); + let items = vec![RenamePlanItem { + source: file.to_string_lossy().to_string(), + new_name: "copied.txt".to_string(), + }]; + + let results = execute_renames(&items, RenameAction::Copy); + match &results[0].outcome { + RenameOutcome::Renamed { new_path } => { + assert!(new_path.ends_with("copied.txt")); + assert!(Path::new(new_path).exists()); + assert_eq!(fs::read_to_string(new_path).unwrap(), "hello"); + // The whole point of Copy: the source is untouched. + assert!(file.exists()); + assert_eq!(fs::read_to_string(&file).unwrap(), "hello"); + } + other => panic!("expected Renamed, got {other:?}"), + } + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn copy_action_can_also_organize_into_a_new_subfolder() { + let (dir, file) = temp_file_setup("original.txt", "hello"); + let items = vec![RenamePlanItem { + source: file.to_string_lossy().to_string(), + new_name: "Sub/copied.txt".to_string(), + }]; + + let results = execute_renames(&items, RenameAction::Copy); + let expected = dir.join("Sub").join("copied.txt"); + match &results[0].outcome { + RenameOutcome::Renamed { new_path } => { + assert_eq!(Path::new(new_path), expected.as_path()); + assert!(expected.exists()); + assert!(file.exists()); + } + other => panic!("expected Renamed, got {other:?}"), + } + + fs::remove_dir_all(&dir).ok(); + } + + #[cfg(unix)] + #[test] + fn symlink_action_leaves_the_source_file_in_place_and_points_back_at_it() { + let (dir, file) = temp_file_setup("original.txt", "hello"); + let items = vec![RenamePlanItem { + source: file.to_string_lossy().to_string(), + new_name: "linked.txt".to_string(), + }]; + + let results = execute_renames(&items, RenameAction::Symlink); + match &results[0].outcome { + RenameOutcome::Renamed { new_path } => { + let link = Path::new(new_path); + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(link).unwrap(), file); + // Reading through the link gets the source's real content... + assert_eq!(fs::read_to_string(link).unwrap(), "hello"); + // ...and the source itself is completely untouched. + assert!(file.exists()); + assert!(!fs::symlink_metadata(&file).unwrap().file_type().is_symlink()); + } + other => panic!("expected Renamed, got {other:?}"), + } + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn conflict_policy_applies_the_same_way_to_copy_and_symlink() { + let (dir, file) = temp_file_setup("a.txt", "aaa"); + fs::write(dir.join("b.txt"), "bbb").unwrap(); + + for action in [RenameAction::Copy, RenameAction::Symlink] { + let items = vec![RenamePlanItem { + source: file.to_string_lossy().to_string(), + new_name: "b.txt".to_string(), + }]; + let results = execute_renames(&items, action); + match &results[0].outcome { + RenameOutcome::SkippedConflict { existing_path } => { + assert!(existing_path.ends_with("b.txt")); + } + other => panic!("expected SkippedConflict for {action:?}, got {other:?}"), + } + } + // Neither original file nor the pre-existing conflicting file should be touched. + assert!(file.exists()); + assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb"); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn copy_and_symlink_report_unchanged_when_the_computed_name_is_already_the_source() { + let (dir, file) = temp_file_setup("same.txt", "hello"); + + for action in [RenameAction::Copy, RenameAction::Symlink] { + let items = vec![RenamePlanItem { + source: file.to_string_lossy().to_string(), + new_name: "same.txt".to_string(), + }]; + let results = execute_renames(&items, action); + assert_eq!( + results[0].outcome, + RenameOutcome::Unchanged, + "expected Unchanged for {action:?}" + ); + } + assert!(file.exists()); + + fs::remove_dir_all(&dir).ok(); + } } diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 647ae53..c541b34 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -4,7 +4,7 @@ //! list the log, and undo one batch from it. use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult}; -use renamr_core::rename::{RenameOutcome, RenameResult}; +use renamr_core::rename::{RenameAction, RenameOutcome, RenameResult}; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -43,12 +43,14 @@ fn now_millis() -> u64 { } /// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) - -/// records only the entries that were actually renamed; a file that was unchanged, -/// skipped, or failed to rename has nothing to undo. Best-effort: if persisting the -/// history fails for some reason, the rename itself already succeeded and shouldn't be -/// reported as failed just because its undo record couldn't be saved, so errors here +/// records only the entries that were actually renamed/copied/linked, and the action +/// that was used to do it (so `undo_rename_batch` later knows whether to move the +/// result back or just delete it - see `renamr_core::history::revert_one`); a file that +/// was unchanged, skipped, or failed has nothing to undo. Best-effort: if persisting the +/// history fails for some reason, the operation itself already succeeded and shouldn't +/// be reported as failed just because its undo record couldn't be saved, so errors here /// are swallowed rather than surfaced to the caller. -pub fn record_batch(app: &AppHandle, results: &[RenameResult]) { +pub fn record_batch(app: &AppHandle, results: &[RenameResult], action: RenameAction) { let entries: Vec = results .iter() .filter_map(|r| match &r.outcome { @@ -67,6 +69,7 @@ pub fn record_batch(app: &AppHandle, results: &[RenameResult]) { let batch = HistoryBatch { id: format!("batch-{}", now_millis()), timestamp: now_millis(), + action, entries, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c4ef1a..77286f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ mod settings; use renamr_core::format::ExpressionFormat; use renamr_core::matching::{self, EpisodeGuess, MovieGuess}; -use renamr_core::rename::{self, RenamePlanItem, RenameResult}; +use renamr_core::rename::{self, RenameAction, RenamePlanItem, RenameResult}; use renamr_core::string_bindings; use std::collections::HashMap; use tauri::AppHandle; @@ -39,20 +39,23 @@ fn guess_movie(filename: String) -> Option { matching::guess_movie(&filename) } -/// Executes a batch of planned on-disk renames (optionally moving files into -/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped, -/// never overwritten). The UI is expected to have already shown the user the full -/// old-path/new-path plan and gotten explicit confirmation before calling this. -/// Runs off the main thread since real filesystem I/O shouldn't block the UI. +/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally +/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting +/// targets are always skipped, never overwritten). `action` picks which of the three +/// happens - the UI is expected to have already shown the user the full old-path/ +/// new-path plan, the chosen action, and gotten explicit confirmation before calling +/// this. Runs off the main thread since real filesystem I/O shouldn't block the UI. /// -/// Every successfully-renamed item is recorded to the rename history log afterward -/// (see `history::record_batch`) so it can be undone later from the History panel. +/// Every successfully-produced item is recorded to the rename history log afterward +/// (see `history::record_batch`), tagged with `action`, so it can be undone later from +/// the History panel - a Move undo moves the file back, a Copy/Symlink undo just +/// removes what was created. #[tauri::command] -async fn execute_rename(app: AppHandle, items: Vec) -> Vec { - let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items)) +async fn execute_rename(app: AppHandle, items: Vec, action: RenameAction) -> Vec { + let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action)) .await .unwrap_or_default(); - history::record_batch(&app, &results); + history::record_batch(&app, &results, action); results } diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index fcaabd2..a22bffc 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -459,6 +459,44 @@ color: var(--aurora-cyan); } +.action-segmented { + display: flex; + gap: 4px; + margin: 2px 0 12px; + padding: 3px; + background: var(--surface); + border: 1px solid var(--surface-deep); + border-radius: 10px; +} + +.action-segmented-option { + all: unset; + box-sizing: border-box; + flex: 1 1 0; + text-align: center; + padding: 7px 0; + border-radius: 7px; + font-size: 12.5px; + font-weight: 600; + color: var(--muted-text); + cursor: pointer; +} + +.action-segmented-option:hover { + color: var(--text); +} + +.action-segmented-option.selected { + background: var(--aurora-green); + color: var(--dock-deep); +} + +.action-segmented-option:disabled { + opacity: 0.6; + cursor: default; + pointer-events: none; +} + .modal-field-label { display: block; font-size: 11px; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 435dcc2..c7c0b38 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -78,6 +78,13 @@ interface RenameResult { outcome: RenameOutcome; } +// Mirrors renamr-core's `RenameAction` - what actually happens at each computed target. +// "move" relocates the file in place (the only action Renamr had before); "copy" and +// "symlink" both leave the source file exactly where it is and produce a new file +// elsewhere, which is why only "move" updates the loaded item's own path/name (see +// confirmRename below). +type RenameAction = "move" | "copy" | "symlink"; + // Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against // real TMDB data. Needs a series name + season/episode to search with - exactly what // Match already produces offline, which is why Fetch Data requires Match to have run @@ -131,6 +138,10 @@ interface HistoryBatch { id: string; /// Unix epoch milliseconds - formatted client-side with `Date`. timestamp: number; + // What action produced every entry in this batch. Older history entries recorded + // before this field existed come back as "move" (the Rust side's serde default), + // which is also correct - that was the only action that ever existed back then. + action: RenameAction; entries: HistoryEntry[]; } @@ -228,6 +239,22 @@ const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}"; // What Match + Fetch Data together can fill in. const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; +// Copy for each RenameAction, used in the confirmation modal's segmented selector, +// subtitle, confirm-button label, and the post-confirm status message. +const RENAME_ACTION_LABELS: Record< + RenameAction, + { button: string; verb: string; pastTense: string; progressive: string } +> = { + move: { button: "Rename", verb: "rename", pastTense: "Renamed", progressive: "Renaming…" }, + copy: { button: "Copy", verb: "copy", pastTense: "Copied", progressive: "Copying…" }, + symlink: { + button: "Symlink", + verb: "create symlinks for", + pastTense: "Symlinked", + progressive: "Creating symlinks…", + }, +}; + function realFileToItem(entry: FileEntry): Item { return { id: entry.path, @@ -309,6 +336,10 @@ export default function RenamePanel() { const [renamePlan, setRenamePlan] = useState(null); const [renamePlanSkipped, setRenamePlanSkipped] = useState(0); const [renaming, setRenaming] = useState(false); + // What to actually do at each computed target - chosen in the confirmation modal, + // remembered across modal opens within the session (defaults to "move", matching + // Renamr's original and only behavior before copy/symlink existed). + const [renameAction, setRenameAction] = useState("move"); // TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and // nothing saved, `true` = a token is on file (the value itself never comes back to // the frontend after saving - see Settings modal below). @@ -582,9 +613,12 @@ export default function RenamePanel() { setRenamePlan(plan); }; - // Step 2: the user approved the plan shown in the modal - actually rename the files - // on disk via the Rust `execute_rename` command, then reconcile local state (a - // renamed file's id, which is its path, changes) and report the outcome. + // Step 2: the user approved the plan (and chose an action) shown in the modal - + // actually run it on disk via the Rust `execute_rename` command, then reconcile + // local state and report the outcome. Only "move" relocates the source file, so only + // "move" updates a loaded item's own id/name/bindings to point at the new path - + // "copy"/"symlink" leave the source exactly where it was and produce an additional + // file elsewhere that Renamr doesn't separately track as a loaded item. const confirmRename = async () => { if (!renamePlan || renamePlan.length === 0) return; setRenaming(true); @@ -592,45 +626,48 @@ export default function RenamePanel() { try { const results = await invoke("execute_rename", { items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })), + action: renameAction, }); const bySource = new Map(results.map((r) => [r.source, r] as const)); // Compute counts from `results` directly, not inside the setItems updater below: // React doesn't run a functional updater synchronously, so counters mutated in // there aren't ready yet when building the status message right after (this bit // runMatching earlier - same fix applies here). - const renamed = results.filter((r) => r.outcome.status === "renamed").length; + const succeeded = results.filter((r) => r.outcome.status === "renamed").length; const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length; const failed = results.filter((r) => r.outcome.status === "failed").length; - setItems((prev) => - prev.map((item) => { - const result = bySource.get(item.id); - if (!result) return item; + if (renameAction === "move") { + setItems((prev) => + prev.map((item) => { + const result = bySource.get(item.id); + if (!result) return item; - if (result.outcome.status === "renamed") { - const newFileName = fileNameFromPath(result.outcome.newPath); - const [base, ext] = splitExt(newFileName); - return { - ...item, - id: result.outcome.newPath, - originalName: newFileName, - ext, - bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings, - }; - } - return item; - }) - ); + if (result.outcome.status === "renamed") { + const newFileName = fileNameFromPath(result.outcome.newPath); + const [base, ext] = splitExt(newFileName); + return { + ...item, + id: result.outcome.newPath, + originalName: newFileName, + ext, + bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings, + }; + } + return item; + }) + ); - // Keep the current selection pointing at the same logical file if its id (path) - // changed underneath it. - setSelected((prevSelected) => { - if (!prevSelected) return prevSelected; - const result = bySource.get(prevSelected); - return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected; - }); + // Keep the current selection pointing at the same logical file if its id + // (path) changed underneath it. + setSelected((prevSelected) => { + if (!prevSelected) return prevSelected; + const result = bySource.get(prevSelected); + return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected; + }); + } - const parts = [`Renamed ${renamed} file${renamed === 1 ? "" : "s"}`]; + const parts = [`${RENAME_ACTION_LABELS[renameAction].pastTense} ${succeeded} file${succeeded === 1 ? "" : "s"}`]; if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`); if (failed > 0) parts.push(`${failed} failed`); setStatus(parts.join(", ") + "."); @@ -1080,14 +1117,32 @@ export default function RenamePanel() {
e.stopPropagation()}>

Confirm Rename

+
+ {(["move", "copy", "symlink"] as const).map((a) => ( + + ))} +

- This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now. - There's no undo yet — review the list below before continuing, and use the ✕ on any row to drop just - that file from this batch. + This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file + {renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use + the ✕ on any row to drop just that file from this batch, or undo the whole batch afterward from the + History panel. + {renameAction !== "move" && + " The original file is left exactly where it is; only the new copy/symlink is created."} {renamePlanSkipped > 0 && ` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`} {renamePlan.some((p) => p.newName.includes("/")) && - " Some of these also move the file into a new subfolder — any missing folders are created automatically."} + ` Some of these also ${ + renameAction === "move" ? "move" : "place" + } the file into a new subfolder — any missing folders are created automatically.`}

{renamePlan.length === 0 ? ( @@ -1126,7 +1181,11 @@ export default function RenamePanel() { onClick={confirmRename} disabled={renaming || renamePlan.length === 0} > - {renaming ? "Renaming…" : `Rename ${renamePlan.length} File${renamePlan.length === 1 ? "" : "s"}`} + {renaming + ? RENAME_ACTION_LABELS[renameAction].progressive + : `${RENAME_ACTION_LABELS[renameAction].button} ${renamePlan.length} File${ + renamePlan.length === 1 ? "" : "s" + }`}
@@ -1185,9 +1244,11 @@ export default function RenamePanel() {
e.stopPropagation()}>

Rename History

- Every completed rename is logged here so it can be undone. Undoing moves each file back to its - original path — if something else now exists there, that one file is skipped rather than overwritten. - The log keeps the most recent 50 rename operations. + Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file + back to its original path — if something else now exists there, that one file is skipped rather than + overwritten. For a Copy or Symlink batch, undoing just removes the copy/symlink that was created; the + original file was never touched, so there's nothing to move back. The log keeps the most recent 50 + operations.

{historyBatches === null ? (

Loading…

@@ -1201,6 +1262,7 @@ export default function RenamePanel() { {new Date(batch.timestamp).toLocaleString()} {batch.entries.length} file{batch.entries.length === 1 ? "" : "s"} + {batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}