fs: Add support for restoring trashed files (#52014)

Dino created

Introduce a new `fs::Fs::restore` method which, given a
`fs::TrashedEntry` should attempt to restore the file or directory back
to its original path.

Change summary

crates/fs/src/fs.rs               |  96 ++++++++++++++++++++++++
crates/fs/tests/integration/fs.rs | 129 +++++++++++++++++++++++++++++++++
2 files changed, 224 insertions(+), 1 deletion(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -170,6 +170,13 @@ pub trait Fs: Send + Sync {
     async fn is_case_sensitive(&self) -> bool;
     fn subscribe_to_jobs(&self) -> JobEventReceiver;
 
+    /// Restores a given `TrashedEntry`, moving it from the system's trash back
+    /// to the original path.
+    async fn restore(
+        &self,
+        trashed_entry: TrashedEntry,
+    ) -> std::result::Result<(), TrashRestoreError>;
+
     #[cfg(feature = "test-support")]
     fn as_fake(&self) -> Arc<FakeFs> {
         panic!("called as_fake on a real fs");
@@ -181,7 +188,7 @@ pub trait Fs: Send + Sync {
 // tests from changes to that crate's API surface.
 /// Represents a file or directory that has been moved to the system trash,
 /// retaining enough information to restore it to its original location.
-#[derive(Clone)]
+#[derive(Clone, PartialEq)]
 pub struct TrashedEntry {
     /// Platform-specific identifier for the file/directory in the trash.
     ///
@@ -205,6 +212,41 @@ impl From<trash::TrashItem> for TrashedEntry {
     }
 }
 
+impl TrashedEntry {
+    fn into_trash_item(self) -> trash::TrashItem {
+        trash::TrashItem {
+            id: self.id,
+            name: self.name,
+            original_parent: self.original_parent,
+            // `TrashedEntry` doesn't preserve `time_deleted` as we don't
+            // currently need it for restore, so we default it to 0 here.
+            time_deleted: 0,
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum TrashRestoreError {
+    /// The specified `path` was not found in the system's trash.
+    NotFound { path: PathBuf },
+    /// A file or directory already exists at the restore destination.
+    Collision { path: PathBuf },
+    /// Any other platform-specific error.
+    Unknown { description: String },
+}
+
+impl From<trash::Error> for TrashRestoreError {
+    fn from(err: trash::Error) -> Self {
+        match err {
+            trash::Error::RestoreCollision { path, .. } => Self::Collision { path },
+            trash::Error::Unknown { description } => Self::Unknown { description },
+            other => Self::Unknown {
+                description: other.to_string(),
+            },
+        }
+    }
+}
+
 struct GlobalFs(Arc<dyn Fs>);
 
 impl Global for GlobalFs {}
@@ -1212,6 +1254,13 @@ impl Fs for RealFs {
         );
         res
     }
+
+    async fn restore(
+        &self,
+        trashed_entry: TrashedEntry,
+    ) -> std::result::Result<(), TrashRestoreError> {
+        trash::restore_all([trashed_entry.into_trash_item()]).map_err(Into::into)
+    }
 }
 
 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
@@ -3043,6 +3092,51 @@ impl Fs for FakeFs {
         receiver
     }
 
+    async fn restore(
+        &self,
+        trashed_entry: TrashedEntry,
+    ) -> std::result::Result<(), TrashRestoreError> {
+        let mut state = self.state.lock();
+
+        let Some((trashed_entry, fake_entry)) = state
+            .trash
+            .iter()
+            .find(|(entry, _)| *entry == trashed_entry)
+            .cloned()
+        else {
+            return Err(TrashRestoreError::NotFound {
+                path: PathBuf::from(trashed_entry.id),
+            });
+        };
+
+        let path = trashed_entry
+            .original_parent
+            .join(trashed_entry.name.clone());
+
+        let result = state.write_path(&path, |entry| match entry {
+            btree_map::Entry::Vacant(entry) => {
+                entry.insert(fake_entry);
+                Ok(())
+            }
+            btree_map::Entry::Occupied(_) => {
+                anyhow::bail!("Failed to restore {:?}", path);
+            }
+        });
+
+        match result {
+            Ok(_) => {
+                state.trash.retain(|(entry, _)| *entry != trashed_entry);
+                Ok(())
+            }
+            Err(_) => {
+                // For now we'll just assume that this failed because it was a
+                // collision error, which I think that, for the time being, is
+                // the only case where this could fail?
+                Err(TrashRestoreError::Collision { path })
+            }
+        }
+    }
+
     #[cfg(feature = "test-support")]
     fn as_fake(&self) -> Arc<FakeFs> {
         self.this.upgrade().unwrap()

crates/fs/tests/integration/fs.rs 🔗

@@ -1,5 +1,6 @@
 use std::{
     collections::BTreeSet,
+    ffi::OsString,
     io::Write,
     path::{Path, PathBuf},
     time::Duration,
@@ -687,6 +688,134 @@ async fn test_fake_fs_trash_dir(executor: BackgroundExecutor) {
     assert_eq!(trash_entries[0].original_parent, root_path);
 }
 
+#[gpui::test]
+async fn test_fake_fs_restore(executor: BackgroundExecutor) {
+    let fs = FakeFs::new(executor.clone());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "src": {
+                "file_a.txt": "File A",
+                "file_b.txt": "File B",
+            },
+            "file_c.txt": "File C",
+        }),
+    )
+    .await;
+
+    // Providing a non-existent `TrashedEntry` should result in an error.
+    let id: OsString = "/trash/file_c.txt".into();
+    let name: OsString = "file_c.txt".into();
+    let original_parent = PathBuf::from(path!("/root"));
+    let trashed_entry = TrashedEntry {
+        id,
+        name,
+        original_parent,
+    };
+    let result = fs.restore(trashed_entry).await;
+    assert!(matches!(result, Err(TrashRestoreError::NotFound { .. })));
+
+    // Attempt deleting a file, asserting that the filesystem no longer reports
+    // it as part of its list of files, restore it and verify that the list of
+    // files and trash has been updated accordingly.
+    let path = path!("/root/src/file_a.txt").as_ref();
+    let trashed_entry = fs.trash_file(path).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 1);
+    assert_eq!(
+        fs.files(),
+        vec![
+            PathBuf::from(path!("/root/file_c.txt")),
+            PathBuf::from(path!("/root/src/file_b.txt"))
+        ]
+    );
+
+    fs.restore(trashed_entry).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 0);
+    assert_eq!(
+        fs.files(),
+        vec![
+            PathBuf::from(path!("/root/file_c.txt")),
+            PathBuf::from(path!("/root/src/file_a.txt")),
+            PathBuf::from(path!("/root/src/file_b.txt"))
+        ]
+    );
+
+    // Deleting and restoring a directory should also remove all of its files
+    // but create a single trashed entry, which should be removed after
+    // restoration.
+    let path = path!("/root/src/").as_ref();
+    let trashed_entry = fs.trash_dir(path).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 1);
+    assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
+
+    fs.restore(trashed_entry).await.unwrap();
+
+    assert_eq!(
+        fs.files(),
+        vec![
+            PathBuf::from(path!("/root/file_c.txt")),
+            PathBuf::from(path!("/root/src/file_a.txt")),
+            PathBuf::from(path!("/root/src/file_b.txt"))
+        ]
+    );
+    assert_eq!(fs.trash_entries().len(), 0);
+
+    // A collision error should be returned in case a file is being restored to
+    // a path where a file already exists.
+    let path = path!("/root/src/file_a.txt").as_ref();
+    let trashed_entry = fs.trash_file(path).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 1);
+    assert_eq!(
+        fs.files(),
+        vec![
+            PathBuf::from(path!("/root/file_c.txt")),
+            PathBuf::from(path!("/root/src/file_b.txt"))
+        ]
+    );
+
+    fs.write(path, "New File A".as_bytes()).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 1);
+    assert_eq!(
+        fs.files(),
+        vec![
+            PathBuf::from(path!("/root/file_c.txt")),
+            PathBuf::from(path!("/root/src/file_a.txt")),
+            PathBuf::from(path!("/root/src/file_b.txt"))
+        ]
+    );
+
+    let file_contents = fs.files_with_contents(path);
+    assert!(fs.restore(trashed_entry).await.is_err());
+    assert_eq!(
+        file_contents,
+        vec![(PathBuf::from(path), b"New File A".to_vec())]
+    );
+
+    // A collision error should be returned in case a directory is being
+    // restored to a path where a directory already exists.
+    let path = path!("/root/src/").as_ref();
+    let trashed_entry = fs.trash_dir(path).await.unwrap();
+
+    assert_eq!(fs.trash_entries().len(), 2);
+    assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
+
+    fs.create_dir(path).await.unwrap();
+
+    assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
+    assert_eq!(fs.trash_entries().len(), 2);
+
+    let result = fs.restore(trashed_entry).await;
+    assert!(result.is_err());
+
+    assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/file_c.txt"))]);
+    assert_eq!(fs.trash_entries().len(), 2);
+}
+
 #[gpui::test]
 #[ignore = "stress test; run explicitly when needed"]
 async fn test_realfs_watch_stress_reports_missed_paths(