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:
co-authored by
Claude Sonnet 5
parent
aab8e28246
commit
fd4e6f66f0
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user