From fd285c8ec73af9a71ad77b88f3bed08224808b1a Mon Sep 17 00:00:00 2001 From: Dino Date: Thu, 9 Apr 2026 18:01:47 +0100 Subject: [PATCH] fs: Add support for restoring trashed files (#52014) 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. --- crates/fs/src/fs.rs | 96 +++++++++++++++++++++- crates/fs/tests/integration/fs.rs | 129 ++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 6b486cafe00d6f9103721571724898a7b7b6f428..bdeb139088bf33e1251bc23a5583a0ee3c9f4bf2 100644 --- a/crates/fs/src/fs.rs +++ b/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 { 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 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 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); 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 { self.this.upgrade().unwrap() diff --git a/crates/fs/tests/integration/fs.rs b/crates/fs/tests/integration/fs.rs index 83956c76c9f1dbe44ae4899ff14f7f8939d0006d..fce8a98dea64fb153cacb5998f005a5cbd5cc11a 100644 --- a/crates/fs/tests/integration/fs.rs +++ b/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(