Allow deleting entries from the project panel

Max Brunsfeld created

Change summary

Cargo.lock                                |  2 
assets/keymaps/default.json               |  3 
crates/collab/src/rpc.rs                  | 53 ++++++++++++++++++
crates/project/src/project.rs             | 45 +++++++++++++++
crates/project/src/worktree.rs            | 72 ++++++++++++++++++++++++
crates/project_panel/Cargo.toml           |  2 
crates/project_panel/src/project_panel.rs | 25 +++++++-
crates/rpc/proto/zed.proto                |  3 
crates/rpc/src/proto.rs                   |  1 
9 files changed, 198 insertions(+), 8 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3330,7 +3330,9 @@ name = "project_panel"
 version = "0.1.0"
 dependencies = [
  "editor",
+ "futures",
  "gpui",
+ "postage",
  "project",
  "serde_json",
  "settings",

assets/keymaps/default.json 🔗

@@ -332,7 +332,8 @@
         "bindings": {
             "left": "project_panel::CollapseSelectedEntry",
             "right": "project_panel::ExpandSelectedEntry",
-            "f2": "project_panel::Rename"
+            "f2": "project_panel::Rename",
+            "backspace": "project_panel::Delete"
         }
     }
 ]

crates/collab/src/rpc.rs 🔗

@@ -128,6 +128,7 @@ impl Server {
             .add_request_handler(Server::forward_project_request::<proto::FormatBuffers>)
             .add_request_handler(Server::forward_project_request::<proto::CreateProjectEntry>)
             .add_request_handler(Server::forward_project_request::<proto::RenameProjectEntry>)
+            .add_request_handler(Server::forward_project_request::<proto::DeleteProjectEntry>)
             .add_request_handler(Server::update_buffer)
             .add_message_handler(Server::update_buffer_file)
             .add_message_handler(Server::buffer_reloaded)
@@ -1900,7 +1901,7 @@ mod tests {
             );
         });
 
-        project_b
+        let dir_entry = project_b
             .update(cx_b, |project, cx| {
                 project
                     .create_entry((worktree_id, "DIR"), true, cx)
@@ -1926,6 +1927,56 @@ mod tests {
                 [".zed.toml", "DIR", "a.txt", "b.txt", "d.txt"]
             );
         });
+
+        project_b
+            .update(cx_b, |project, cx| {
+                project.delete_entry(dir_entry.id, cx).unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+
+        project_b
+            .update(cx_b, |project, cx| {
+                project.delete_entry(entry.id, cx).unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt"]
+            );
+        });
     }
 
     #[gpui::test(iterations = 10)]

crates/project/src/project.rs 🔗

@@ -263,6 +263,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_update_worktree);
         client.add_model_request_handler(Self::handle_create_project_entry);
         client.add_model_request_handler(Self::handle_rename_project_entry);
+        client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_apply_additional_edits_for_completion);
         client.add_model_request_handler(Self::handle_apply_code_action);
         client.add_model_request_handler(Self::handle_reload_buffers);
@@ -768,6 +769,35 @@ impl Project {
         }
     }
 
+    pub fn delete_entry(
+        &mut self,
+        entry_id: ProjectEntryId,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        if self.is_local() {
+            worktree.update(cx, |worktree, cx| {
+                worktree.as_local_mut().unwrap().delete_entry(entry_id, cx)
+            })
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                client
+                    .request(proto::DeleteProjectEntry {
+                        project_id,
+                        entry_id: entry_id.to_proto(),
+                    })
+                    .await?;
+                worktree
+                    .update(&mut cx, move |worktree, cx| {
+                        worktree.as_remote().unwrap().delete_entry(entry_id, cx)
+                    })
+                    .await
+            }))
+        }
+    }
+
     pub fn can_share(&self, cx: &AppContext) -> bool {
         self.is_local() && self.visible_worktrees(cx).next().is_some()
     }
@@ -3858,6 +3888,21 @@ impl Project {
         })
     }
 
+    async fn handle_delete_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::DeleteProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::Ack> {
+        this.update(&mut cx, |this, cx| {
+            let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+            this.delete_entry(entry_id, cx)
+                .ok_or_else(|| anyhow!("invalid entry"))
+        })?
+        .await?;
+        Ok(proto::Ack {})
+    }
+
     async fn handle_update_diagnostic_summary(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::UpdateDiagnosticSummary>,

crates/project/src/worktree.rs 🔗

@@ -1,4 +1,4 @@
-use crate::ProjectEntryId;
+use crate::{ProjectEntryId, RemoveOptions};
 
 use super::{
     fs::{self, Fs},
@@ -712,6 +712,44 @@ impl LocalWorktree {
         self.write_entry_internal(path, Some(text), cx)
     }
 
+    pub fn delete_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<()>>> {
+        let entry = self.entry_for_id(entry_id)?.clone();
+        let abs_path = self.absolutize(&entry.path);
+        let delete = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_path = abs_path.clone();
+            async move {
+                if entry.is_file() {
+                    fs.remove_file(&abs_path, Default::default()).await
+                } else {
+                    fs.remove_dir(
+                        &abs_path,
+                        RemoveOptions {
+                            recursive: true,
+                            ignore_if_not_exists: false,
+                        },
+                    )
+                    .await
+                }
+            }
+        });
+
+        Some(cx.spawn(|this, mut cx| async move {
+            delete.await?;
+            this.update(&mut cx, |this, _| {
+                let this = this.as_local_mut().unwrap();
+                let mut snapshot = this.background_snapshot.lock();
+                snapshot.delete_entry(entry_id);
+            });
+            this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
+            Ok(())
+        }))
+    }
+
     pub fn rename_entry(
         &self,
         entry_id: ProjectEntryId,
@@ -1019,6 +1057,29 @@ impl RemoteWorktree {
             })
         })
     }
