Skip to main content

gravityfile_ops/
undo.rs

1//! Session undo history.
2
3use 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/// An operation that can be reversed.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum UndoableOperation {
17    /// `(original, current)` locations of moved items.
18    FilesMoved { moves: Vec<(PathBuf, PathBuf)> },
19    /// Items created by a copy; undo moves them to the Trash.
20    FilesCopied { created: Vec<PathBuf> },
21    /// Items moved to the Trash at `at`; restorable where the platform
22    /// exposes the Trash (Linux, Windows).
23    FilesTrashed { paths: Vec<PathBuf>, at: SystemTime },
24    /// `(original, current)` paths of renamed items.
25    FilesRenamed { renames: Vec<(PathBuf, PathBuf)> },
26    /// A file that is removed on undo only while still empty.
27    FileCreated { path: PathBuf },
28    /// A directory that is removed on undo only while still empty.
29    DirectoryCreated { path: PathBuf },
30}
31
32impl UndoableOperation {
33    /// Number of top-level items in the record.
34    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    /// Every path the operation, or undoing it, can change.
45    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    /// Whether undo is possible on this platform.
59    pub fn can_undo(&self) -> bool {
60        match self {
61            Self::FilesTrashed { .. } => TRASH_RESTORE_SUPPORTED,
62            _ => true,
63        }
64    }
65
66    /// What undo will do, for display.
67    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/// A recorded operation.
107#[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/// Bounded, most-recent-last undo history.
127#[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    /// A log keeping at most `max_entries` operations; zero disables history.
142    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    /// Record an operation; empty operations are ignored. Returns its id.
151    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    /// Put an entry back, e.g. the remainder of a partially failed undo.
161    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    /// Remove and return the most recent entry if it can be undone here.
206    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    /// Entries, oldest first.
231    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/// Message sent while an undo runs.
243#[derive(Debug)]
244pub enum UndoResult {
245    Progress(OperationProgress),
246    Complete(UndoComplete),
247}
248
249/// Completion of an undo.
250#[derive(Debug)]
251pub struct UndoComplete {
252    pub inner: OperationComplete,
253    /// The items that could not be undone, ready to [`UndoLog::push`] back so
254    /// the user can retry after fixing the cause.
255    pub remaining: Option<UndoEntry>,
256}
257
258/// Reverse `entry`. Items are processed independently; failures are reported
259/// and returned in [`UndoComplete::remaining`].
260pub 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            // Nothing was attempted: report no failures and hand the whole
266            // entry back for a later retry.
267            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
327/// Returns the part of `entry` that was not undone (if any), plus errors.
328fn 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            // The original move crossed filesystems; reverse it the same way.
410            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        // The filesystem refuses non-empty directories atomically.
444        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    // Newest matching trash item per original path.
473    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}