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
+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"