1use std::collections::VecDeque;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use crate::progress::{OperationComplete, OperationProgress, OperationType, publish_progress};
12use crate::{OPERATION_CHANNEL_SIZE, OperationError};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum UndoableOperation {
17 FilesMoved { moves: Vec<(PathBuf, PathBuf)> },
19 FilesCopied { created: Vec<PathBuf> },
21 FilesTrashed { paths: Vec<PathBuf>, at: SystemTime },
24 FilesRenamed { renames: Vec<(PathBuf, PathBuf)> },
26 FileCreated { path: PathBuf },
28 DirectoryCreated { path: PathBuf },
30}
31
32impl UndoableOperation {
33 pub fn item_count(&self) -> usize {
35 match self {
36 Self::FilesMoved { moves } => moves.len(),
37 Self::FilesCopied { created } => created.len(),
38 Self::FilesTrashed { paths, .. } => paths.len(),
39 Self::FilesRenamed { renames } => renames.len(),
40 Self::FileCreated { .. } | Self::DirectoryCreated { .. } => 1,
41 }
42 }
43
44 pub fn touched_paths(&self) -> Vec<PathBuf> {
46 match self {
47 Self::FilesMoved { moves: pairs } | Self::FilesRenamed { renames: pairs } => pairs
48 .iter()
49 .flat_map(|(original, current)| [original.clone(), current.clone()])
50 .collect(),
51 Self::FilesCopied { created: paths } | Self::FilesTrashed { paths, .. } => {
52 paths.clone()
53 }
54 Self::FileCreated { path } | Self::DirectoryCreated { path } => vec![path.clone()],
55 }
56 }
57
58 pub fn can_undo(&self) -> bool {
60 match self {
61 Self::FilesTrashed { .. } => TRASH_RESTORE_SUPPORTED,
62 _ => true,
63 }
64 }
65
66 pub fn undo_description(&self) -> String {
68 let count = self.item_count();
69 match self {
70 Self::FilesMoved { .. } => format!("Move {count} item(s) back"),
71 Self::FilesCopied { .. } => format!("Move {count} copied item(s) to the Trash"),
72 Self::FilesTrashed { .. } if TRASH_RESTORE_SUPPORTED => {
73 format!("Restore {count} item(s) from the Trash")
74 }
75 Self::FilesTrashed { .. } => {
76 format!("{count} item(s) are in the Trash; restore them from Finder")
77 }
78 Self::FilesRenamed { .. } => format!("Rename {count} item(s) back"),
79 Self::FileCreated { .. } => "Remove the new file if still empty".into(),
80 Self::DirectoryCreated { .. } => "Remove the new directory if still empty".into(),
81 }
82 }
83
84 fn operation_type(&self) -> OperationType {
85 match self {
86 Self::FilesMoved { .. } => OperationType::Move,
87 Self::FilesTrashed { .. } => OperationType::Restore,
88 Self::FilesRenamed { .. } => OperationType::Rename,
89 Self::FilesCopied { .. } | Self::FileCreated { .. } | Self::DirectoryCreated { .. } => {
90 OperationType::Delete
91 }
92 }
93 }
94}
95
96const TRASH_RESTORE_SUPPORTED: bool = cfg!(any(
97 windows,
98 all(
99 unix,
100 not(target_os = "macos"),
101 not(target_os = "ios"),
102 not(target_os = "android")
103 )
104));
105
106#[derive(Debug, Clone)]
108pub struct UndoEntry {
109 pub id: u64,
110 pub timestamp: SystemTime,
111 pub operation: UndoableOperation,
112 pub description: String,
113}
114
115impl UndoEntry {
116 pub fn new(id: u64, operation: UndoableOperation, description: impl Into<String>) -> Self {
117 Self {
118 id,
119 timestamp: SystemTime::now(),
120 operation,
121 description: description.into(),
122 }
123 }
124}
125
126#[derive(Debug)]
128pub struct UndoLog {
129 entries: VecDeque<UndoEntry>,
130 max_entries: usize,
131 next_id: u64,
132}
133
134impl Default for UndoLog {
135 fn default() -> Self {
136 Self::new(100)
137 }
138}
139
140impl UndoLog {
141 pub fn new(max_entries: usize) -> Self {
143 Self {
144 entries: VecDeque::with_capacity(max_entries.min(128)),
145 max_entries,
146 next_id: 0,
147 }
148 }
149
150 pub fn record(&mut self, operation: UndoableOperation, description: impl Into<String>) -> u64 {
152 let id = self.next_id;
153 self.next_id += 1;
154 if operation.item_count() > 0 {
155 self.push(UndoEntry::new(id, operation, description));
156 }
157 id
158 }
159
160 pub fn push(&mut self, entry: UndoEntry) {
162 if self.max_entries == 0 {
163 return;
164 }
165 if self.entries.len() >= self.max_entries {
166 self.entries.pop_front();
167 }
168 self.entries.push_back(entry);
169 }
170
171 pub fn record_move(&mut self, moves: Vec<(PathBuf, PathBuf)>) -> u64 {
172 let description = format!("Moved {} item(s)", moves.len());
173 self.record(UndoableOperation::FilesMoved { moves }, description)
174 }
175
176 pub fn record_copy(&mut self, created: Vec<PathBuf>) -> u64 {
177 let description = format!("Copied {} item(s)", created.len());
178 self.record(UndoableOperation::FilesCopied { created }, description)
179 }
180
181 pub fn record_trash(&mut self, paths: Vec<PathBuf>) -> u64 {
182 let description = format!("Moved {} item(s) to the Trash", paths.len());
183 let at = SystemTime::now();
184 self.record(UndoableOperation::FilesTrashed { paths, at }, description)
185 }
186
187 pub fn record_renames(&mut self, renames: Vec<(PathBuf, PathBuf)>) -> u64 {
188 let description = match renames.as_slice() {
189 [(from, to)] => format!("Renamed '{}' to '{}'", file_name(from), file_name(to)),
190 _ => format!("Renamed {} item(s)", renames.len()),
191 };
192 self.record(UndoableOperation::FilesRenamed { renames }, description)
193 }
194
195 pub fn record_create_file(&mut self, path: PathBuf) -> u64 {
196 let description = format!("Created file '{}'", file_name(&path));
197 self.record(UndoableOperation::FileCreated { path }, description)
198 }
199
200 pub fn record_create_directory(&mut self, path: PathBuf) -> u64 {
201 let description = format!("Created directory '{}'", file_name(&path));
202 self.record(UndoableOperation::DirectoryCreated { path }, description)
203 }
204
205 pub fn pop(&mut self) -> Option<UndoEntry> {
207 if self.entries.back()?.operation.can_undo() {
208 self.entries.pop_back()
209 } else {
210 None
211 }
212 }
213
214 pub fn peek(&self) -> Option<&UndoEntry> {
215 self.entries.back()
216 }
217
218 pub fn len(&self) -> usize {
219 self.entries.len()
220 }
221
222 pub fn is_empty(&self) -> bool {
223 self.entries.is_empty()
224 }
225
226 pub fn clear(&mut self) {
227 self.entries.clear();
228 }
229
230 pub fn iter(&self) -> impl Iterator<Item = &UndoEntry> {
232 self.entries.iter()
233 }
234}
235
236fn file_name(path: &Path) -> String {
237 path.file_name()
238 .map(|name| name.to_string_lossy().into_owned())
239 .unwrap_or_default()
240}
241
242#[derive(Debug)]
244pub enum UndoResult {
245 Progress(OperationProgress),
246 Complete(UndoComplete),
247}
248
249#[derive(Debug)]
251pub struct UndoComplete {
252 pub inner: OperationComplete,
253 pub remaining: Option<UndoEntry>,
256}
257
258pub fn execute_undo(entry: UndoEntry, token: CancellationToken) -> mpsc::Receiver<UndoResult> {
261 let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
262 tokio::spawn(async move {
263 let operation_type = entry.operation.operation_type();
264 if token.is_cancelled() {
265 let complete = UndoComplete {
268 inner: OperationComplete {
269 cancelled: true,
270 operation_type,
271 succeeded: 0,
272 failed: 0,
273 skipped: 0,
274 bytes_processed: 0,
275 errors: Vec::new(),
276 },
277 remaining: Some(entry),
278 };
279 let _ = tx.send(UndoResult::Complete(complete)).await;
280 return;
281 }
282 let mut progress = OperationProgress::new(operation_type, entry.operation.item_count(), 0);
283 publish_progress(&tx, || UndoResult::Progress(progress.clone()), &token);
284 let work_token = token.clone();
285 let work = entry.clone();
286 let (left, errors) = tokio::task::spawn_blocking(move || undo_blocking(&work, &work_token))
287 .await
288 .unwrap_or_else(|error| {
289 (
290 Some(entry.operation.clone()),
291 vec![OperationError::new(
292 PathBuf::new(),
293 format!("worker failed: {error}"),
294 )],
295 )
296 });
297 for error in errors {
298 progress.add_error(error);
299 }
300 let failed = left.as_ref().map_or(0, UndoableOperation::item_count);
301 const INCOMPLETE: &str = " (undo incomplete)";
302 let remaining = left.map(|operation| UndoEntry {
303 operation,
304 description: format!(
305 "{}{INCOMPLETE}",
306 entry.description.trim_end_matches(INCOMPLETE)
307 ),
308 ..entry.clone()
309 });
310 let complete = UndoComplete {
311 inner: OperationComplete {
312 cancelled: token.is_cancelled(),
313 operation_type,
314 succeeded: entry.operation.item_count().saturating_sub(failed),
315 failed,
316 skipped: 0,
317 bytes_processed: 0,
318 errors: progress.errors,
319 },
320 remaining,
321 };
322 let _ = tx.send(UndoResult::Complete(complete)).await;
323 });
324 rx
325}
326
327fn undo_blocking(
329 entry: &UndoEntry,
330 token: &CancellationToken,
331) -> (Option<UndoableOperation>, Vec<OperationError>) {
332 let mut errors = Vec::new();
333 let mut fail = |path: &Path, message: String| {
334 errors.push(OperationError::new(path.into(), message));
335 };
336 let all_or_nothing =
337 |result: Result<(), String>, path: &Path, fail: &mut dyn FnMut(&Path, String)| {
338 result.err().map(|error| {
339 fail(path, error);
340 entry.operation.clone()
341 })
342 };
343 let remaining = match &entry.operation {
344 UndoableOperation::FilesMoved { moves } => {
345 let mut left = Vec::new();
346 for (original, current) in moves {
347 let result = if token.is_cancelled() {
348 Err(crate::fs::CANCELLED.to_string())
349 } else {
350 move_back(current, original)
351 };
352 if let Err(error) = result {
353 fail(current, error);
354 left.push((original.clone(), current.clone()));
355 }
356 }
357 (!left.is_empty()).then_some(UndoableOperation::FilesMoved { moves: left })
358 }
359 UndoableOperation::FilesCopied { created } => {
360 let mut left = Vec::new();
361 for path in created {
362 let result = if token.is_cancelled() {
363 Err(crate::fs::CANCELLED.to_string())
364 } else {
365 crate::delete::trash_context()
366 .delete(path)
367 .map_err(|error| format!("could not move to the Trash: {error}"))
368 };
369 if let Err(error) = result {
370 fail(path, error);
371 left.push(path.clone());
372 }
373 }
374 (!left.is_empty()).then_some(UndoableOperation::FilesCopied { created: left })
375 }
376 UndoableOperation::FilesTrashed { paths, at } => all_or_nothing(
377 restore_from_trash(paths, *at),
378 paths.first().map_or(Path::new(""), PathBuf::as_path),
379 &mut fail,
380 ),
381 UndoableOperation::FilesRenamed { renames } => {
382 let reverse: Vec<_> = renames
383 .iter()
384 .map(|(original, current)| (current.clone(), original.clone()))
385 .collect();
386 all_or_nothing(
387 crate::rename::rename_paths(&reverse, token),
388 reverse.first().map_or(Path::new(""), |(path, _)| path),
389 &mut fail,
390 )
391 }
392 UndoableOperation::FileCreated { path } => {
393 all_or_nothing(remove_created(path, false), path, &mut fail)
394 }
395 UndoableOperation::DirectoryCreated { path } => {
396 all_or_nothing(remove_created(path, true), path, &mut fail)
397 }
398 };
399 (remaining, errors)
400}
401
402fn move_back(current: &Path, original: &Path) -> Result<(), String> {
403 if let Some(parent) = original.parent() {
404 fs::create_dir_all(parent).map_err(|error| format!("cannot recreate parent: {error}"))?;
405 }
406 match crate::fs::rename_noreplace(current, original) {
407 Ok(()) => Ok(()),
408 Err(crate::fs::RenameError::CrossDevice) => {
409 let token = CancellationToken::new();
411 let bytes = std::sync::atomic::AtomicU64::new(0);
412 let mut transfer = crate::transfer::Transfer::new(
413 &token,
414 &bytes,
415 crate::transfer::Preserve {
416 times: true,
417 xattrs: true,
418 },
419 );
420 transfer
421 .copy_staged(current, original, None)
422 .map_err(|error| error.to_string())?;
423 let errors = transfer.into_errors();
424 if !errors.is_empty() {
425 return Err(format!(
426 "copied back with {} problem(s); '{}' was kept",
427 errors.len(),
428 current.display()
429 ));
430 }
431 crate::fs::remove_path(current).map_err(|error| error.to_string())
432 }
433 Err(error) => Err(error.to_string()),
434 }
435}
436
437fn remove_created(path: &Path, directory: bool) -> Result<(), String> {
438 let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
439 if directory {
440 if !metadata.is_dir() {
441 return Err("replaced by something that is not a directory".into());
442 }
443 fs::remove_dir(path).map_err(|error| format!("not removed (no longer empty?): {error}"))
445 } else {
446 if !metadata.is_file() {
447 return Err("replaced by something that is not a file".into());
448 }
449 if metadata.len() != 0 {
450 return Err("now contains data; kept".into());
451 }
452 fs::remove_file(path).map_err(|error| error.to_string())
453 }
454}
455
456#[cfg(any(
457 windows,
458 all(
459 unix,
460 not(target_os = "macos"),
461 not(target_os = "ios"),
462 not(target_os = "android")
463 )
464))]
465fn restore_from_trash(paths: &[PathBuf], at: SystemTime) -> Result<(), String> {
466 use std::collections::HashMap;
467 let since = at
468 .duration_since(SystemTime::UNIX_EPOCH)
469 .map(|elapsed| elapsed.as_secs() as i64 - 2)
470 .unwrap_or(0);
471 let wanted: std::collections::HashSet<&Path> = paths.iter().map(PathBuf::as_path).collect();
472 let mut matches: HashMap<PathBuf, trash::TrashItem> = HashMap::new();
474 for item in trash::os_limited::list().map_err(|error| error.to_string())? {
475 let original = item.original_path();
476 if item.time_deleted >= since && wanted.contains(original.as_path()) {
477 match matches.get(&original) {
478 Some(existing) if existing.time_deleted >= item.time_deleted => {}
479 _ => {
480 matches.insert(original, item);
481 }
482 }
483 }
484 }
485 if matches.len() != paths.len() {
486 return Err(format!(
487 "found {} of {} item(s) in the Trash",
488 matches.len(),
489 paths.len()
490 ));
491 }
492 trash::os_limited::restore_all(matches.into_values()).map_err(|error| error.to_string())
493}
494
495#[cfg(not(any(
496 windows,
497 all(
498 unix,
499 not(target_os = "macos"),
500 not(target_os = "ios"),
501 not(target_os = "android")
502 )
503)))]
504fn restore_from_trash(_paths: &[PathBuf], _at: SystemTime) -> Result<(), String> {
505 Err("restoring from the Trash is not supported on this platform".into())
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 async fn undo(entry: UndoEntry) -> UndoComplete {
513 let mut rx = execute_undo(entry, CancellationToken::new());
514 loop {
515 if let Some(UndoResult::Complete(complete)) = rx.recv().await {
516 return complete;
517 }
518 }
519 }
520
521 #[test]
522 fn log_is_bounded_and_ignores_empty_records() {
523 let mut log = UndoLog::new(2);
524 log.record_copy(Vec::new());
525 assert!(log.is_empty());
526 for index in 0..3 {
527 log.record_create_file(PathBuf::from(format!("f{index}")));
528 }
529 assert_eq!(log.len(), 2);
530 assert_eq!(
531 log.pop().unwrap().operation,
532 UndoableOperation::FileCreated { path: "f2".into() }
533 );
534 }
535
536 #[tokio::test]
537 async fn partial_move_undo_requeues_only_failed_items() {
538 let dir = tempfile::tempdir().unwrap();
539 let (a_orig, a_now) = (dir.path().join("a"), dir.path().join("moved/a"));
540 let (b_orig, b_now) = (dir.path().join("b"), dir.path().join("moved/b"));
541 fs::create_dir(dir.path().join("moved")).unwrap();
542 fs::write(&a_now, "a").unwrap();
543 fs::write(&b_now, "b").unwrap();
544 fs::write(&b_orig, "occupied").unwrap();
545 let mut log = UndoLog::default();
546 log.record_move(vec![
547 (a_orig.clone(), a_now),
548 (b_orig.clone(), b_now.clone()),
549 ]);
550 let complete = undo(log.pop().unwrap()).await;
551 assert_eq!((complete.inner.succeeded, complete.inner.failed), (1, 1));
552 assert!(a_orig.exists());
553 let remaining = complete.remaining.unwrap();
554 assert_eq!(
555 remaining.operation,
556 UndoableOperation::FilesMoved {
557 moves: vec![(b_orig.clone(), b_now)]
558 }
559 );
560 fs::remove_file(&b_orig).unwrap();
561 let complete = undo(remaining).await;
562 assert!(complete.inner.is_success() && complete.remaining.is_none());
563 assert_eq!(fs::read_to_string(b_orig).unwrap(), "b");
564 }
565
566 #[tokio::test]
567 async fn rename_undo_and_created_item_guards() {
568 let dir = tempfile::tempdir().unwrap();
569 let (old, new) = (dir.path().join("old"), dir.path().join("new"));
570 fs::write(&new, "x").unwrap();
571 let mut log = UndoLog::default();
572 log.record_renames(vec![(old.clone(), new.clone())]);
573 assert!(undo(log.pop().unwrap()).await.inner.is_success());
574 assert!(old.exists() && !new.exists());
575
576 let file = dir.path().join("created");
577 fs::write(&file, "data").unwrap();
578 log.record_create_file(file.clone());
579 let complete = undo(log.pop().unwrap()).await;
580 assert_eq!(complete.inner.failed, 1);
581 assert!(file.exists());
582 }
583}