When the file is deleted via project panel, close it in editors (#2490)

Kirill Bulatov created

Deals with https://github.com/zed-industries/community/issues/179 by
sending a message about it, to asynchronously apply on all workspaces.

Release Notes:

* Fixes a bug when files, deleted in the project panel were left open in
the editor

Change summary

crates/gpui/src/app/test_app_context.rs   |   2 
crates/project/src/project.rs             |   7 
crates/project_panel/src/project_panel.rs | 180 +++++++++++++++++++++++++
crates/workspace/src/pane.rs              |  28 +++
crates/workspace/src/workspace.rs         |   8 +
5 files changed, 224 insertions(+), 1 deletion(-)

Detailed changes

crates/gpui/src/app/test_app_context.rs 🔗

@@ -270,7 +270,7 @@ impl TestAppContext {
             .borrow_mut()
             .pop_front()
             .expect("prompt was not called");
-        let _ = done_tx.try_send(answer);
+        done_tx.try_send(answer).ok();
     }
 
     pub fn has_pending_prompt(&self, window_id: usize) -> bool {

crates/project/src/project.rs 🔗

@@ -212,6 +212,7 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
     Closed,
+    DeletedEntry(ProjectEntryId),
     CollaboratorUpdated {
         old_peer_id: proto::PeerId,
         new_peer_id: proto::PeerId,
@@ -976,6 +977,9 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let worktree = self.worktree_for_entry(entry_id, cx)?;
+
+        cx.emit(Event::DeletedEntry(entry_id));
+
         if self.is_local() {
             worktree.update(cx, |worktree, cx| {
                 worktree.as_local_mut().unwrap().delete_entry(entry_id, cx)
@@ -5188,6 +5192,9 @@ impl Project {
         mut cx: AsyncAppContext,
     ) -> Result<proto::ProjectEntryResponse> {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+
+        this.update(&mut cx, |_, cx| cx.emit(Event::DeletedEntry(entry_id)));
+
         let worktree = this.read_with(&cx, |this, cx| {
             this.worktree_for_entry(entry_id, cx)
                 .ok_or_else(|| anyhow!("worktree not found"))

crates/project_panel/src/project_panel.rs 🔗

@@ -1375,6 +1375,7 @@ mod tests {
     use serde_json::json;
     use settings::SettingsStore;
     use std::{collections::HashSet, path::Path};
+    use workspace::{pane, AppState};
 
     #[gpui::test]
     async fn test_visible_list(cx: &mut gpui::TestAppContext) {
@@ -1850,6 +1851,95 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/src",
+            json!({
+                "test": {
+                    "first.rs": "// First Rust file",
+                    "second.rs": "// Second Rust file",
+                    "third.rs": "// Third Rust file",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        toggle_expand_dir(&panel, "src/test", cx);
+        select_path(&panel, "src/test/first.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          first.rs  <== selected",
+                "          second.rs",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
+
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs",
+                "          third.rs"
+            ],
+            "Project panel should have no deleted file, no other file is selected in it"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+
+        select_path(&panel, "src/test/second.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs  <== selected",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
+
+        cx.update_window(window_id, |cx| {
+            let active_items = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item())
+                .collect::<Vec<_>>();
+            assert_eq!(active_items.len(), 1);
+            let open_editor = active_items
+                .into_iter()
+                .next()
+                .unwrap()
+                .downcast::<Editor>()
+                .expect("Open item should be an editor");
+            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
+        });
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &["v src", "    v test", "          third.rs"],
+            "Project panel should have no deleted file, with one last file remaining"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+    }
+
     fn toggle_expand_dir(
         panel: &ViewHandle<ProjectPanel>,
         path: impl AsRef<Path>,
@@ -1953,4 +2043,94 @@ mod tests {
             workspace::init_settings(cx);
         });
     }
+
+    fn init_test_with_editor(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            let app_state = AppState::test(cx);
+            theme::init((), cx);
+            language::init(cx);
+            editor::init(cx);
+            pane::init(cx);
+            workspace::init(app_state.clone(), cx);
+        });
+    }
+
+    fn ensure_single_file_is_opened(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        expected_path: &str,
+        cx: &mut TestAppContext,
+    ) {
+        cx.read_window(window_id, |cx| {
+            let workspace = workspace.read(cx);
+            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
+
+            let open_project_paths = workspace
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert_eq!(
+                open_project_paths,
+                vec![ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new(expected_path))
+                }],
+                "Should have opened file, selected in project panel"
+            );
+        });
+    }
+
+    fn submit_deletion(
+        window_id: usize,
+        panel: &ViewHandle<ProjectPanel>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts before the deletion"
+        );
+        panel.update(cx, |panel, cx| {
+            panel
+                .delete(&Delete, cx)
+                .expect("Deletion start")
+                .detach_and_log_err(cx);
+        });
+        assert!(
+            cx.has_pending_prompt(window_id),
+            "Should have a prompt after the deletion"
+        );
+        cx.simulate_prompt_answer(window_id, 0);
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after prompt was replied to"
+        );
+        cx.foreground().run_until_parked();
+    }
+
+    fn ensure_no_open_items_and_panes(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after deletion operation closes the file"
+        );
+        cx.read_window(window_id, |cx| {
+            let open_project_paths = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert!(
+                open_project_paths.is_empty(),
+                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
+            );
+        });
+    }
 }

crates/workspace/src/pane.rs 🔗

@@ -1305,6 +1305,25 @@ impl Pane {
         &self.toolbar
     }
 
+    pub fn handle_deleted_project_item(
+        &mut self,
+        entry_id: ProjectEntryId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Option<()> {
+        let (item_index_to_delete, item_id) = self.items().enumerate().find_map(|(i, item)| {
+            if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] {
+                Some((i, item.id()))
+            } else {
+                None
+            }
+        })?;
+
+        self.remove_item(item_index_to_delete, false, cx);
+        self.nav_history.borrow_mut().remove_item(item_id);
+
+        Some(())
+    }
+
     fn update_toolbar(&mut self, cx: &mut ViewContext<Self>) {
         let active_item = self
             .items
@@ -2007,6 +2026,15 @@ impl NavHistory {
             });
         }
     }
+
+    fn remove_item(&mut self, item_id: usize) {
+        self.paths_by_item.remove(&item_id);
+        self.backward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.forward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.closed_stack.retain(|entry| entry.item.id() != item_id);
+    }
 }
 
 impl PaneNavHistory {

crates/workspace/src/workspace.rs 🔗

@@ -537,6 +537,14 @@ impl Workspace {
                     cx.remove_window();
                 }
 
+                project::Event::DeletedEntry(entry_id) => {
+                    for pane in this.panes.iter() {
+                        pane.update(cx, |pane, cx| {
+                            pane.handle_deleted_project_item(*entry_id, cx)
+                        });
+                    }
+                }
+
                 _ => {}
             }
             cx.notify()