Add rename history with undo

- New crates/renamr-core/src/history module: HistoryBatch/HistoryEntry
  records, revert_batch() moves files back to their original paths
  (skip-on-conflict, same policy as rename::execute_renames; recreates a
  deleted destination folder defensively), push_batch() maintains a
  most-recent-first log capped at 50 batches. A partial port of the idea
  behind net.filebot.HistoryDialog, as a flat undo stack rather than a full
  session/element log. 6 new tests.
- New src-tauri/src/history.rs: persists the log as history.json in the OS
  app-config directory (same location as settings.json). execute_rename now
  takes an AppHandle and calls history::record_batch after every rename,
  recording only the items that actually got renamed. Two new commands:
  list_rename_history and undo_rename_batch.
- Frontend: the History button (previously a no-op) now opens a panel
  listing past rename batches with a timestamp, file count, and the
  old-name/new-name pairs, each with an Undo button. Undoing reconciles any
  currently-loaded item back to its pre-rename path/name, same pattern as
  post-rename reconciliation.
- Verified via cargo test (45 total)/check + npm build + a full Playwright
  pass: loaded mocked files, renamed them, opened History, undid the batch,
  and confirmed both the UI and the (mocked) backend state matched at every
  step.
This commit is contained in:
Renamr Scaffold
2026-08-29 03:44:38 +00:00
parent 658a235e05
commit 93b5ac5e56
7 changed files with 643 additions and 28 deletions
+156 -1
View File
@@ -84,6 +84,30 @@ interface FetchResult {
outcome: FetchOutcome;
}
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
interface HistoryEntry {
source: string;
newPath: string;
}
interface HistoryBatch {
id: string;
/// Unix epoch milliseconds - formatted client-side with `Date`.
timestamp: number;
entries: HistoryEntry[];
}
type RevertOutcome =
| { status: "reverted"; restoredPath: string }
| { status: "skippedConflict"; existingPath: string }
| { status: "failed"; error: string };
interface RevertResult {
source: string;
outcome: RevertOutcome;
}
function fileNameFromPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? path;
@@ -252,6 +276,11 @@ export default function RenamePanel() {
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [fetching, setFetching] = useState(false);
// Rename history / undo. `null` batches = not loaded yet (also used to show a
// loading state the first time the History panel is opened).
const [historyOpen, setHistoryOpen] = useState(false);
const [historyBatches, setHistoryBatches] = useState<HistoryBatch[] | null>(null);
const [undoingBatchId, setUndoingBatchId] = useState<string | null>(null);
useEffect(() => {
invoke<string | null>("get_tmdb_token")
@@ -544,6 +573,76 @@ export default function RenamePanel() {
setRenamePlan(null);
};
// Rename history / undo - Renamr's only safety net beyond the confirm-before-execute
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
// batch of every successfully-renamed file after each Rename; this panel lists those
// batches and can revert one back to its original paths.
const openHistory = () => {
setHistoryOpen(true);
invoke<HistoryBatch[]>("list_rename_history")
.then(setHistoryBatches)
.catch((err) => {
setHistoryBatches([]);
setStatus(`Couldn't load rename history: ${err}`);
});
};
const closeHistory = () => {
if (undoingBatchId) return;
setHistoryOpen(false);
};
const undoBatch = async (batch: HistoryBatch) => {
setUndoingBatchId(batch.id);
try {
const results = await invoke<RevertResult[]>("undo_rename_batch", { batchId: batch.id });
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename/runFetch above (React doesn't run a
// functional updater synchronously, so a counter mutated in there wouldn't be
// ready yet when building the status message right after).
const bySource = new Map(results.map((r) => [r.source, r] as const));
const reverted = results.filter((r) => r.outcome.status === "reverted").length;
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
// Reconcile any currently-loaded item that was part of this batch back to its
// pre-rename path/filename - mirrors confirmRename's post-rename reconciliation.
setItems((prev) =>
prev.map((item) => {
const result = bySource.get(item.id);
if (!result || result.outcome.status !== "reverted") return item;
const restoredFileName = fileNameFromPath(result.outcome.restoredPath);
const [base, ext] = splitExt(restoredFileName);
return {
...item,
id: result.outcome.restoredPath,
originalName: restoredFileName,
ext,
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
};
})
);
setSelected((prevSelected) => {
if (!prevSelected) return prevSelected;
const result = bySource.get(prevSelected);
return result?.outcome.status === "reverted" ? result.outcome.restoredPath : prevSelected;
});
setHistoryBatches((prev) => (prev ? prev.filter((b) => b.id !== batch.id) : prev));
const parts = [`Reverted ${reverted} file${reverted === 1 ? "" : "s"}`];
if (skipped > 0) parts.push(`${skipped} skipped (something else is there now)`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Undo failed: ${err}`);
} finally {
setUndoingBatchId(null);
window.clearTimeout((undoBatch as any)._t);
(undoBatch as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const openSettings = () => {
setTmdbTokenInput("");
setSettingsOpen(true);
@@ -727,7 +826,7 @@ export default function RenamePanel() {
<span className="list-button-gap" />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
<ListButton icon={<HistoryIcon />} label="History" onClick={openHistory} />
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
</>
}
@@ -906,6 +1005,62 @@ export default function RenamePanel() {
</div>
</div>
)}
{historyOpen && (
<div className="modal-overlay" onClick={closeHistory}>
<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.
</p>
{historyBatches === null ? (
<p className="modal-note">Loading…</p>
) : historyBatches.length === 0 ? (
<p className="modal-note">No renames yet — this fills in after you use Rename.</p>
) : (
<div className="modal-plan-list history-batch-list">
{historyBatches.map((batch) => (
<div className="history-batch" key={batch.id}>
<div className="history-batch-header">
<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"}
</span>
<button
className="action-button history-undo-button"
onClick={() => undoBatch(batch)}
disabled={undoingBatchId !== null}
>
{undoingBatchId === batch.id ? "Undoing…" : "Undo"}
</button>
</div>
<div className="history-batch-entries">
{batch.entries.map((entry, i) => (
<div className="modal-plan-row" key={i}>
<span className="modal-plan-old" title={entry.newPath}>
{fileNameFromPath(entry.newPath)}
</span>
<span className="modal-plan-arrow">→</span>
<span className="modal-plan-new" title={entry.source}>
{fileNameFromPath(entry.source)}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="modal-actions">
<button className="action-button" onClick={closeHistory} disabled={undoingBatchId !== null}>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}