diff --git a/README.md b/README.md index 111893e..77e7f79 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/crates/renamr-core/src/history/mod.rs b/crates/renamr-core/src/history/mod.rs new file mode 100644 index 0000000..8691b2d --- /dev/null +++ b/crates/renamr-core/src/history/mod.rs @@ -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, +} + +/// 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 { + 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, 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")); + } +} diff --git a/crates/renamr-core/src/lib.rs b/crates/renamr-core/src/lib.rs index 1884199..90ba2a9 100644 --- a/crates/renamr-core/src/lib.rs +++ b/crates/renamr-core/src/lib.rs @@ -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; diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs new file mode 100644 index 0000000..647ae53 --- /dev/null +++ b/src-tauri/src/history.rs @@ -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 { + 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 { + 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 = 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 { + 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, 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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 753d306..a93d338 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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) -> Vec { - tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items)) +async fn execute_rename(app: AppHandle, items: Vec) -> Vec { + 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, diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 7bc814c..df6eb1f 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -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; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index d51b5ae..9351c2f 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -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(null); + const [undoingBatchId, setUndoingBatchId] = useState(null); useEffect(() => { invoke("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("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("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() { } label="Exclude Selected" onClick={removeSelected} /> } label="Clear" onClick={clearAll} /> - } label="History" onClick={() => notImplemented("Rename history")} /> + } label="History" onClick={openHistory} /> } label="Presets" onClick={() => notImplemented("Presets")} /> } @@ -906,6 +1005,62 @@ export default function RenamePanel() { )} + + {historyOpen && ( +
+
e.stopPropagation()}> +

Rename History

+

+ 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. +

+ {historyBatches === null ? ( +

Loading…

+ ) : historyBatches.length === 0 ? ( +

No renames yet — this fills in after you use Rename.

+ ) : ( +
+ {historyBatches.map((batch) => ( +
+
+ {new Date(batch.timestamp).toLocaleString()} + + {batch.entries.length} file{batch.entries.length === 1 ? "" : "s"} + + +
+
+ {batch.entries.map((entry, i) => ( +
+ + {fileNameFromPath(entry.newPath)} + + → + + {fileNameFromPath(entry.source)} + +
+ ))} +
+
+ ))} +
+ )} +
+ +
+
+
+ )} ); }