Add trash & restore operations to `Fs`

HactarCE and Cole Miller created

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                |   1 
crates/fs/Cargo.toml                      |   1 
crates/fs/src/fs.rs                       | 243 ++++++++++++++++++++++--
crates/project_panel/src/project_panel.rs |   4 
crates/worktree/src/worktree.rs           |   6 
5 files changed, 228 insertions(+), 27 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6426,6 +6426,7 @@ dependencies = [
  "text",
  "time",
  "util",
+ "uuid",
  "windows 0.61.3",
 ]
 

crates/fs/Cargo.toml 🔗

@@ -33,6 +33,7 @@ tempfile.workspace = true
 text.workspace = true
 time.workspace = true
 util.workspace = true
+uuid.workspace = true
 is_executable = "1.0.5"
 
 [target.'cfg(target_os = "macos")'.dependencies]

crates/fs/src/fs.rs 🔗

@@ -7,6 +7,7 @@ pub mod fs_watcher;
 use parking_lot::Mutex;
 use std::sync::atomic::{AtomicUsize, Ordering};
 use std::time::Instant;
+use uuid::Uuid;
 
 use anyhow::{Context as _, Result, anyhow};
 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
@@ -30,6 +31,7 @@ use std::os::unix::fs::{FileTypeExt, MetadataExt};
 use std::mem::MaybeUninit;
 
 use async_tar::Archive;
+use collections::HashMap;
 use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture};
 use git::repository::{GitRepository, RealGitRepository};
 use is_executable::IsExecutable;
@@ -66,6 +68,9 @@ use std::ffi::OsStr;
 #[cfg(any(test, feature = "test-support"))]
 pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK};
 
+/// Maximum size in bytes allowed for a file to allow trash & restore using temp dir.
+const TRASH_LIMIT: u64 = 8 * 1024 * 1024; // 8 MiB
+
 pub trait Watcher: Send + Sync {
     fn add(&self, path: &Path) -> Result<()>;
     fn remove(&self, path: &Path) -> Result<()>;
@@ -90,6 +95,44 @@ impl From<PathEvent> for PathBuf {
     }
 }
 
