Add conflict policy options: Skip / Auto-number / Override

New ConflictPolicy enum in rename/ (Skip = original/default behavior,
AutoNumber = save as "Name (1).ext" counting up to the first free slot,
Override = replace the conflicting file outright), threaded through
execute_renames/rename_one alongside RenameAction. Override never deletes
a conflicting directory (falls back to Skip for that case); AutoNumber
sidesteps directories the same way by just trying the next number.
execute_rename now takes a conflict argument; undo/history are
deliberately NOT conflict-aware and always use their own fixed
skip-on-conflict policy regardless of what the original rename used.

Frontend: a second segmented selector in the rename confirmation modal
("On conflict: Skip / Auto-number / Override"), with Override styled in
red and its warning copy calling out that it can't be undone.
This commit is contained in:
Renamr Scaffold
2026-08-29 09:50:36 +00:00
parent 21d78b8f3a
commit 7dd9ad2078
5 changed files with 449 additions and 71 deletions
+52 -2
View File
@@ -98,6 +98,12 @@ interface RenameResult {
// confirmRename below).
type RenameAction = "move" | "copy" | "symlink";
// Mirrors renamr-core's `ConflictPolicy` - what happens when a computed target already
// exists. "skip" (the default, and the only behavior Renamr had before) leaves the
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
// "override" replaces it outright (irreversible for whatever gets overwritten).
type ConflictPolicy = "skip" | "autoNumber" | "override";
// 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
@@ -287,6 +293,25 @@ const RENAME_ACTION_LABELS: Record<
},
};
// Copy for each ConflictPolicy, used in the confirmation modal's second segmented
// selector and the modal's conflict-behavior note.
const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; description: string }> = {
skip: {
button: "Skip",
description: "If a computed name already exists on disk, that file is left alone and skipped.",
},
autoNumber: {
button: "Auto-number",
description:
'If a computed name already exists on disk, the new one is saved alongside it as "Name (1).ext" instead (counting up until a free name is found).',
},
override: {
button: "Override",
description:
"If a computed name already exists on disk, it's replaced outright. This can't be undone — undoing this batch later can restore the source file, but not whatever got overwritten.",
},
};
function realFileToItem(entry: FileEntry): Item {
return {
id: entry.path,
@@ -372,6 +397,10 @@ export default function RenamePanel() {
// 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");
// What to do when a computed target already exists - same remembered-across-opens
// pattern as renameAction, defaults to "skip" (Renamr's original and only behavior
// before auto-number/override existed).
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
// 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).
@@ -707,6 +736,7 @@ export default function RenamePanel() {
const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
action: renameAction,
conflict: conflictPolicy,
});
const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below:
@@ -1316,6 +1346,26 @@ export default function RenamePanel() {
</button>
))}
</div>
<span className="modal-field-label">On conflict</span>
<div
className="action-segmented"
role="radiogroup"
aria-label="What to do when the computed name already exists"
>
{(["skip", "autoNumber", "override"] as const).map((c) => (
<button
key={c}
type="button"
className={`action-segmented-option${conflictPolicy === c ? " selected" : ""}${
c === "override" ? " action-segmented-option-danger" : ""
}`}
onClick={() => setConflictPolicy(c)}
disabled={renaming}
>
{CONFLICT_POLICY_LABELS[c].button}
</button>
))}
</div>
<p className="modal-subtitle">
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
@@ -1355,8 +1405,8 @@ export default function RenamePanel() {
))
)}
</div>
<p className="modal-note">
If a computed name already exists on disk, that file is skipped rather than overwritten.
<p className={`modal-note${conflictPolicy === "override" ? " modal-note-danger" : ""}`}>
{CONFLICT_POLICY_LABELS[conflictPolicy].description}
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>