@@ -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()
@@ -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(