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 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 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 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 (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 before anything touches disk, a per-row ✕ to drop an individual file from that plan at
at the last step). Rename can also organize files into subfolders — the last step, and a Move/Copy/Symlink selector for what to actually do at each
including ones that don't exist yet — when the naming scheme includes a `/`. Every target). It can also organize files into subfolders — including ones that don't exist
completed rename is logged to a history panel that can undo it, moving files back to yet — when the naming scheme includes a `/`. Every completed batch is logged to a
their original paths. A single loaded batch can freely mix TV episodes and movies — history panel that can undo it (moving files back for Move, or just removing what was
each file only fills in the fields that apply to it. Full media-metadata bindings are 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. not yet ported. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next 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 TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
and movie search + lookup, both disambiguated via `similarity/`, and movie search + lookup, both disambiguated via `similarity/`,
tested against a mocked HTTP server via `mockito`), the on-disk tested against a mocked HTTP server via `mockito`), the on-disk
rename engine (`rename/`) — rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink
skip-on-conflict, can also organize into (auto-created) actions, can also organize into (auto-created) subfolders when
subfolders when the naming scheme includes `/` — and a rename the naming scheme includes `/`) — and a rename history/undo log
history/undo log (`history/`, a partial `HistoryDialog` port). (`history/`, a partial `HistoryDialog` port, undo-aware of which
Not yet ported: full media metadata bindings, other web action produced each batch). Not yet ported: full media metadata
datasource clients (TVDB/AniDB/etc). bindings, other web datasource clients (TVDB/AniDB/etc).
No Tauri dependency, so it builds and unit-tests standalone. No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
(naming-scheme rendering, filename matching for both TV and (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 UI's Rename button behind a confirmation step) including the `move`-into-subfolder
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into `--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into
subfolders, creating any missing folders automatically, always scoped to the file's subfolders, creating any missing folders automatically, always scoped to the file's
own directory tree. Not yet done: the `copy`/`symlink` `--action` variants, the own directory tree. The `copy`/`symlink` `--action` variants are done too — an action
`--conflict` variants beyond skip (auto-number, override), and the CLI flag surface selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy
itself. 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, 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) 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 is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are
+145 -13
View File
@@ -9,6 +9,7 @@
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same //! 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. //! split as `rename` (pure logic here) vs. the Tauri command that calls it.
use crate::rename::RenameAction;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -22,7 +23,8 @@ pub struct HistoryEntry {
} }
/// One executed rename operation - everything renamed together in a single "Rename N /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HistoryBatch { pub struct HistoryBatch {
@@ -31,6 +33,11 @@ pub struct HistoryBatch {
/// (no date/time dependency needed in this crate) so the frontend can format it /// (no date/time dependency needed in this crate) so the frontend can format it
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`. /// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
pub timestamp: u64, 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>, pub entries: Vec<HistoryEntry>,
} }
@@ -38,13 +45,18 @@ pub struct HistoryBatch {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum RevertOutcome { 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 }, Reverted { restored_path: String },
/// A different file already exists at the original path; left untouched - the same /// 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 }, SkippedConflict { existing_path: String },
/// The renamed file isn't where the history says it should be anymore (moved, /// The renamed/copied/linked file isn't where the history says it should be
/// deleted, or already reverted some other way), or the OS reported an error. /// anymore (moved, deleted, or already reverted some other way), or the OS reported
/// an error.
Failed { error: String }, Failed { error: String },
} }
@@ -63,10 +75,12 @@ 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 current = Path::new(&entry.new_path);
let original = Path::new(&entry.source); let original = Path::new(&entry.source);
match action {
RenameAction::Move => {
if !current.exists() { if !current.exists() {
return RevertOutcome::Failed { return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(), error: "the renamed file is no longer at its recorded location".to_string(),
@@ -79,9 +93,10 @@ fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
}; };
} }
// The original's parent directory should still exist (nothing in Renamr deletes // The original's parent directory should still exist (nothing in Renamr
// directories), but recreate it defensively in case it was removed by hand since - // deletes directories), but recreate it defensively in case it was removed
// cheap, and `create_dir_all` is a no-op when it's already there. // 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 Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) { if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed { return RevertOutcome::Failed {
@@ -96,18 +111,40 @@ fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
}, },
Err(e) => RevertOutcome::Failed { error: e.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::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`], /// Reverts every entry in a batch, in order, all under the batch's recorded
/// entries are processed sequentially (not in parallel) so a collision between two /// [`RenameAction`]. Like [`crate::rename::execute_renames`], entries are processed
/// entries in the same batch resolves deterministically rather than racing. /// 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> { pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
batch batch
.entries .entries
.iter() .iter()
.map(|entry| RevertResult { .map(|entry| RevertResult {
source: entry.new_path.clone(), source: entry.new_path.clone(),
outcome: revert_one(entry), outcome: revert_one(entry, batch.action),
}) })
.collect() .collect()
} }
@@ -152,6 +189,7 @@ mod tests {
let batch = HistoryBatch { let batch = HistoryBatch {
id: "b1".to_string(), id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0, timestamp: 0,
entries: vec![HistoryEntry { entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(), source: original.to_string_lossy().to_string(),
@@ -185,6 +223,7 @@ mod tests {
let batch = HistoryBatch { let batch = HistoryBatch {
id: "b1".to_string(), id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0, timestamp: 0,
entries: vec![HistoryEntry { entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(), source: original.to_string_lossy().to_string(),
@@ -209,6 +248,7 @@ mod tests {
let batch = HistoryBatch { let batch = HistoryBatch {
id: "b1".to_string(), id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0, timestamp: 0,
entries: vec![HistoryEntry { entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(), source: original.to_string_lossy().to_string(),
@@ -236,6 +276,7 @@ mod tests {
let batch = HistoryBatch { let batch = HistoryBatch {
id: "b1".to_string(), id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0, timestamp: 0,
entries: vec![HistoryEntry { entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(), source: original.to_string_lossy().to_string(),
@@ -257,6 +298,7 @@ mod tests {
HistoryBatch { HistoryBatch {
id: "empty".to_string(), id: "empty".to_string(),
timestamp: 0, timestamp: 0,
action: RenameAction::Move,
entries: vec![], entries: vec![],
}, },
); );
@@ -272,6 +314,7 @@ mod tests {
HistoryBatch { HistoryBatch {
id: format!("batch-{i}"), id: format!("batch-{i}"),
timestamp: i as u64, timestamp: i as u64,
action: RenameAction::Move,
entries: vec![HistoryEntry { entries: vec![HistoryEntry {
source: "a".to_string(), source: "a".to_string(),
new_path: "b".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. // The oldest batches (0..5) were dropped to stay under the cap.
assert!(!history.iter().any(|b| b.id == "batch-0")); 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 //! 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 //! intermediate directories are created automatically. Conflict policy is fixed at
//! **skip**: if the computed target already exists and isn't the same underlying file, //! **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 //! that item is left alone and reported as skipped rather than overwritten. The UI is
//! undo yet, so the UI is expected to show the caller a confirmation of the full plan //! expected to show the caller a confirmation of the full plan (and let them choose a
//! before calling [`execute_renames`]. //! [`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 serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@@ -32,6 +34,33 @@ pub struct RenamePlanItem {
pub new_name: String, 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. /// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")] #[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) { if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed { return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid target: {reason}"), 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); let target = parent.join(new_name);
if target == source { 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; 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) - // `new_name` may include subdirectory components (organizing files into folders) -
// make sure the destination directory tree exists before attempting the actual // 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 Some(target_parent) = target.parent() {
if let Err(e) = fs::create_dir_all(target_parent) { if let Err(e) = fs::create_dir_all(target_parent) {
return RenameOutcome::Failed { 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 { Ok(()) => RenameOutcome::Renamed {
new_path: target.to_string_lossy().to_string(), 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 /// Executes a batch of planned renames in order, all under the same [`RenameAction`].
/// in parallel) so that two items whose computed names collide with each other resolve /// Items are processed sequentially (not in parallel) so that two items whose computed
/// the same way an on-disk collision would: the first one through wins, the second is /// names collide with each other resolve the same way an on-disk collision would: the
/// reported as a skipped conflict rather than silently overwriting it. /// first one through wins, the second is reported as a skipped conflict rather than
pub fn execute_renames(items: &[RenamePlanItem]) -> Vec<RenameResult> { /// silently overwriting it.
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec<RenameResult> {
items items
.iter() .iter()
.map(|item| RenameResult { .map(|item| RenameResult {
source: item.source.clone(), 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() .collect()
} }
@@ -178,7 +235,7 @@ mod tests {
new_name: "new.txt".to_string(), new_name: "new.txt".to_string(),
}]; }];
let results = execute_renames(&items); let results = execute_renames(&items, RenameAction::Move);
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
@@ -200,7 +257,7 @@ mod tests {
new_name: "same.txt".to_string(), 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_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists()); assert!(file.exists());
@@ -217,7 +274,7 @@ mod tests {
new_name: "b.txt".to_string(), new_name: "b.txt".to_string(),
}]; }];
let results = execute_renames(&items); let results = execute_renames(&items, RenameAction::Move);
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => { RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt")); 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[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!( assert!(matches!(
results[1].outcome, results[1].outcome,
@@ -268,7 +325,7 @@ mod tests {
new_name: "../escaped.txt".to_string(), 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!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists()); assert!(file.exists());
@@ -283,7 +340,7 @@ mod tests {
new_name: "Breaking Bad/Ozymandias.mkv".to_string(), new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
}]; }];
let results = execute_renames(&items); let results = execute_renames(&items, RenameAction::Move);
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv"); 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(), 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"); let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
@@ -329,7 +386,7 @@ mod tests {
new_name: "Sub/b.txt".to_string(), new_name: "Sub/b.txt".to_string(),
}]; }];
let results = execute_renames(&items); let results = execute_renames(&items, RenameAction::Move);
assert!(matches!( assert!(matches!(
results[0].outcome, results[0].outcome,
RenameOutcome::SkippedConflict { .. } RenameOutcome::SkippedConflict { .. }
@@ -348,7 +405,7 @@ mod tests {
new_name: "Sub\\b.txt".to_string(), 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!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists()); assert!(file.exists());
@@ -364,7 +421,7 @@ mod tests {
source: file.to_string_lossy().to_string(), source: file.to_string_lossy().to_string(),
new_name: bad_name.to_string(), new_name: bad_name.to_string(),
}]; }];
let results = execute_renames(&items); let results = execute_renames(&items, RenameAction::Move);
assert!( assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }), matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected" "expected {bad_name:?} to be rejected"
@@ -374,4 +431,123 @@ mod tests {
fs::remove_dir_all(&dir).ok(); 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. //! list the log, and undo one batch from it.
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult}; 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::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; 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`) - /// 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, /// records only the entries that were actually renamed/copied/linked, and the action
/// skipped, or failed to rename has nothing to undo. Best-effort: if persisting the /// that was used to do it (so `undo_rename_batch` later knows whether to move the
/// history fails for some reason, the rename itself already succeeded and shouldn't be /// result back or just delete it - see `renamr_core::history::revert_one`); a file that
/// reported as failed just because its undo record couldn't be saved, so errors here /// 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. /// 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 let entries: Vec<HistoryEntry> = results
.iter() .iter()
.filter_map(|r| match &r.outcome { .filter_map(|r| match &r.outcome {
@@ -67,6 +69,7 @@ pub fn record_batch(app: &AppHandle, results: &[RenameResult]) {
let batch = HistoryBatch { let batch = HistoryBatch {
id: format!("batch-{}", now_millis()), id: format!("batch-{}", now_millis()),
timestamp: now_millis(), timestamp: now_millis(),
action,
entries, entries,
}; };
+14 -11
View File
@@ -5,7 +5,7 @@ mod settings;
use renamr_core::format::ExpressionFormat; use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess, MovieGuess}; 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 renamr_core::string_bindings;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle; use tauri::AppHandle;
@@ -39,20 +39,23 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
matching::guess_movie(&filename) matching::guess_movie(&filename)
} }
/// Executes a batch of planned on-disk renames (optionally moving files into /// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped, /// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
/// never overwritten). The UI is expected to have already shown the user the full /// targets are always skipped, never overwritten). `action` picks which of the three
/// old-path/new-path plan and gotten explicit confirmation before calling this. /// happens - the UI is expected to have already shown the user the full old-path/
/// Runs off the main thread since real filesystem I/O shouldn't block the UI. /// 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 /// Every successfully-produced item is recorded to the rename history log afterward
/// (see `history::record_batch`) so it can be undone later from the History panel. /// (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] #[tauri::command]
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>) -> Vec<RenameResult> { 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)) let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action))
.await .await
.unwrap_or_default(); .unwrap_or_default();
history::record_batch(&app, &results); history::record_batch(&app, &results, action);
results results
} }
+38
View File
@@ -459,6 +459,44 @@
color: var(--aurora-cyan); 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 { .modal-field-label {
display: block; display: block;
font-size: 11px; font-size: 11px;
+77 -15
View File
@@ -78,6 +78,13 @@ interface RenameResult {
outcome: RenameOutcome; 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 // 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 // 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 // Match already produces offline, which is why Fetch Data requires Match to have run
@@ -131,6 +138,10 @@ interface HistoryBatch {
id: string; id: string;
/// Unix epoch milliseconds - formatted client-side with `Date`. /// Unix epoch milliseconds - formatted client-side with `Date`.
timestamp: number; 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[]; entries: HistoryEntry[];
} }
@@ -228,6 +239,22 @@ const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}";
// What Match + Fetch Data together can fill in. // What Match + Fetch Data together can fill in.
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; 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 { function realFileToItem(entry: FileEntry): Item {
return { return {
id: entry.path, id: entry.path,
@@ -309,6 +336,10 @@ export default function RenamePanel() {
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null); const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0); const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
const [renaming, setRenaming] = useState(false); 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 // 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 // nothing saved, `true` = a token is on file (the value itself never comes back to
// the frontend after saving - see Settings modal below). // the frontend after saving - see Settings modal below).
@@ -582,9 +613,12 @@ export default function RenamePanel() {
setRenamePlan(plan); setRenamePlan(plan);
}; };
// Step 2: the user approved the plan shown in the modal - actually rename the files // Step 2: the user approved the plan (and chose an action) shown in the modal -
// on disk via the Rust `execute_rename` command, then reconcile local state (a // actually run it on disk via the Rust `execute_rename` command, then reconcile
// renamed file's id, which is its path, changes) and report the outcome. // 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 () => { const confirmRename = async () => {
if (!renamePlan || renamePlan.length === 0) return; if (!renamePlan || renamePlan.length === 0) return;
setRenaming(true); setRenaming(true);
@@ -592,16 +626,18 @@ export default function RenamePanel() {
try { try {
const results = await invoke<RenameResult[]>("execute_rename", { const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })), items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
action: renameAction,
}); });
const bySource = new Map(results.map((r) => [r.source, r] as const)); const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below: // Compute counts from `results` directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so counters mutated in // 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 // there aren't ready yet when building the status message right after (this bit
// runMatching earlier - same fix applies here). // 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 skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
const failed = results.filter((r) => r.outcome.status === "failed").length; const failed = results.filter((r) => r.outcome.status === "failed").length;
if (renameAction === "move") {
setItems((prev) => setItems((prev) =>
prev.map((item) => { prev.map((item) => {
const result = bySource.get(item.id); const result = bySource.get(item.id);
@@ -622,15 +658,16 @@ export default function RenamePanel() {
}) })
); );
// Keep the current selection pointing at the same logical file if its id (path) // Keep the current selection pointing at the same logical file if its id
// changed underneath it. // (path) changed underneath it.
setSelected((prevSelected) => { setSelected((prevSelected) => {
if (!prevSelected) return prevSelected; if (!prevSelected) return prevSelected;
const result = bySource.get(prevSelected); const result = bySource.get(prevSelected);
return result?.outcome.status === "renamed" ? result.outcome.newPath : 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 (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
if (failed > 0) parts.push(`${failed} failed`); if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + "."); setStatus(parts.join(", ") + ".");
@@ -1080,14 +1117,32 @@ export default function RenamePanel() {
<div className="modal-overlay" onClick={cancelRename}> <div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2> <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"> <p className="modal-subtitle">
This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now. This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
There's no undo yet — review the list below before continuing, and use the ✕ on any row to drop just {renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
that file from this batch. 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 > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`} ` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
{renamePlan.some((p) => p.newName.includes("/")) && {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> </p>
<div className="modal-plan-list"> <div className="modal-plan-list">
{renamePlan.length === 0 ? ( {renamePlan.length === 0 ? (
@@ -1126,7 +1181,11 @@ export default function RenamePanel() {
onClick={confirmRename} onClick={confirmRename}
disabled={renaming || renamePlan.length === 0} 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> </button>
</div> </div>
</div> </div>
@@ -1185,9 +1244,11 @@ export default function RenamePanel() {
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Rename History</h2> <h2 className="modal-title">Rename History</h2>
<p className="modal-subtitle"> <p className="modal-subtitle">
Every completed rename is logged here so it can be undone. Undoing moves each file back to its Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file
original path — if something else now exists there, that one file is skipped rather than overwritten. back to its original path — if something else now exists there, that one file is skipped rather than
The log keeps the most recent 50 rename operations. 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> </p>
{historyBatches === null ? ( {historyBatches === null ? (
<p className="modal-note">Loading…</p> <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-when">{new Date(batch.timestamp).toLocaleString()}</span>
<span className="history-batch-count"> <span className="history-batch-count">
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"} {batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
{batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}
</span> </span>
<button <button
className="action-button history-undo-button" className="action-button history-undo-button"