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 <[email protected]>
This commit is contained in:
Renamr Scaffold
2026-08-29 04:31:16 +00:00
co-authored by Claude Sonnet 5
parent aab8e28246
commit fd4e6f66f0
7 changed files with 546 additions and 127 deletions
+21 -16
View File
@@ -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
+166 -34
View File
@@ -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<HistoryEntry>,
}
@@ -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<RevertResult> {
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(&copy, "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();
}
}
+198 -22
View File
@@ -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<RenameResult> {
/// 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<RenameResult> {
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();
}
}
+9 -6
View File
@@ -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<HistoryEntry> = 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,
};
+14 -11
View File
@@ -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<MovieGuess> {
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<RenamePlanItem>) -> Vec<RenameResult> {
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>, action: RenameAction) -> Vec<RenameResult> {
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
}
+38
View File
@@ -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;
+100 -38
View File
@@ -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<RenamePlanEntry[] | null>(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<RenameAction>("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<RenameResult[]>("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() {
<div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2>
<div className="action-segmented" role="radiogroup" aria-label="What to do with these files">
{(["move", "copy", "symlink"] as const).map((a) => (
<button
key={a}
type="button"
className={`action-segmented-option${renameAction === a ? " selected" : ""}`}
onClick={() => setRenameAction(a)}
disabled={renaming}
>
{RENAME_ACTION_LABELS[a].button}
</button>
))}
</div>
<p className="modal-subtitle">
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.`}
</p>
<div className="modal-plan-list">
{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"
}`}
</button>
</div>
</div>
@@ -1185,9 +1244,11 @@ export default function RenamePanel() {
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Rename History</h2>
<p className="modal-subtitle">
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.
</p>
{historyBatches === null ? (
<p className="modal-note">Loading…</p>
@@ -1201,6 +1262,7 @@ export default function RenamePanel() {
<span className="history-batch-when">{new Date(batch.timestamp).toLocaleString()}</span>
<span className="history-batch-count">
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
{batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}
</span>
<button
className="action-button history-undo-button"