Switch from delete file by default to trash file by default (#10875)

TODO:

- [x] Don't immediately seg fault
- [x] Implement for directories 
- [x] Add cmd-delete to remove files
- [ ] ~~Add setting for trash vs. delete~~ You can just use keybindings
to change the behavior.

fixes https://github.com/zed-industries/zed/issues/7228
fixes https://github.com/zed-industries/zed/issues/5094

Release Notes:

- Added a new `project_panel::Trash` action and changed the default
behavior for `backspace` and `delete` in the project panel to send a
file to the systems trash, instead of permanently deleting it
([#7228](https://github.com/zed-industries/zed/issues/7228),
[#5094](https://github.com/zed-industries/zed/issues/5094)). The
original behavior can be restored by adding the following section to
your keybindings:

```json5
[
// ...Other keybindings...
  {
    "context": "ProjectPanel",
    "bindings": {
        "backspace": "project_panel::Delete",
        "delete": "project_panel::Delete",
    }
  }
]
This commit is contained in:
Mikayla Maki 2024-04-26 17:43:50 -07:00 committed by GitHub
parent 5dbd23f6b0
commit d2569afe66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 99 additions and 22 deletions

2
Cargo.lock generated
View File

@ -4065,6 +4065,7 @@ dependencies = [
"anyhow", "anyhow",
"async-tar", "async-tar",
"async-trait", "async-trait",
"cocoa",
"collections", "collections",
"fsevent", "fsevent",
"futures 0.3.28", "futures 0.3.28",
@ -4074,6 +4075,7 @@ dependencies = [
"lazy_static", "lazy_static",
"libc", "libc",
"notify", "notify",
"objc",
"parking_lot", "parking_lot",
"rope", "rope",
"serde", "serde",

View File

@ -549,8 +549,8 @@
"alt-ctrl-shift-c": "project_panel::CopyRelativePath", "alt-ctrl-shift-c": "project_panel::CopyRelativePath",
"f2": "project_panel::Rename", "f2": "project_panel::Rename",
"enter": "project_panel::Rename", "enter": "project_panel::Rename",
"backspace": "project_panel::Delete", "backspace": "project_panel::Trash",
"delete": "project_panel::Delete", "delete": "project_panel::Trash",
"ctrl-backspace": ["project_panel::Delete", { "skip_prompt": true }], "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": true }],
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": true }], "ctrl-delete": ["project_panel::Delete", { "skip_prompt": true }],
"alt-ctrl-r": "project_panel::RevealInFinder", "alt-ctrl-r": "project_panel::RevealInFinder",

View File

@ -576,8 +576,8 @@
"alt-cmd-shift-c": "project_panel::CopyRelativePath", "alt-cmd-shift-c": "project_panel::CopyRelativePath",
"f2": "project_panel::Rename", "f2": "project_panel::Rename",
"enter": "project_panel::Rename", "enter": "project_panel::Rename",
"backspace": "project_panel::Delete", "backspace": "project_panel::Trash",
"delete": "project_panel::Delete", "delete": "project_panel::Trash",
"cmd-backspace": ["project_panel::Delete", { "skip_prompt": true }], "cmd-backspace": ["project_panel::Delete", { "skip_prompt": true }],
"cmd-delete": ["project_panel::Delete", { "skip_prompt": true }], "cmd-delete": ["project_panel::Delete", { "skip_prompt": true }],
"alt-cmd-r": "project_panel::RevealInFinder", "alt-cmd-r": "project_panel::RevealInFinder",

View File

@ -3190,7 +3190,7 @@ async fn test_fs_operations(
project_b project_b
.update(cx_b, |project, cx| { .update(cx_b, |project, cx| {
project.delete_entry(dir_entry.id, cx).unwrap() project.delete_entry(dir_entry.id, false, cx).unwrap()
}) })
.await .await
.unwrap(); .unwrap();
@ -3218,7 +3218,7 @@ async fn test_fs_operations(
project_b project_b
.update(cx_b, |project, cx| { .update(cx_b, |project, cx| {
project.delete_entry(entry.id, cx).unwrap() project.delete_entry(entry.id, false, cx).unwrap()
}) })
.await .await
.unwrap(); .unwrap();

View File

@ -36,6 +36,9 @@ gpui = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
fsevent.workspace = true fsevent.workspace = true
objc = "0.2"
cocoa = "0.25"
[target.'cfg(not(target_os = "macos"))'.dependencies] [target.'cfg(not(target_os = "macos"))'.dependencies]
notify = "6.1.1" notify = "6.1.1"

View File

@ -49,7 +49,13 @@ pub trait Fs: Send + Sync {
async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()>; 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 rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>;
async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> 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 remove_file(&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 open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>; async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>;
async fn load(&self, path: &Path) -> Result<String>; async fn load(&self, path: &Path) -> Result<String>;
async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>; async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>;
@ -237,6 +243,33 @@ impl Fs for RealFs {
} }
} }
#[cfg(target_os = "macos")]
async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> {
use cocoa::{
base::{id, nil},
foundation::{NSAutoreleasePool, NSString},
};
use objc::{class, msg_send, sel, sel_impl};
unsafe {
unsafe fn ns_string(string: &str) -> id {
NSString::alloc(nil).init_str(string).autorelease()
}
let url: id = msg_send![class!(NSURL), fileURLWithPath: ns_string(path.to_string_lossy().as_ref())];
let array: id = msg_send![class!(NSArray), arrayWithObject: url];
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
let _: id = msg_send![workspace, recycleURLs: array completionHandler: nil];
}
Ok(())
}
#[cfg(target_os = "macos")]
async fn trash_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
self.trash_file(path, options).await
}
async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>> { async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>> {
Ok(Box::new(std::fs::File::open(path)?)) Ok(Box::new(std::fs::File::open(path)?))
} }

View File

@ -1513,6 +1513,7 @@ impl Project {
pub fn delete_entry( pub fn delete_entry(
&mut self, &mut self,
entry_id: ProjectEntryId, entry_id: ProjectEntryId,
trash: bool,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Option<Task<Result<()>>> { ) -> Option<Task<Result<()>>> {
let worktree = self.worktree_for_entry(entry_id, cx)?; let worktree = self.worktree_for_entry(entry_id, cx)?;
@ -1521,7 +1522,10 @@ impl Project {
if self.is_local() { if self.is_local() {
worktree.update(cx, |worktree, cx| { worktree.update(cx, |worktree, cx| {
worktree.as_local_mut().unwrap().delete_entry(entry_id, cx) worktree
.as_local_mut()
.unwrap()
.delete_entry(entry_id, trash, cx)
}) })
} else { } else {
let client = self.client.clone(); let client = self.client.clone();
@ -1531,6 +1535,7 @@ impl Project {
.request(proto::DeleteProjectEntry { .request(proto::DeleteProjectEntry {
project_id, project_id,
entry_id: entry_id.to_proto(), entry_id: entry_id.to_proto(),
use_trash: trash,
}) })
.await?; .await?;
worktree worktree
@ -8341,6 +8346,7 @@ impl Project {
mut cx: AsyncAppContext, mut cx: AsyncAppContext,
) -> Result<proto::ProjectEntryResponse> { ) -> Result<proto::ProjectEntryResponse> {
let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id); let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
let trash = envelope.payload.use_trash;
this.update(&mut cx, |_, cx| cx.emit(Event::DeletedEntry(entry_id)))?; this.update(&mut cx, |_, cx| cx.emit(Event::DeletedEntry(entry_id)))?;
@ -8354,7 +8360,7 @@ impl Project {
worktree worktree
.as_local_mut() .as_local_mut()
.unwrap() .unwrap()
.delete_entry(entry_id, cx) .delete_entry(entry_id, trash, cx)
.ok_or_else(|| anyhow!("invalid entry")) .ok_or_else(|| anyhow!("invalid entry"))
})?? })??
.await?; .await?;

View File

@ -111,7 +111,13 @@ pub struct Delete {
pub skip_prompt: bool, pub skip_prompt: bool,
} }
impl_actions!(project_panel, [Delete]); #[derive(PartialEq, Clone, Default, Debug, Deserialize)]
pub struct Trash {
#[serde(default)]
pub skip_prompt: bool,
}
impl_actions!(project_panel, [Delete, Trash]);
actions!( actions!(
project_panel, project_panel,
@ -880,16 +886,25 @@ impl ProjectPanel {
} }
} }
fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
self.remove(true, action.skip_prompt, cx);
}
fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) { fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
self.remove(false, action.skip_prompt, cx);
}
fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
maybe!({ maybe!({
let Selection { entry_id, .. } = self.selection?; let Selection { entry_id, .. } = self.selection?;
let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path; let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
let file_name = path.file_name()?; let file_name = path.file_name()?;
let answer = (!action.skip_prompt).then(|| { let operation = if trash { "Trash" } else { "Delete" };
let answer = (!skip_prompt).then(|| {
cx.prompt( cx.prompt(
PromptLevel::Destructive, PromptLevel::Destructive,
&format!("Delete {file_name:?}?"), &format!("{operation:?} {file_name:?}?",),
None, None,
&["Delete", "Cancel"], &["Delete", "Cancel"],
) )
@ -903,7 +918,7 @@ impl ProjectPanel {
} }
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.project this.project
.update(cx, |project, cx| project.delete_entry(entry_id, cx)) .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
.ok_or_else(|| anyhow!("no such entry")) .ok_or_else(|| anyhow!("no such entry"))
})?? })??
.await .await
@ -1808,6 +1823,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::new_directory)) .on_action(cx.listener(Self::new_directory))
.on_action(cx.listener(Self::rename)) .on_action(cx.listener(Self::rename))
.on_action(cx.listener(Self::delete)) .on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::trash))
.on_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy)) .on_action(cx.listener(Self::copy))
.on_action(cx.listener(Self::paste)) .on_action(cx.listener(Self::paste))

View File

@ -578,6 +578,7 @@ message CopyProjectEntry {
message DeleteProjectEntry { message DeleteProjectEntry {
uint64 project_id = 1; uint64 project_id = 1;
uint64 entry_id = 2; uint64 entry_id = 2;
bool use_trash = 3;
} }
message ExpandProjectEntry { message ExpandProjectEntry {

View File

@ -1335,6 +1335,7 @@ impl LocalWorktree {
pub fn delete_entry( pub fn delete_entry(
&self, &self,
entry_id: ProjectEntryId, entry_id: ProjectEntryId,
trash: bool,
cx: &mut ModelContext<Worktree>, cx: &mut ModelContext<Worktree>,
) -> Option<Task<Result<()>>> { ) -> Option<Task<Result<()>>> {
let entry = self.entry_for_id(entry_id)?.clone(); let entry = self.entry_for_id(entry_id)?.clone();
@ -1343,16 +1344,31 @@ impl LocalWorktree {
let delete = cx.background_executor().spawn(async move { let delete = cx.background_executor().spawn(async move {
if entry.is_file() { if entry.is_file() {
fs.remove_file(&abs_path?, Default::default()).await?; if trash {
fs.trash_file(&abs_path?, Default::default()).await?;
} else {
fs.remove_file(&abs_path?, Default::default()).await?;
}
} else { } else {
fs.remove_dir( if trash {
&abs_path?, fs.trash_dir(
RemoveOptions { &abs_path?,
recursive: true, RemoveOptions {
ignore_if_not_exists: false, recursive: true,
}, ignore_if_not_exists: false,
) },
.await?; )
.await?;
} else {
fs.remove_dir(
&abs_path?,
RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await?;
}
} }
anyhow::Ok(entry.path) anyhow::Ok(entry.path)
}); });

View File

@ -1764,7 +1764,7 @@ fn randomly_mutate_worktree(
match rng.gen_range(0_u32..100) { match rng.gen_range(0_u32..100) {
0..=33 if entry.path.as_ref() != Path::new("") => { 0..=33 if entry.path.as_ref() != Path::new("") => {
log::info!("deleting entry {:?} ({})", entry.path, entry.id.0); log::info!("deleting entry {:?} ({})", entry.path, entry.id.0);
worktree.delete_entry(entry.id, cx).unwrap() worktree.delete_entry(entry.id, false, cx).unwrap()
} }
..=66 if entry.path.as_ref() != Path::new("") => { ..=66 if entry.path.as_ref() != Path::new("") => {
let other_entry = snapshot.entries(false).choose(rng).unwrap(); let other_entry = snapshot.entries(false).choose(rng).unwrap();