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:
@@ -454,6 +454,57 @@
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.history-batch-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-batch {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.history-batch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 4px 6px;
|
||||
}
|
||||
|
||||
.history-batch-when {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-batch-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.history-undo-button {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.history-batch-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user