Compare commits
4 Commits
remove-asy
...
project-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b6e281a8 | ||
|
|
377c40350c | ||
|
|
9809204aca | ||
|
|
f41cd72f6a |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6426,6 +6426,7 @@ dependencies = [
|
||||
"text",
|
||||
"time",
|
||||
"util",
|
||||
"uuid",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
|
||||
@@ -880,6 +880,11 @@
|
||||
"ctrl-alt-c": "project_panel::CopyPath",
|
||||
"alt-shift-copy": "workspace::CopyRelativePath",
|
||||
"alt-ctrl-shift-c": "workspace::CopyRelativePath",
|
||||
"undo": "project_panel::Undo",
|
||||
"ctrl-z": "project_panel::Undo",
|
||||
"redo": "project_panel::Redo",
|
||||
"ctrl-y": "project_panel::Redo",
|
||||
"ctrl-shift-z": "project_panel::Redo",
|
||||
"enter": "project_panel::Rename",
|
||||
"f2": "project_panel::Rename",
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
|
||||
@@ -943,6 +943,8 @@
|
||||
"cmd-v": "project_panel::Paste",
|
||||
"cmd-alt-c": "workspace::CopyPath",
|
||||
"alt-cmd-shift-c": "workspace::CopyRelativePath",
|
||||
"cmd-z": "project_panel::Undo",
|
||||
"cmd-shift-z": "project_panel::Redo",
|
||||
"enter": "project_panel::Rename",
|
||||
"f2": "project_panel::Rename",
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
|
||||
@@ -884,6 +884,9 @@
|
||||
"ctrl-v": "project_panel::Paste",
|
||||
"shift-alt-c": "project_panel::CopyPath",
|
||||
"ctrl-k ctrl-shift-c": "workspace::CopyRelativePath",
|
||||
"ctrl-z": "project_panel::Undo",
|
||||
"ctrl-y": "project_panel::Redo",
|
||||
"ctrl-shift-z": "project_panel::Redo",
|
||||
"enter": "project_panel::Rename",
|
||||
"f2": "project_panel::Rename",
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,7 +1447,8 @@ impl GitPanel {
|
||||
if !entry.status.is_created() {
|
||||
self.perform_checkout(vec![entry.clone()], window, cx);
|
||||
} else {
|
||||
let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
|
||||
let details = "This cannot be undone without restoring the file manually.";
|
||||
let prompt = prompt(&format!("Trash {}?", filename), Some(details), window, cx);
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
match prompt.await? {
|
||||
TrashCancel::Trash => {}
|
||||
@@ -1562,11 +1563,10 @@ impl GitPanel {
|
||||
.filter(|status_entry| !status_entry.status.is_created())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match entries.len() {
|
||||
0 => return,
|
||||
1 => return self.revert_entry(&entries[0], window, cx),
|
||||
_ => {}
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut details = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.repo_path.as_ref().file_name())
|
||||
@@ -1584,7 +1584,7 @@ impl GitPanel {
|
||||
Cancel,
|
||||
}
|
||||
let prompt = prompt(
|
||||
"Discard changes to these files?",
|
||||
"Discard changes to these files? This is PERMANENT and cannot be undone.",
|
||||
Some(&details),
|
||||
window,
|
||||
cx,
|
||||
@@ -1613,11 +1613,9 @@ impl GitPanel {
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match to_delete.len() {
|
||||
0 => return,
|
||||
1 => return self.revert_entry(&to_delete[0], window, cx),
|
||||
_ => {}
|
||||
};
|
||||
if to_delete.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut details = to_delete
|
||||
.iter()
|
||||
@@ -1635,8 +1633,21 @@ impl GitPanel {
|
||||
if to_delete.len() > 5 {
|
||||
details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
|
||||
}
|
||||
let files_noun_phrase = if to_delete.len() == 1 {
|
||||
"this file"
|
||||
} else {
|
||||
"these files"
|
||||
};
|
||||
details.push_str(&format!(
|
||||
"\n\nYou can restore {files_noun_phrase} from the trash."
|
||||
));
|
||||
|
||||
let prompt = prompt("Trash these files?", Some(&details), window, cx);
|
||||
let prompt = prompt(
|
||||
&format!("Trash {files_noun_phrase}?"),
|
||||
Some(&details),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match prompt.await? {
|
||||
TrashCancel::Trash => {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
mod project_panel_operation;
|
||||
mod project_panel_settings;
|
||||
mod utils;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use client::{ErrorCode, ErrorExt};
|
||||
use collections::{BTreeSet, HashMap, hash_map};
|
||||
use command_palette_hooks::CommandPaletteFilter;
|
||||
@@ -71,6 +72,8 @@ use workspace::{
|
||||
use worktree::CreatedEntry;
|
||||
use zed_actions::{project_panel::ToggleFocus, workspace::OpenWithSystem};
|
||||
|
||||
use crate::project_panel_operation::ProjectPanelOperation;
|
||||
|
||||
const PROJECT_PANEL_KEY: &str = "ProjectPanel";
|
||||
const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
|
||||
|
||||
@@ -139,6 +142,8 @@ pub struct ProjectPanel {
|
||||
sticky_items_count: usize,
|
||||
last_reported_update: Instant,
|
||||
update_visible_entries_task: UpdateVisibleEntriesTask,
|
||||
undo_stack: Vec<ProjectPanelOperation>,
|
||||
redo_stack: Vec<ProjectPanelOperation>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
@@ -340,6 +345,10 @@ actions!(
|
||||
SelectPrevDirectory,
|
||||
/// Opens a diff view to compare two marked files.
|
||||
CompareMarkedFiles,
|
||||
/// Undoes the last file operation.
|
||||
Undo,
|
||||
/// Redoes the last undone file operation.
|
||||
Redo,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -819,6 +828,8 @@ impl ProjectPanel {
|
||||
unfolded_dir_ids: Default::default(),
|
||||
},
|
||||
update_visible_entries_task: Default::default(),
|
||||
undo_stack: Default::default(),
|
||||
redo_stack: Default::default(),
|
||||
};
|
||||
this.update_visible_entries(None, false, false, window, cx);
|
||||
|
||||
@@ -1151,7 +1162,10 @@ impl ProjectPanel {
|
||||
menu.action("Trash", Box::new(Trash { skip_prompt: false }))
|
||||
})
|
||||
.when(!is_root, |menu| {
|
||||
menu.action("Delete", Box::new(Delete { skip_prompt: false }))
|
||||
menu.action(
|
||||
"Delete Permanently",
|
||||
Box::new(Delete { skip_prompt: false }),
|
||||
)
|
||||
})
|
||||
.when(!is_remote && is_root, |menu| {
|
||||
menu.separator()
|
||||
@@ -1724,11 +1738,13 @@ impl ProjectPanel {
|
||||
return None;
|
||||
}
|
||||
edited_entry_id = entry.id;
|
||||
edit_task = self.project.update(cx, |project, cx| {
|
||||
project.rename_entry(entry.id, (worktree_id, new_path).into(), cx)
|
||||
});
|
||||
edit_task =
|
||||
self.confirm_undoable_rename_entry(entry.id, (worktree_id, new_path).into(), cx);
|
||||
};
|
||||
|
||||
// Reborrow so lifetime does not overlap `self.confirm_undoable_rename_entry()`
|
||||
let edit_state = self.state.edit_state.as_mut()?;
|
||||
|
||||
if refocus {
|
||||
window.focus(&self.focus_handle, cx);
|
||||
}
|
||||
@@ -1970,6 +1986,90 @@ impl ProjectPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_undoable(&mut self, operation: ProjectPanelOperation) {
|
||||
self.redo_stack.clear();
|
||||
self.undo_stack.push(operation);
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(operation) = self.undo_stack.pop() {
|
||||
let task = self.do_operation(operation, cx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let reverse_operation = task.await?;
|
||||
this.update(cx, |this, _cx| this.redo_stack.push(reverse_operation))
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context<Self>) -> () {
|
||||
if let Some(operation) = self.redo_stack.pop() {
|
||||
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))
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Does an undoable operation and returns the reverse operation.
|
||||
fn do_operation(
|
||||
&self,
|
||||
operation: ProjectPanelOperation,
|
||||
cx: &mut Context<'_, Self>,
|
||||
) -> Task<Result<ProjectPanelOperation>> {
|
||||
match operation {
|
||||
ProjectPanelOperation::Rename { old_path, new_path } => {
|
||||
let Some(entry) = self.project.read(cx).entry_for_path(&old_path, cx) else {
|
||||
return Task::ready(Err(anyhow!("no entry for path")));
|
||||
};
|
||||
let task = self.confirm_rename_entry(entry.id, new_path, cx);
|
||||
cx.spawn(async move |_, _| {
|
||||
let (_created_entry, reverse_operation) = task.await?;
|
||||
Ok(reverse_operation)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_undoable_rename_entry(
|
||||
&self,
|
||||
entry_id: ProjectEntryId,
|
||||
new_path: ProjectPath,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<CreatedEntry>> {
|
||||
let rename_task = self.confirm_rename_entry(entry_id, new_path, cx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (new_entry, operation) = rename_task.await?;
|
||||
this.update(cx, |this, _cx| this.record_undoable(operation))
|
||||
.ok();
|
||||
Ok(new_entry)
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_rename_entry(
|
||||
&self,
|
||||
entry_id: ProjectEntryId,
|
||||
new_path: ProjectPath,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<(CreatedEntry, ProjectPanelOperation)>> {
|
||||
let Some(old_path) = self.project.read(cx).path_for_entry(entry_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("no path for entry")));
|
||||
};
|
||||
let rename_task = self.project.update(cx, |project, cx| {
|
||||
project.rename_entry(entry_id, new_path.clone(), cx)
|
||||
});
|
||||
cx.spawn(async move |_, _| {
|
||||
let created_entry = rename_task.await?;
|
||||
let reverse_operation = ProjectPanelOperation::Rename {
|
||||
old_path: new_path,
|
||||
new_path: old_path,
|
||||
};
|
||||
Ok((created_entry, reverse_operation))
|
||||
})
|
||||
}
|
||||
|
||||
fn rename_impl(
|
||||
&mut self,
|
||||
selection: Option<Range<usize>>,
|
||||
@@ -2073,6 +2173,11 @@ impl ProjectPanel {
|
||||
}
|
||||
let answer = if !skip_prompt {
|
||||
let operation = if trash { "Trash" } else { "Delete" };
|
||||
let permanent_warning = if trash {
|
||||
""
|
||||
} else {
|
||||
"\n\nThis is PERMANENT and CANNOT be undone."
|
||||
};
|
||||
let prompt = match file_paths.first() {
|
||||
Some((_, path)) if file_paths.len() == 1 => {
|
||||
let unsaved_warning = if dirty_buffers > 0 {
|
||||
@@ -2081,7 +2186,7 @@ impl ProjectPanel {
|
||||
""
|
||||
};
|
||||
|
||||
format!("{operation} {path}?{unsaved_warning}")
|
||||
format!("{operation} {path}?{unsaved_warning}{permanent_warning}")
|
||||
}
|
||||
_ => {
|
||||
const CUTOFF_POINT: usize = 10;
|
||||
@@ -2113,7 +2218,7 @@ impl ProjectPanel {
|
||||
};
|
||||
|
||||
format!(
|
||||
"Do you want to {} the following {} files?\n{}{unsaved_warning}",
|
||||
"Do you want to {} the following {} files?\n{}{unsaved_warning}{permanent_warning}",
|
||||
operation.to_lowercase(),
|
||||
file_paths.len(),
|
||||
names.join("\n")
|
||||
@@ -2796,9 +2901,11 @@ impl ProjectPanel {
|
||||
self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
|
||||
let clip_entry_id = clipboard_entry.entry_id;
|
||||
let task = if clipboard_entries.is_cut() {
|
||||
let task = self.project.update(cx, |project, cx| {
|
||||
project.rename_entry(clip_entry_id, (worktree_id, new_path).into(), cx)
|
||||
});
|
||||
let task = self.confirm_undoable_rename_entry(
|
||||
clip_entry_id,
|
||||
(worktree_id, new_path).into(),
|
||||
cx,
|
||||
);
|
||||
PasteTask::Rename(task)
|
||||
} else {
|
||||
let task = self.project.update(cx, |project, cx| {
|
||||
@@ -3131,6 +3238,8 @@ impl ProjectPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let this = cx.entity();
|
||||
|
||||
let destination_worktree = self.project.update(cx, |project, cx| {
|
||||
let source_path = project.path_for_entry(entry_to_move, cx)?;
|
||||
let destination_path = project.path_for_entry(destination_entry, cx)?;
|
||||
@@ -3144,11 +3253,13 @@ impl ProjectPanel {
|
||||
let mut new_path = destination_path.to_rel_path_buf();
|
||||
new_path.push(RelPath::unix(source_path.path.file_name()?).unwrap());
|
||||
if new_path.as_rel_path() != source_path.path.as_ref() {
|
||||
let task = project.rename_entry(
|
||||
entry_to_move,
|
||||
(destination_worktree_id, new_path).into(),
|
||||
cx,
|
||||
);
|
||||
let task = this.update(cx, |this, cx| {
|
||||
this.confirm_undoable_rename_entry(
|
||||
entry_to_move,
|
||||
(destination_worktree_id, new_path).into(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.foreground_executor().spawn(task).detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
@@ -5622,6 +5733,8 @@ impl Render for ProjectPanel {
|
||||
.on_action(cx.listener(Self::fold_directory))
|
||||
.on_action(cx.listener(Self::remove_from_project))
|
||||
.on_action(cx.listener(Self::compare_marked_files))
|
||||
.on_action(cx.listener(Self::undo))
|
||||
.on_action(cx.listener(Self::redo))
|
||||
.when(!project.is_read_only(cx), |el| {
|
||||
el.on_action(cx.listener(Self::new_file))
|
||||
.on_action(cx.listener(Self::new_directory))
|
||||
|
||||
22
crates/project_panel/src/project_panel_operation.rs
Normal file
22
crates/project_panel/src/project_panel_operation.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use project::ProjectPath;
|
||||
// use trash::FileInTrash;
|
||||
|
||||
/// Operation done in the project panel that can be undone.
|
||||
///
|
||||
/// There is no variant for creating a file or copying a file because their
|
||||
/// reverse is `Trash`.
|
||||
///
|
||||
/// - `Trash` and `Restore` are the reverse of each other.
|
||||
/// - `Rename` is its own reverse.
|
||||
pub enum ProjectPanelOperation {
|
||||
// Trash(RelPath),
|
||||
// Restore(FileInTrashId),
|
||||
Rename {
|
||||
old_path: ProjectPath,
|
||||
new_path: ProjectPath,
|
||||
},
|
||||
}
|
||||
|
||||
// pub struct FileInTrashId(u32);
|
||||
|
||||
// proto::Trash -> opaque integer
|
||||
@@ -1917,6 +1917,56 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_undo_redo(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
// - paste (?)
|
||||
// -
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/test",
|
||||
json!({
|
||||
"dir1": {
|
||||
"a.txt": "",
|
||||
"b.txt": "",
|
||||
},
|
||||
"dir2": {},
|
||||
"c.txt": "",
|
||||
"d.txt": "",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
toggle_expand_dir(&panel, "test/dir1", cx);
|
||||
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// todo!(andrew) test cut/paste conflict->rename, copy/paste conflict->rename, drag rename, and rename with 'enter' key
|
||||
|
||||
select_path(&panel, path, cx);
|
||||
// select_path_with_mark(&panel, "test/dir1/a.txt", cx);
|
||||
// select_path_with_mark(&panel, "test/dir1", cx);
|
||||
// select_path_with_mark(&panel, "test/c.txt", cx);
|
||||
drag_selection_to(&panel, target_path, is_file, cx);
|
||||
panel.update_in(cx, |this, window, cx| {
|
||||
this.undo(&Undo, window, cx);
|
||||
});
|
||||
panel.update_in(cx, |this, window, cx| {
|
||||
this.rename(&Undo, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user