+#[derive(Debug, Default)]
+struct TrashCache {
+    trashed_items: HashMap<TrashedItem, TrashedItemInfo>,
+}
+impl TrashCache {
+    /// Adds an item to the trash cache.
+    ///
+    /// This assumes that the item will then be moved or copied to the returned `path_in_trash`.
+    fn add_item(&mut self, original_path: &Path) -> (TrashedItem, TrashedItemInfo) {
+        let uuid = Uuid::new_v4();
+        let path_in_trash = paths::temp_dir()
+            .join("trashed_files")
+            .join(uuid.to_string());
+        let id = TrashedItem(uuid);
+        let info = TrashedItemInfo {
+            path_in_trash,
+            original_path: original_path.to_path_buf(),
+        };
+        self.trashed_items.insert(id, info.clone());
+        (id, info)
+    }
+    fn remove(&mut self, id: TrashedItem) -> Option<TrashedItemInfo> {
+        self.trashed_items.remove(&id)
+    }
+}
+/// Info needed to restore an item from the trash.
+///
+/// In the future, this can be made OS-specific.
+#[derive(Debug, Clone)]
+struct TrashedItemInfo {
+    path_in_trash: PathBuf,
+    original_path: PathBuf,
+}
+
+/// Handle to a trashed item.
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
+pub struct TrashedItem(Uuid);
+
 #[async_trait::async_trait]
 pub trait Fs: Send + Sync {
     async fn create_dir(&self, path: &Path) -> Result<()>;
@@ -108,13 +151,10 @@ pub trait Fs: Send + Sync {
     async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()>;
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>;
     async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()>;
-    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
-        self.remove_dir(path, options).await
-    }
+    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>>;
     async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>;
-    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
-        self.remove_file(path, options).await
-    }
+    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>>;
+    async fn restore_from_trash(&self, trashed_item: TrashedItem) -> Result<PathBuf>;
     async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>>;
     async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read + Send + Sync>>;
     async fn load(&self, path: &Path) -> Result<String> {
@@ -317,6 +357,7 @@ pub struct RealFs {
     executor: BackgroundExecutor,
     next_job_id: Arc<AtomicUsize>,
     job_event_subscribers: Arc<Mutex<Vec<JobEventSender>>>,
+    trash_cache: Arc<Mutex<TrashCache>>,
 }
 
 pub trait FileHandle: Send + Sync + std::fmt::Debug {
@@ -423,6 +464,7 @@ impl RealFs {
             executor,
             next_job_id: Arc::new(AtomicUsize::new(0)),
             job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+            trash_cache: Arc::new(Mutex::new(TrashCache::default())),
         }
     }
 
@@ -644,7 +686,9 @@ impl Fs for RealFs {
     }
 
     #[cfg(target_os = "macos")]
-    async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
+    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+
         use cocoa::{
             base::{id, nil},
             foundation::{NSAutoreleasePool, NSString},
@@ -664,11 +708,13 @@ impl Fs for RealFs {
 
             let _: id = msg_send![workspace, recycleURLs: array completionHandler: nil];
         }
-        Ok(())
+        Ok(trashed_item)
     }
 
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
+    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+
         if let Ok(Some(metadata)) = self.metadata(path).await
             && metadata.is_symlink
         {
@@ -677,7 +723,7 @@ impl Fs for RealFs {
         }
         let file = smol::fs::File::open(path).await?;
         match trash::trash_file(&file.as_fd()).await {
-            Ok(_) => Ok(()),
+            Ok(_) => Ok(trashed_item),
             Err(err) => {
                 log::error!("Failed to trash file: {}", err);
                 // Trashing files can fail if you don't have a trashing dbus service configured.
@@ -685,10 +731,14 @@ impl Fs for RealFs {
                 return self.remove_file(path, RemoveOptions::default()).await;
             }
         }
+
+        Ok(trashed_item)
     }
 
     #[cfg(target_os = "windows")]
-    async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
+    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+
         use util::paths::SanitizedPath;
         use windows::{
             Storage::{StorageDeleteOption, StorageFile},
@@ -701,21 +751,23 @@ impl Fs for RealFs {
         let path_string = path.to_string();
         let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?;
         file.DeleteAsync(StorageDeleteOption::Default)?.get()?;
-        Ok(())
+        Ok(trashed_item)
     }
 
     #[cfg(target_os = "macos")]
-    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
         self.trash_file(path, options).await
     }
 
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
         self.trash_file(path, options).await
     }
 
     #[cfg(target_os = "windows")]
-    async fn trash_dir(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
+    async fn trash_dir(&self, path: &Path, _options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+
         use util::paths::SanitizedPath;
         use windows::{
             Storage::{StorageDeleteOption, StorageFolder},
@@ -729,7 +781,26 @@ impl Fs for RealFs {
         let path_string = path.to_string();
         let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?;
         folder.DeleteAsync(StorageDeleteOption::Default)?.get()?;
-        Ok(())
+        Ok(trashed_item)
+    }
+
+    async fn restore_from_trash(&self, trashed_item: TrashedItem) -> Result<PathBuf> {
+        let trash_info = self
+            .trash_cache
+            .lock()
+            .remove(trashed_item)
+            .context("no item in trash")?;
+        self.rename(
+            &trash_info.path_in_trash,
+            &trash_info.original_path,
+            RenameOptions {
+                overwrite: false,
+                ignore_if_exists: false,
+                create_parents: true,
+            },
+        )
+        .await?;
+        Ok(trash_info.original_path)
     }
 
     async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read + Send + Sync>> {
@@ -1211,6 +1282,7 @@ pub struct FakeFs {
     // Use an unfair lock to ensure tests are deterministic.
     state: Arc<Mutex<FakeFsState>>,
     executor: gpui::BackgroundExecutor,
+    trash_cache: Arc<Mutex<TrashCache>>,
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -1513,6 +1585,7 @@ impl FakeFs {
                 moves: Default::default(),
                 job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
             })),
+            trash_cache: Arc::new(Mutex::new(TrashCache::default())),
         });
 
         executor.spawn({
@@ -2504,6 +2577,12 @@ impl Fs for FakeFs {
         Ok(())
     }
 
+    async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+        self.remove_dir(path, options).await?;
+        Ok(trashed_item)
+    }
+
     async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
         self.simulate_random_delay().await;
 
@@ -2530,6 +2609,31 @@ impl Fs for FakeFs {
         Ok(())
     }
 
+    async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<Option<TrashedItem>> {
+        let trashed_item = copy_to_trash_cache(self, path, &self.trash_cache, options).await?;
+        self.remove_file(path, options).await?;
+        Ok(trashed_item)
+    }
+
+    async fn restore_from_trash(&self, trashed_item: TrashedItem) -> Result<PathBuf> {
+        let trash_info = self
+            .trash_cache
+            .lock()
+            .remove(trashed_item)
+            .context("no item in trash")?;
+        self.rename(
+            &trash_info.path_in_trash,
+            &trash_info.original_path,
+            RenameOptions {
+                overwrite: false,
+                ignore_if_exists: false,
+                create_parents: true,
+            },
+        )
+        .await?;
+        Ok(trash_info.original_path)
+    }
+
     async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read + Send + Sync>> {
         let bytes = self.load_internal(path).await?;
         Ok(Box::new(io::Cursor::new(bytes)))
@@ -2819,7 +2923,7 @@ pub async fn copy_recursive<'a>(
     target: &'a Path,
     options: CopyOptions,
 ) -> Result<()> {
-    for (item, is_dir) in read_dir_items(fs, source).await? {
+    for (item, metadata) in read_dir_items(fs, source).await? {
         let Ok(item_relative_path) = item.strip_prefix(source) else {
             continue;
         };
@@ -2828,7 +2932,7 @@ pub async fn copy_recursive<'a>(
         } else {
             target.join(item_relative_path)
         };
-        if is_dir {
+        if metadata.is_dir {
             if !options.overwrite && fs.metadata(&target_item).await.is_ok_and(|m| m.is_some()) {
                 if options.ignore_if_exists {
                     continue;
@@ -2853,10 +2957,22 @@ pub async fn copy_recursive<'a>(
     Ok(())
 }
 
+pub async fn dir_total_len<'a>(fs: &'a dyn Fs, source: &'a Path) -> Result<u64> {
+    Ok(read_dir_items(fs, source)
+        .await?
+        .into_iter()
+        .filter(|(_path, metadata)| !metadata.is_dir)
+        .map(|(_path, metadata)| metadata.len)
+        .sum())
+}
+
 /// Recursively reads all of the paths in the given directory.
 ///
 /// Returns a vector of tuples of (path, is_dir).
-pub async fn read_dir_items<'a>(fs: &'a dyn Fs, source: &'a Path) -> Result<Vec<(PathBuf, bool)>> {
+pub async fn read_dir_items<'a>(
+    fs: &'a dyn Fs,
+    source: &'a Path,
+) -> Result<Vec<(PathBuf, Metadata)>> {
     let mut items = Vec::new();
     read_recursive(fs, source, &mut items).await?;
     Ok(items)
@@ -2865,7 +2981,7 @@ pub async fn read_dir_items<'a>(fs: &'a dyn Fs, source: &'a Path) -> Result<Vec<
 fn read_recursive<'a>(
     fs: &'a dyn Fs,
     source: &'a Path,
-    output: &'a mut Vec<(PathBuf, bool)>,
+    output: &'a mut Vec<(PathBuf, Metadata)>,
 ) -> BoxFuture<'a, Result<()>> {
     use futures::future::FutureExt;
 
@@ -2876,7 +2992,7 @@ fn read_recursive<'a>(
             .with_context(|| format!("path does not exist: {source:?}"))?;
 
         if metadata.is_dir {
-            output.push((source.to_path_buf(), true));
+            output.push((source.to_path_buf(), metadata));
             let mut children = fs.read_dir(source).await?;
             while let Some(child_path) = children.next().await {
                 if let Ok(child_path) = child_path {
@@ -2884,13 +3000,52 @@ fn read_recursive<'a>(
                 }
             }
         } else {
-            output.push((source.to_path_buf(), false));
+            output.push((source.to_path_buf(), metadata));
         }
         Ok(())
     }
     .boxed()
 }
 
+/// If implementing OS-specific restore-from-trash functionality, use
+/// `#[cfg(...)]` to exclude this function or change its implementation
+async fn copy_to_trash_cache<F: Fs>(
+    fs: &F,
+    path: &Path,
+    trash_cache: &Mutex<TrashCache>,
+    options: RemoveOptions,
+) -> Result<Option<TrashedItem>> {
+    // if path doesn't exist, we'll return `None` and let the caller handle the error case
+    let Some(metadata) = fs.metadata(path).await? else {
+        return Ok(None);
+    };
+
+    let len = if metadata.is_dir {
+        dir_total_len(fs, path).await?
+    } else {
+        metadata.len
+    };
+    if len <= TRASH_LIMIT {
+        let (id, trash_info) = trash_cache.lock().add_item(path);
+        if let Some(parent) = trash_info.path_in_trash.parent() {
+            fs.create_dir(parent).await?;
+        }
+        if metadata.is_dir {
+            if options.recursive {
+                copy_recursive(fs, path, &trash_info.path_in_trash, CopyOptions::default()).await?;
+            } else {
+                fs.create_dir(path).await?;
+            }
+        } else {
+            fs.copy_file(path, &trash_info.path_in_trash, CopyOptions::default())
+                .await?;
+        }
+        Ok(Some(id))
+    } else {
+        Ok(None) // file is too big
+    }
+}
+
 // todo(windows)
 // can we get file id not open the file twice?
 // https://github.com/rust-lang/rust/issues/63010
@@ -3377,6 +3532,7 @@ mod tests {
             executor,
             next_job_id: Arc::new(AtomicUsize::new(0)),
             job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+            trash_cache: Arc::new(Mutex::new(TrashCache::default())),
         };
         let temp_dir = TempDir::new().unwrap();
         let file_to_be_replaced = temp_dir.path().join("file.txt");
@@ -3397,6 +3553,7 @@ mod tests {
             executor,
             next_job_id: Arc::new(AtomicUsize::new(0)),
             job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+            trash_cache: Arc::new(Mutex::new(TrashCache::default())),
         };
         let temp_dir = TempDir::new().unwrap();
         let file_to_be_replaced = temp_dir.path().join("file.txt");
@@ -3483,4 +3640,46 @@ mod tests {
             ]
         );
     }
+
+    #[gpui::test]
+    async fn test_trash_and_restore(executor: BackgroundExecutor) {
+        let fs = FakeFs::new(executor.clone());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "src": {
+                    "file_a.txt": "content a",
+                    "file_b.txt": "content b",
+                    "file_c.txt": "content c"
+                }
+            }),
+        )
+        .await;
+        let file_a = fs
+            .trash_file(
+                path!("/root/src/file_a.txt").as_ref(),
+                RemoveOptions::default(),
+            )
+            .await
+            .unwrap()
+            .unwrap();
+        assert!(!fs.is_file(path!("/root/src/file_a.txt").as_ref()).await);
+        let src_dir = fs
+            .trash_dir(
+                path!("/root/src").as_ref(),
+                RemoveOptions {
+                    recursive: true,
+                    ignore_if_not_exists: false,
+                },
+            )
+            .await
+            .unwrap()
+            .unwrap();
+        assert!(!fs.is_dir(path!("/root/src").as_ref()).await);
+        fs.restore_from_trash(src_dir).await.unwrap();
+        assert!(fs.is_dir(path!("/root/src").as_ref()).await);
+        assert!(!fs.is_file(path!("/root/src/file_a.txt").as_ref()).await);
+        fs.restore_from_trash(file_a).await.unwrap();
+        assert!(fs.is_file(path!("/root/src/file_a.txt").as_ref()).await);
+    }
 }

