vim: Add ZZ and ZQ (#2950)

Conrad Irwin created

The major change here is a refactoring to allow controling the save
behaviour when closing items, which is pre-work needed for vim command
palette.

For zed-industries/community#1868

Release Notes:

- vim: Add `ZZ` and `ZQ` to close the current item.
([#1868](https://github.com/zed-industries/community/issues/1868))

Change summary

assets/keymaps/vim.json                   |  12 +
crates/file_finder/src/file_finder.rs     |   9 
crates/terminal_view/src/terminal_view.rs |   7 
crates/workspace/src/item.rs              |  10 +
crates/workspace/src/pane.rs              | 198 ++++++++++++++++++------
crates/workspace/src/workspace.rs         |  31 ++-
crates/zed/src/menus.rs                   |   7 
crates/zed/src/zed.rs                     |  52 ++++--
8 files changed, 244 insertions(+), 82 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -198,6 +198,18 @@
       "z c": "editor::Fold",
       "z o": "editor::UnfoldLines",
       "z f": "editor::FoldSelectedRanges",
+      "shift-z shift-q": [
+        "pane::CloseActiveItem",
+        {
+          "saveBehavior": "dontSave"
+        }
+      ],
+      "shift-z shift-z": [
+        "pane::CloseActiveItem",
+        {
+          "saveBehavior": "promptOnConflict"
+        }
+      ],
       // Count support
       "1": [
         "vim::Number",

crates/file_finder/src/file_finder.rs 🔗

@@ -1528,8 +1528,13 @@ mod tests {
         let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
         active_pane
             .update(cx, |pane, cx| {
-                pane.close_active_item(&workspace::CloseActiveItem, cx)
-                    .unwrap()
+                pane.close_active_item(
+                    &workspace::CloseActiveItem {
+                        save_behavior: None,
+                    },
+                    cx,
+                )
+                .unwrap()
             })
             .await
             .unwrap();

crates/terminal_view/src/terminal_view.rs 🔗

@@ -283,7 +283,12 @@ impl TerminalView {
     pub fn deploy_context_menu(&mut self, position: Vector2F, cx: &mut ViewContext<Self>) {
         let menu_entries = vec![
             ContextMenuItem::action("Clear", Clear),
-            ContextMenuItem::action("Close", pane::CloseActiveItem),
+            ContextMenuItem::action(
+                "Close",
+                pane::CloseActiveItem {
+                    save_behavior: None,
+                },
+            ),
         ];
 
         self.context_menu.update(cx, |menu, cx| {

crates/workspace/src/item.rs 🔗

@@ -474,8 +474,14 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                     for item_event in T::to_item_events(event).into_iter() {
                         match item_event {
                             ItemEvent::CloseItem => {
-                                pane.update(cx, |pane, cx| pane.close_item_by_id(item.id(), cx))
-                                    .detach_and_log_err(cx);
+                                pane.update(cx, |pane, cx| {
+                                    pane.close_item_by_id(
+                                        item.id(),
+                                        crate::SaveBehavior::PromptOnWrite,
+                                        cx,
+                                    )
+                                })
+                                .detach_and_log_err(cx);
                                 return;
                             }
 

crates/workspace/src/pane.rs 🔗

@@ -43,6 +43,19 @@ use std::{
 };
 use theme::{Theme, ThemeSettings};
 
+#[derive(PartialEq, Clone, Copy, Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub enum SaveBehavior {
+    /// ask before overwriting conflicting files (used by default with %s)
+    PromptOnConflict,
+    /// ask before writing any file that wouldn't be auto-saved (used by default with %w)
+    PromptOnWrite,
+    /// never prompt, write on conflict (used with vim's :w!)
+    SilentlyOverwrite,
+    /// skip all save-related behaviour (used with vim's :cq)
+    DontSave,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct ActivateItem(pub usize);
 
@@ -64,13 +77,17 @@ pub struct CloseItemsToTheRightById {
     pub pane: WeakViewHandle<Pane>,
 }
 
+#[derive(Clone, PartialEq, Debug, Deserialize, Default)]
+pub struct CloseActiveItem {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
 actions!(
     pane,
     [
         ActivatePrevItem,
         ActivateNextItem,
         ActivateLastItem,
-        CloseActiveItem,
         CloseInactiveItems,
         CloseCleanItems,
         CloseItemsToTheLeft,
@@ -86,7 +103,7 @@ actions!(
     ]
 );
 
-impl_actions!(pane, [ActivateItem]);
+impl_actions!(pane, [ActivateItem, CloseActiveItem]);
 
 const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
@@ -696,22 +713,29 @@ impl Pane {
 
     pub fn close_active_item(
         &mut self,
-        _: &CloseActiveItem,
+        action: &CloseActiveItem,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         if self.items.is_empty() {
             return None;
         }
         let active_item_id = self.items[self.active_item_index].id();
-        Some(self.close_item_by_id(active_item_id, cx))
+        Some(self.close_item_by_id(
+            active_item_id,
+            action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite),
+            cx,
+        ))
     }
 
     pub fn close_item_by_id(
         &mut self,
         item_id_to_close: usize,
+        save_behavior: SaveBehavior,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        self.close_items(cx, move |view_id| view_id == item_id_to_close)
+        self.close_items(cx, save_behavior, move |view_id| {
+            view_id == item_id_to_close
+        })
     }
 
     pub fn close_inactive_items(
@@ -724,7 +748,11 @@ impl Pane {
         }
 
         let active_item_id = self.items[self.active_item_index].id();
-        Some(self.close_items(cx, move |item_id| item_id != active_item_id))
+        Some(
+            self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| {
+                item_id != active_item_id
+            }),
+        )
     }
 
     pub fn close_clean_items(
@@ -737,7 +765,11 @@ impl Pane {
             .filter(|item| !item.is_dirty(cx))
             .map(|item| item.id())
             .collect();
-        Some(self.close_items(cx, move |item_id| item_ids.contains(&item_id)))
+        Some(
+            self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| {
+                item_ids.contains(&item_id)
+            }),
+        )
     }
 
     pub fn close_items_to_the_left(
@@ -762,7 +794,9 @@ impl Pane {
             .take_while(|item| item.id() != item_id)
             .map(|item| item.id())
             .collect();
-        self.close_items(cx, move |item_id| item_ids.contains(&item_id))
+        self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| {
+            item_ids.contains(&item_id)
+        })
     }
 
     pub fn close_items_to_the_right(
@@ -788,7 +822,9 @@ impl Pane {
             .take_while(|item| item.id() != item_id)
             .map(|item| item.id())
             .collect();
-        self.close_items(cx, move |item_id| item_ids.contains(&item_id))
+        self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| {
+            item_ids.contains(&item_id)
+        })
     }
 
     pub fn close_all_items(
@@ -800,12 +836,13 @@ impl Pane {
             return None;
         }
 
-        Some(self.close_items(cx, move |_| true))
+        Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true))
     }
 
     pub fn close_items(
         &mut self,
         cx: &mut ViewContext<Pane>,
+        save_behavior: SaveBehavior,
         should_close: impl 'static + Fn(usize) -> bool,
     ) -> Task<Result<()>> {
         // Find the items to close.
@@ -858,8 +895,15 @@ impl Pane {
                     .any(|id| saved_project_items_ids.insert(*id));
 
                 if should_save
-                    && !Self::save_item(project.clone(), &pane, item_ix, &*item, true, &mut cx)
-                        .await?
+                    && !Self::save_item(
+                        project.clone(),
+                        &pane,
+                        item_ix,
+                        &*item,
+                        save_behavior,
+                        &mut cx,
+                    )
+                    .await?
                 {
                     break;
                 }
@@ -954,13 +998,17 @@ impl Pane {
         pane: &WeakViewHandle<Pane>,
         item_ix: usize,
         item: &dyn ItemHandle,
-        should_prompt_for_save: bool,
+        save_behavior: SaveBehavior,
         cx: &mut AsyncAppContext,
     ) -> Result<bool> {
         const CONFLICT_MESSAGE: &str =
             "This file has changed on disk since you started editing it. Do you want to overwrite it?";
         const DIRTY_MESSAGE: &str = "This file contains unsaved edits. Do you want to save it?";
 
+        if save_behavior == SaveBehavior::DontSave {
+            return Ok(true);
+        }
+
         let (has_conflict, is_dirty, can_save, is_singleton) = cx.read(|cx| {
             (
                 item.has_conflict(cx),
@@ -971,18 +1019,22 @@ impl Pane {
         });
 
         if has_conflict && can_save {
-            let mut answer = pane.update(cx, |pane, cx| {
-                pane.activate_item(item_ix, true, true, cx);
-                cx.prompt(
-                    PromptLevel::Warning,
-                    CONFLICT_MESSAGE,
-                    &["Overwrite", "Discard", "Cancel"],
-                )
-            })?;
-            match answer.next().await {
-                Some(0) => pane.update(cx, |_, cx| item.save(project, cx))?.await?,
-                Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?,
-                _ => return Ok(false),
+            if save_behavior == SaveBehavior::SilentlyOverwrite {
+                pane.update(cx, |_, cx| item.save(project, cx))?.await?;
+            } else {
+                let mut answer = pane.update(cx, |pane, cx| {
+                    pane.activate_item(item_ix, true, true, cx);
+                    cx.prompt(
+                        PromptLevel::Warning,
+                        CONFLICT_MESSAGE,
+                        &["Overwrite", "Discard", "Cancel"],
+                    )
+                })?;
+                match answer.next().await {
+                    Some(0) => pane.update(cx, |_, cx| item.save(project, cx))?.await?,
+                    Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?,
+                    _ => return Ok(false),
+                }
             }
         } else if is_dirty && (can_save || is_singleton) {
             let will_autosave = cx.read(|cx| {
@@ -991,7 +1043,7 @@ impl Pane {
                     AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange
                 ) && Self::can_autosave_item(&*item, cx)
             });
-            let should_save = if should_prompt_for_save && !will_autosave {
+            let should_save = if save_behavior == SaveBehavior::PromptOnWrite && !will_autosave {
                 let mut answer = pane.update(cx, |pane, cx| {
                     pane.activate_item(item_ix, true, true, cx);
                     cx.prompt(
@@ -1113,7 +1165,12 @@ impl Pane {
                 AnchorCorner::TopLeft,
                 if is_active_item {
                     vec![
-                        ContextMenuItem::action("Close Active Item", CloseActiveItem),
+                        ContextMenuItem::action(
+                            "Close Active Item",
+                            CloseActiveItem {
+                                save_behavior: None,
+                            },
+                        ),
                         ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
                         ContextMenuItem::action("Close Clean Items", CloseCleanItems),
                         ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft),
@@ -1128,8 +1185,12 @@ impl Pane {
                             move |cx| {
                                 if let Some(pane) = pane.upgrade(cx) {
                                     pane.update(cx, |pane, cx| {
-                                        pane.close_item_by_id(target_item_id, cx)
-                                            .detach_and_log_err(cx);
+                                        pane.close_item_by_id(
+                                            target_item_id,
+                                            SaveBehavior::PromptOnWrite,
+                                            cx,
+                                        )
+                                        .detach_and_log_err(cx);
                                     })
                                 }
                             }
@@ -1278,7 +1339,12 @@ impl Pane {
                                 .on_click(MouseButton::Middle, {
                                     let item_id = item.id();
                                     move |_, pane, cx| {
-                                        pane.close_item_by_id(item_id, cx).detach_and_log_err(cx);
+                                        pane.close_item_by_id(
+                                            item_id,
+                                            SaveBehavior::PromptOnWrite,
+                                            cx,
+                                        )
+                                        .detach_and_log_err(cx);
                                     }
                                 })
                                 .on_down(
@@ -1486,7 +1552,8 @@ impl Pane {
                     cx.window_context().defer(move |cx| {
                         if let Some(pane) = pane.upgrade(cx) {
                             pane.update(cx, |pane, cx| {
-                                pane.close_item_by_id(item_id, cx).detach_and_log_err(cx);
+                                pane.close_item_by_id(item_id, SaveBehavior::PromptOnWrite, cx)
+                                    .detach_and_log_err(cx);
                             });
                         }
                     });
@@ -2089,7 +2156,14 @@ mod tests {
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         pane.update(cx, |pane, cx| {
-            assert!(pane.close_active_item(&CloseActiveItem, cx).is_none())
+            assert!(pane
+                .close_active_item(
+                    &CloseActiveItem {
+                        save_behavior: None
+                    },
+                    cx
+                )
+                .is_none())
         });
     }
 
@@ -2339,31 +2413,59 @@ mod tests {
         add_labeled_item(&pane, "1", false, cx);
         assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx);
 
-        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
-            .unwrap()
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_active_item(
+                &CloseActiveItem {
+                    save_behavior: None,
+                },
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
         assert_item_labels(&pane, ["A", "B*", "C", "D"], cx);
 
         pane.update(cx, |pane, cx| pane.activate_item(3, false, false, cx));
         assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
 
-        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
-            .unwrap()
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_active_item(
+                &CloseActiveItem {
+                    save_behavior: None,
+                },
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
         assert_item_labels(&pane, ["A", "B*", "C"], cx);
 
-        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
-            .unwrap()
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_active_item(
+                &CloseActiveItem {
+                    save_behavior: None,
+                },
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
         assert_item_labels(&pane, ["A", "C*"], cx);
 
-        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
-            .unwrap()
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_active_item(
+                &CloseActiveItem {
+                    save_behavior: None,
+                },
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
         assert_item_labels(&pane, ["A*"], cx);
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -1308,13 +1308,15 @@ impl Workspace {
             }
 
             Ok(this
-                .update(&mut cx, |this, cx| this.save_all_internal(true, cx))?
+                .update(&mut cx, |this, cx| {
+                    this.save_all_internal(SaveBehavior::PromptOnWrite, cx)
+                })?
                 .await?)
         })
     }
 
     fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
-        let save_all = self.save_all_internal(false, cx);
+        let save_all = self.save_all_internal(SaveBehavior::PromptOnConflict, cx);
         Some(cx.foreground().spawn(async move {
             save_all.await?;
             Ok(())
@@ -1323,7 +1325,7 @@ impl Workspace {
 
     fn save_all_internal(
         &mut self,
-        should_prompt_to_save: bool,
+        save_behaviour: SaveBehavior,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<bool>> {
         if self.project.read(cx).is_read_only() {
@@ -1358,7 +1360,7 @@ impl Workspace {
                             &pane,
                             ix,
                             &*item,
-                            should_prompt_to_save,
+                            save_behaviour,
                             &mut cx,
                         )
                         .await?
@@ -4358,7 +4360,9 @@ mod tests {
             let item1_id = item1.id();
             let item3_id = item3.id();
             let item4_id = item4.id();
-            pane.close_items(cx, move |id| [item1_id, item3_id, item4_id].contains(&id))
+            pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| {
+                [item1_id, item3_id, item4_id].contains(&id)
+            })
         });
         cx.foreground().run_until_parked();
 
@@ -4493,7 +4497,9 @@ mod tests {
         // once for project entry 0, and once for project entry 2. After those two
         // prompts, the task should complete.
 
-        let close = left_pane.update(cx, |pane, cx| pane.close_items(cx, |_| true));
+        let close = left_pane.update(cx, |pane, cx| {
+            pane.close_items(cx, SaveBehavior::PromptOnWrite, move |_| true)
+        });
         cx.foreground().run_until_parked();
         left_pane.read_with(cx, |pane, cx| {
             assert_eq!(
@@ -4609,9 +4615,11 @@ mod tests {
             item.is_dirty = true;
         });
 
-        pane.update(cx, |pane, cx| pane.close_items(cx, move |id| id == item_id))
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id)
+        })
+        .await
+        .unwrap();
         assert!(!window.has_pending_prompt(cx));
         item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));
 
@@ -4630,8 +4638,9 @@ mod tests {
         item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));
 
         // Ensure autosave is prevented for deleted files also when closing the buffer.
-        let _close_items =
-            pane.update(cx, |pane, cx| pane.close_items(cx, move |id| id == item_id));
+        let _close_items = pane.update(cx, |pane, cx| {
+            pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id)
+        });
         deterministic.run_until_parked();
         assert!(window.has_pending_prompt(cx));
         item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));

crates/zed/src/menus.rs 🔗

@@ -41,7 +41,12 @@ pub fn menus() -> Vec<Menu<'static>> {
                 MenuItem::action("Save", workspace::Save),
                 MenuItem::action("Save As…", workspace::SaveAs),
                 MenuItem::action("Save All", workspace::SaveAll),
-                MenuItem::action("Close Editor", workspace::CloseActiveItem),
+                MenuItem::action(
+                    "Close Editor",
+                    workspace::CloseActiveItem {
+                        save_behavior: None,
+                    },
+                ),
                 MenuItem::action("Close Window", workspace::CloseWindow),
             ],
         },

crates/zed/src/zed.rs 🔗

@@ -733,7 +733,7 @@ mod tests {
     use theme::{ThemeRegistry, ThemeSettings};
     use workspace::{
         item::{Item, ItemHandle},
-        open_new, open_paths, pane, NewFile, SplitDirection, WorkspaceHandle,
+        open_new, open_paths, pane, NewFile, SaveBehavior, SplitDirection, WorkspaceHandle,
     };
 
     #[gpui::test]
@@ -1495,7 +1495,12 @@ mod tests {
 
             pane2_item.downcast::<Editor>().unwrap().downgrade()
         });
-        cx.dispatch_action(window.into(), workspace::CloseActiveItem);
+        cx.dispatch_action(
+            window.into(),
+            workspace::CloseActiveItem {
+                save_behavior: None,
+            },
+        );
 
         cx.foreground().run_until_parked();
         workspace.read_with(cx, |workspace, _| {
@@ -1503,7 +1508,12 @@ mod tests {
             assert_eq!(workspace.active_pane(), &pane_1);
         });
 
-        cx.dispatch_action(window.into(), workspace::CloseActiveItem);
+        cx.dispatch_action(
+            window.into(),
+            workspace::CloseActiveItem {
+                save_behavior: None,
+            },
+        );
         cx.foreground().run_until_parked();
         window.simulate_prompt_answer(1, cx);
         cx.foreground().run_until_parked();
@@ -1661,7 +1671,7 @@ mod tests {
         pane.update(cx, |pane, cx| {
             let editor3_id = editor3.id();
             drop(editor3);
-            pane.close_item_by_id(editor3_id, cx)
+            pane.close_item_by_id(editor3_id, SaveBehavior::PromptOnWrite, cx)
         })
         .await
         .unwrap();
@@ -1696,7 +1706,7 @@ mod tests {
         pane.update(cx, |pane, cx| {
             let editor2_id = editor2.id();
             drop(editor2);
-            pane.close_item_by_id(editor2_id, cx)
+            pane.close_item_by_id(editor2_id, SaveBehavior::PromptOnWrite, cx)
         })
         .await
         .unwrap();
@@ -1852,24 +1862,32 @@ mod tests {
         assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
 
         // Close all the pane items in some arbitrary order.
-        pane.update(cx, |pane, cx| pane.close_item_by_id(file1_item_id, cx))
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_item_by_id(file1_item_id, SaveBehavior::PromptOnWrite, cx)
+        })
+        .await
+        .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
 
-        pane.update(cx, |pane, cx| pane.close_item_by_id(file4_item_id, cx))
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_item_by_id(file4_item_id, SaveBehavior::PromptOnWrite, cx)
+        })
+        .await
+        .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
 
-        pane.update(cx, |pane, cx| pane.close_item_by_id(file2_item_id, cx))
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_item_by_id(file2_item_id, SaveBehavior::PromptOnWrite, cx)
+        })
+        .await
+        .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
 
-        pane.update(cx, |pane, cx| pane.close_item_by_id(file3_item_id, cx))
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            pane.close_item_by_id(file3_item_id, SaveBehavior::PromptOnWrite, cx)
+        })
+        .await
+        .unwrap();
         assert_eq!(active_path(&workspace, cx), None);
 
         // Reopen all the closed items, ensuring they are reopened in the same order