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
+29 -20
View File
@@ -10,10 +10,11 @@ detection ("Match") is working, real online metadata lookups against TMDB ("Fetc
Data") are working — including fuzzy show-name disambiguation, so a search that
returns several similarly-named shows doesn't just take TMDB's top-ranked hit — and the
actual on-disk rename operation is wired up (skip-on-conflict, with a confirmation step
showing the full old-name/new-name plan before anything touches disk — there is no undo
yet). Rename can also organize files into subfolders — including ones that don't exist
yet — when the naming scheme includes a `/`. Full media-metadata bindings are not yet
ported. See "Porting roadmap" below.
showing the full old-path/new-path plan before anything touches disk). Rename can also
organize files into subfolders — including ones that don't exist yet — when the naming
scheme includes a `/`. Every completed rename is logged to a history panel that can
undo it, moving files back to their original paths. Full media-metadata bindings are
not yet ported. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -110,23 +111,26 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
via `similarity/`, tested against a mocked HTTP server via
`mockito`), and the on-disk rename engine (`rename/`) —
`mockito`), the on-disk rename engine (`rename/`) —
skip-on-conflict, can also organize into (auto-created)
subfolders when the naming scheme includes `/`. Not yet ported:
full media metadata bindings, other web datasource clients
(TVDB/AniDB/etc).
subfolders when the naming scheme includes `/` — and a rename
history/undo log (`history/`, a partial `HistoryDialog` port).
Not yet ported: full media metadata bindings, other web
datasource clients (TVDB/AniDB/etc).
No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
(naming-scheme rendering, filename matching, TMDB metadata fetch,
executing a rename plan, native file/folder dialogs, drag-and-drop
expansion), plus `settings.rs` (TMDB token, stored as JSON in the
OS app-config directory), app metadata/icons/window config
expansion), plus `settings.rs` (TMDB token) and `history.rs`
(rename history/undo log) — both stored as JSON in the OS
app-config directory — app metadata/icons/window config
(tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local matching, TMDB
fetch (behind a Settings modal for the API token), and a
confirm-before-touching-disk modal for the actual rename.
fetch (behind a Settings modal for the API token), a
confirm-before-touching-disk modal for the actual rename, and a
History panel that can undo a past rename.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
change the look).
@@ -217,13 +221,18 @@ ported from, for anyone picking this up:
`--conflict` variants beyond skip (auto-number, override), and the CLI flag surface
itself.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local matching, confirmed rename) is built; the other five sidebar panels
(Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus Align up/down
reordering, History, and Presets on the Rename panel itself are no-ops. There's also
no per-item exclude inside the rename confirmation modal yet (it's all-or-nothing —
use "Exclude Selected" on the file list beforehand to leave a file out entirely).
8. **Undo/history for renames** — there is no undo yet; the confirmation modal before
Rename executes is the only safety net right now. `net.filebot.HistoryDialog`/the
original's rename-history log is the reference for what a real undo would look like.
local matching, confirmed rename, rename history/undo) is built; the other five
sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus
Align up/down reordering and Presets on the Rename panel itself are still no-ops.
There's also no per-item exclude inside the rename confirmation modal yet (it's
all-or-nothing — use "Exclude Selected" on the file list beforehand to leave a file
out entirely).
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
full port of `net.filebot.HistoryDialog`'s session/element log
(`crates/renamr-core/src/history`, `src-tauri/src/history.rs`). Every successful
rename is recorded to a `history.json` in the OS app-config directory (same location
as `settings.json`); the History panel lists past batches and can revert one back to
its original paths, skipping (never overwriting) any file whose original path now
has something else at it. Capped at the 50 most recent batches.
9. **Packaging** — code signing, auto-update, installers per platform via
`npm run tauri build`.
+289
View File
@@ -0,0 +1,289 @@
//! Rename history: a persisted log of past rename batches (what changed, from what to
//! what) and the ability to revert one - Renamr's only safety net beyond the
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
//! session/element log - simpler, and all this app actually needs.
//!
//! This module only implements the *logic* (recording, trimming, reverting); where the
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// One file's old path -> new path, as recorded right after a successful rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryEntry {
pub source: String,
pub new_path: String,
}
/// One executed rename operation - everything renamed together in a single "Rename N
/// Files" confirmation - undoable as a unit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryBatch {
pub id: String,
/// Unix epoch milliseconds when the batch was executed. Kept as a plain number
/// (no date/time dependency needed in this crate) so the frontend can format it
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
pub timestamp: u64,
pub entries: Vec<HistoryEntry>,
}
/// What happened when reverting one entry of a batch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RevertOutcome {
/// Successfully moved back to its original path.
Reverted { restored_path: String },
/// A different file already exists at the original path; left untouched - the same
/// skip-never-overwrite policy `rename::execute_renames` uses.
SkippedConflict { existing_path: String },
/// The renamed file isn't where the history says it should be anymore (moved,
/// deleted, or already reverted some other way), or the OS reported an error.
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct RevertResult {
/// The entry's `new_path` (i.e. where the file was expected to be found, before
/// reverting) - lets the caller map a result back to a specific history entry.
pub source: String,
pub outcome: RevertOutcome,
}
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => false,
}
}
fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
let current = Path::new(&entry.new_path);
let original = Path::new(&entry.source);
if !current.exists() {
return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(),
};
}
if original.exists() && !same_file(original, current) {
return RevertOutcome::SkippedConflict {
existing_path: original.to_string_lossy().to_string(),
};
}
// The original's parent directory should still exist (nothing in Renamr deletes
// directories), but recreate it defensively in case it was removed by hand since -
// cheap, and `create_dir_all` is a no-op when it's already there.
if let Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed {
error: format!("couldn't recreate the original folder: {e}"),
};
}
}
match fs::rename(current, original) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`],
/// entries are processed sequentially (not in parallel) so a collision between two
/// entries in the same batch resolves deterministically rather than racing.
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
batch
.entries
.iter()
.map(|entry| RevertResult {
source: entry.new_path.clone(),
outcome: revert_one(entry),
})
.collect()
}
/// Maximum number of batches kept in the persisted history - once the log grows past
/// this, the oldest batches are dropped so the history file can't grow unbounded over
/// a long-lived install.
pub const MAX_HISTORY_BATCHES: usize = 50;
/// Adds a new batch to the front of `history` (most-recent-first) and trims the log
/// back down to [`MAX_HISTORY_BATCHES`]. A batch with no entries is not recorded at
/// all - a rename operation where every item was unchanged/skipped/failed has nothing
/// to undo, so it shouldn't clutter the history.
pub fn push_batch(history: &mut Vec<HistoryBatch>, batch: HistoryBatch) {
if batch.entries.is_empty() {
return;
}
history.insert(0, batch);
history.truncate(MAX_HISTORY_BATCHES);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("renamr_history_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reverts_a_renamed_file_back_to_its_original_path() {
let dir = temp_dir();
let original = dir.join("Breaking.Bad.S05E14.mkv");
let renamed = dir.join("Ozymandias.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
match &results[0].outcome {
RevertOutcome::Reverted { restored_path } => {
assert_eq!(Path::new(restored_path), original.as_path());
assert!(original.exists());
assert!(!renamed.exists());
}
other => panic!("expected Reverted, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverting_into_a_subfolder_target_recreates_a_deleted_original_folder() {
let dir = temp_dir();
let sub = dir.join("Breaking Bad");
fs::create_dir_all(&sub).unwrap();
let original = sub.join("Ozymandias.mkv");
let renamed = dir.join("flat.mkv");
fs::write(&renamed, "video").unwrap();
// Simulate the organized-into subfolder having been removed since the rename.
fs::remove_dir_all(&sub).unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
assert!(original.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_revert_when_something_else_now_occupies_the_original_path() {
let dir = temp_dir();
let original = dir.join("original.mkv");
fs::write(&original, "a new unrelated file").unwrap();
let renamed = dir.join("renamed.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(
results[0].outcome,
RevertOutcome::SkippedConflict { .. }
));
// Neither file should have been touched.
assert_eq!(fs::read_to_string(&original).unwrap(), "a new unrelated file");
assert_eq!(fs::read_to_string(&renamed).unwrap(), "video");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn fails_gracefully_when_the_renamed_file_is_no_longer_there() {
let dir = temp_dir();
let original = dir.join("original.mkv");
let renamed = dir.join("renamed.mkv"); // never created
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn push_batch_ignores_empty_batches() {
let mut history = Vec::new();
push_batch(
&mut history,
HistoryBatch {
id: "empty".to_string(),
timestamp: 0,
entries: vec![],
},
);
assert!(history.is_empty());
}
#[test]
fn push_batch_orders_most_recent_first_and_trims_to_the_cap() {
let mut history = Vec::new();
for i in 0..(MAX_HISTORY_BATCHES + 5) {
push_batch(
&mut history,
HistoryBatch {
id: format!("batch-{i}"),
timestamp: i as u64,
entries: vec![HistoryEntry {
source: "a".to_string(),
new_path: "b".to_string(),
}],
},
);
}
assert_eq!(history.len(), MAX_HISTORY_BATCHES);
// Most recently pushed batch is first.
assert_eq!(history[0].id, format!("batch-{}", MAX_HISTORY_BATCHES + 4));
// The oldest batches (0..5) were dropped to stay under the cap.
assert!(!history.iter().any(|b| b.id == "batch-0"));
}
}
+1
View File
@@ -6,6 +6,7 @@
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
pub mod history;
pub mod matching;
pub mod rename;
pub mod similarity;
+101
View File
@@ -0,0 +1,101 @@
//! The Tauri-facing side of rename history/undo: persists the log
//! `renamr_core::history` produces to disk (plain JSON in the OS app-config
//! directory, alongside `settings.json`) and exposes it to the UI as two commands -
//! list the log, and undo one batch from it.
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
use renamr_core::rename::{RenameOutcome, RenameResult};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
fn history_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
Ok(dir.join("history.json"))
}
fn load(app: &AppHandle) -> Vec<HistoryBatch> {
let Ok(path) = history_path(app) else {
return Vec::new();
};
fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn save(app: &AppHandle, batches: &[HistoryBatch]) -> Result<(), String> {
let path = history_path(app)?;
let json = serde_json::to_string_pretty(batches).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) -
/// records only the entries that were actually renamed; a file that was unchanged,
/// skipped, or failed to rename has nothing to undo. Best-effort: if persisting the
/// history fails for some reason, the rename itself already succeeded and shouldn't be
/// reported as failed just because its undo record couldn't be saved, so errors here
/// are swallowed rather than surfaced to the caller.
pub fn record_batch(app: &AppHandle, results: &[RenameResult]) {
let entries: Vec<HistoryEntry> = results
.iter()
.filter_map(|r| match &r.outcome {
RenameOutcome::Renamed { new_path } => Some(HistoryEntry {
source: r.source.clone(),
new_path: new_path.clone(),
}),
_ => None,
})
.collect();
if entries.is_empty() {
return;
}
let batch = HistoryBatch {
id: format!("batch-{}", now_millis()),
timestamp: now_millis(),
entries,
};
let mut batches = load(app);
history::push_batch(&mut batches, batch);
let _ = save(app, &batches);
}
/// Lists every recorded rename batch, most-recent-first.
#[tauri::command]
pub fn list_rename_history(app: AppHandle) -> Vec<HistoryBatch> {
load(&app)
}
/// Reverts one batch (by id) and removes it from the history log - a batch is only
/// ever undone once. Reverting is best-effort per file (see `RevertOutcome`): a file
/// that's no longer where the history says, or whose original path now has something
/// else at it, is reported rather than silently skipped, but doesn't block the rest of
/// the batch from being reverted.
#[tauri::command]
pub fn undo_rename_batch(app: AppHandle, batch_id: String) -> Result<Vec<RevertResult>, String> {
let mut batches = load(&app);
let idx = batches
.iter()
.position(|b| b.id == batch_id)
.ok_or_else(|| "That rename batch isn't in the history log anymore.".to_string())?;
let batch = batches.remove(idx);
let results = history::revert_batch(&batch);
save(&app, &batches)?;
Ok(results)
}
+16 -7
View File
@@ -1,4 +1,5 @@
mod files;
mod history;
mod metadata;
mod settings;
@@ -7,6 +8,7 @@ use renamr_core::matching::{self, EpisodeGuess};
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
use renamr_core::string_bindings;
use std::collections::HashMap;
use tauri::AppHandle;
/// Renders a naming-scheme expression against a set of binding values.
/// This is the Tauri-facing entry point into the core format engine, used by the
@@ -28,16 +30,21 @@ fn guess_episode(filename: String) -> Option<EpisodeGuess> {
matching::guess_episode(&filename)
}
/// Executes a batch of planned on-disk renames (same-directory rename only; conflicting
/// targets are skipped, never overwritten - see `renamr_core::rename` for the policy).
/// The UI is expected to have already shown the user the full old-name/new-name plan
/// and gotten explicit confirmation before calling this - there is no undo yet.
/// Executes a batch of planned on-disk renames (optionally moving files into
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
/// never overwritten). The UI is expected to have already shown the user the full
/// old-path/new-path plan and gotten explicit confirmation before calling this.
/// Runs off the main thread since real filesystem I/O shouldn't block the UI.
///
/// Every successfully-renamed item is recorded to the rename history log afterward
/// (see `history::record_batch`) so it can be undone later from the History panel.
#[tauri::command]
async fn execute_rename(items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
.await
.unwrap_or_default()
.unwrap_or_default();
history::record_batch(&app, &results);
results
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -51,6 +58,8 @@ pub fn run() {
metadata::fetch_metadata,
settings::get_tmdb_token,
settings::save_tmdb_token,
history::list_rename_history,
history::undo_rename_batch,
files::pick_files,
files::pick_folder,
files::expand_dropped_paths,
+51
View File
@@ -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;
+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>
);
}