+
+    pub(crate) fn delete_entry(
+        &self,
+        id: ProjectEntryId,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<()>> {
+        cx.spawn(|this, mut cx| async move {
+            this.update(&mut cx, |worktree, _| {
+                worktree
+                    .as_remote_mut()
+                    .unwrap()
+                    .finish_pending_remote_updates()
+            })
+            .await;
+            this.update(&mut cx, |worktree, _| {
+                let worktree = worktree.as_remote_mut().unwrap();
+                let mut snapshot = worktree.background_snapshot.lock();
+                snapshot.delete_entry(id);
+                worktree.snapshot = snapshot.clone();
+            });
+            Ok(())
+        })
+    }
 }
 
 impl Snapshot {
@@ -1048,6 +1109,15 @@ impl Snapshot {
         Ok(entry)
     }
 
+    fn delete_entry(&mut self, entry_id: ProjectEntryId) -> bool {
+        if let Some(entry) = self.entries_by_id.remove(&entry_id, &()) {
+            self.entries_by_path.remove(&PathKey(entry.path), &());
+            true
+        } else {
+            false
+        }
+    }
+
     pub(crate) fn apply_remote_update(&mut self, update: proto::UpdateWorktree) -> Result<()> {
         let mut entries_by_path_edits = Vec::new();
         let mut entries_by_id_edits = Vec::new();

crates/project_panel/Cargo.toml 🔗

@@ -15,6 +15,8 @@ settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
+postage = { version = "0.4.1", features = ["futures-traits"] }
+futures = "0.3"
 unicase = "2.6"
 
 [dev-dependencies]

crates/project_panel/src/project_panel.rs 🔗

@@ -1,15 +1,16 @@
 use editor::{Cancel, Editor};
+use futures::stream::StreamExt;
 use gpui::{
     actions,
-    anyhow::Result,
+    anyhow::{anyhow, Result},
     elements::{
         ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
         ScrollTarget, Svg, UniformList, UniformListState,
     },
     impl_internal_actions, keymap,
     platform::CursorStyle,
-    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, PromptLevel, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
@@ -77,6 +78,7 @@ actions!(
         CollapseSelectedEntry,
         AddDirectory,
         AddFile,
+        Delete,
         Rename
     ]
 );
@@ -92,6 +94,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ProjectPanel::add_file);
     cx.add_action(ProjectPanel::add_directory);
     cx.add_action(ProjectPanel::rename);
+    cx.add_async_action(ProjectPanel::delete);
     cx.add_async_action(ProjectPanel::confirm);
     cx.add_action(ProjectPanel::cancel);
 }
@@ -432,6 +435,22 @@ impl ProjectPanel {
         }
     }
 
+    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+        let Selection { entry_id, .. } = self.selection?;
+        let mut answer = cx.prompt(PromptLevel::Info, "Delete?", &["Delete", "Cancel"]);
+        Some(cx.spawn(|this, mut cx| async move {
+            if answer.next().await != Some(0) {
+                return Ok(());
+            }
+            this.update(&mut cx, |this, cx| {
+                this.project
+                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
+                    .ok_or_else(|| anyhow!("no such entry"))
+            })?
+            .await
+        }))
+    }
+
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
         if let Some(selection) = self.selection {
             let (mut worktree_ix, mut entry_ix, _) =

crates/rpc/proto/zed.proto 🔗

@@ -179,8 +179,7 @@ message RenameProjectEntry {
 
 message DeleteProjectEntry {
     uint64 project_id = 1;
-    uint64 worktree_id = 2;
-    string path = 3;
+    uint64 entry_id = 2;
 }
 
 message ProjectEntryResponse {

crates/rpc/src/proto.rs 🔗

@@ -225,6 +225,7 @@ request_messages!(
         ApplyCompletionAdditionalEditsResponse
     ),
     (CreateProjectEntry, ProjectEntryResponse),
+    (DeleteProjectEntry, Ack),
     (Follow, FollowResponse),
     (FormatBuffers, FormatBuffersResponse),
     (GetChannelMessages, GetChannelMessagesResponse),