diff --git a/Cargo.lock b/Cargo.lock index 0d83b2b9b912ab112d9b38fd1ef1d5ff21f9049c..0a6f3a295c02708926a088f5738162e8a53ee14c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6426,6 +6426,7 @@ dependencies = [ "text", "time", "util", + "uuid", "windows 0.61.3", ] diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 52063eeddcc3aa74adae33f3a78c74ecb6b6f04c..b8f986ebdd40e5f7c34fd960ba787fe837fab84b 100644 --- a/crates/fs/Cargo.toml +++ b/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] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 2cbbf61a21e145464e9dbec01ace3b5510709d0d..d6b9918089f0aaf3cbf3b401be456d03d5c10b65 100644 --- a/crates/fs/src/fs.rs +++ b/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 for PathBuf { } } +#[derive(Debug, Default)] +struct TrashCache { + trashed_items: HashMap, +} +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 { + 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>; 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>; + async fn restore_from_trash(&self, trashed_item: TrashedItem) -> Result; async fn open_handle(&self, path: &Path) -> Result>; async fn open_sync(&self, path: &Path) -> Result>; async fn load(&self, path: &Path) -> Result { @@ -317,6 +357,7 @@ pub struct RealFs { executor: BackgroundExecutor, next_job_id: Arc, job_event_subscribers: Arc>>, + trash_cache: Arc>, } 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> { + 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> { + 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> { + 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> { 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> { 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> { + 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 { + 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> { @@ -1211,6 +1282,7 @@ pub struct FakeFs { // Use an unfair lock to ensure tests are deterministic. state: Arc>, executor: gpui::BackgroundExecutor, + trash_cache: Arc>, } #[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> { + 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> { + 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 { + 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> { 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 { + 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> { +pub async fn read_dir_items<'a>( + fs: &'a dyn Fs, + source: &'a Path, +) -> Result> { 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( 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( + fs: &F, + path: &Path, + trash_cache: &Mutex, + options: RemoveOptions, +) -> Result> { + // 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); + } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3211cc7af0f083f9b07edcdb439e7075f774bdcd..c32ab6acc20bb753ef5b2f1a4841ac605ab23bb8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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(); } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 7145bccd514fbb5d6093efda765a826162c91260..86da9ec07915f53e995f55527eb7371736c2355a 100644 --- a/crates/worktree/src/worktree.rs +++ b/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, }); }