crates/project_panel/src/project_panel.rs 🔗

@@ -1996,7 +1996,7 @@ impl ProjectPanel {
             let task = self.do_operation(operation, cx);
             cx.spawn(async move |this, cx| {
                 let reverse_operation = task.await?;
-                this.update(cx, |this, _| this.redo_stack.push(reverse_operation))
+                this.update(cx, |this, _cx| this.redo_stack.push(reverse_operation))
             })
             .detach();
         }
@@ -2007,7 +2007,7 @@ impl ProjectPanel {
             let task = self.do_operation(operation, cx);
             cx.spawn(async |this, cx| {
                 let reverse_operation = task.await?;
-                this.update(cx, |this, cx| this.undo_stack.push(reverse_operation))
+                this.update(cx, |this, _cx| this.undo_stack.push(reverse_operation))
             })
             .detach();
         }

crates/worktree/src/worktree.rs 🔗

@@ -2061,7 +2061,7 @@ impl RemoteWorktree {
                 else {
                     continue;
                 };
-                for (abs_path, is_directory) in
+                for (abs_path, metadata) in
                     read_dir_items(local_fs.as_ref(), &root_path_to_copy).await?
                 {
                     let Some(relative_path) = abs_path
@@ -2072,7 +2072,7 @@ impl RemoteWorktree {
                     else {
                         continue;
                     };
-                    let content = if is_directory {
+                    let content = if metadata.is_dir {
                         None
                     } else {
                         Some(local_fs.load_bytes(&abs_path).await?)
@@ -2087,7 +2087,7 @@ impl RemoteWorktree {
                         project_id,
                         worktree_id,
                         path: target_path.to_proto(),
-                        is_directory,
+                        is_directory: metadata.is_dir,
                         content,
                     });
                 }