Compare commits

...

4 Commits

Author SHA1 Message Date
HactarCE
d9b6e281a8 Add trash & restore operations to Fs
---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-19 10:34:33 -05:00
HactarCE
377c40350c Merge branch 'main' into project-panel-undo-redo 2025-12-18 16:23:19 -05:00
HactarCE
9809204aca Add undo/redo of renames in project panel
---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-16 11:53:04 -05:00
HactarCE
f41cd72f6a Add scarier warning messages on destructive file ops 2025-12-10 17:17:45 -05:00
11 changed files with 459 additions and 52 deletions

1
Cargo.lock generated
View File

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

View File

@@ -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 }],

View File

@@ -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 }],

View File

@@ -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 }],

View File

@@ -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]

View File

@@ -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);
}
}

View File

@@ -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 => {}

View File

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

View 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

View File

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

View File

@@ -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,
});
}