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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user