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