Add vim-specific interactions to command

Conrad Irwin created

This mostly adds the commonly requested set (:wq and friends) and
a few that I use frequently
:<line> to go to a line number
:vsp / :sp to create a split
:cn / :cp to go to diagnostics

Change summary

assets/keymaps/vim.json                       |   1 
crates/command_palette/src/command_palette.rs |  47 ++++
crates/vim/src/command.rs                     | 219 +++++++++++++++++++++
crates/vim/src/vim.rs                         |   9 
crates/workspace/src/pane.rs                  |  32 ++
crates/workspace/src/workspace.rs             | 103 ++++++++-
crates/zed/src/menus.rs                       |  21 +
7 files changed, 406 insertions(+), 26 deletions(-)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -18,6 +18,7 @@
           }
         }
       ],
+      ":": "command_palette::Toggle",
       "h": "vim::Left",
       "left": "vim::Left",
       "backspace": "vim::Backspace",

crates/command_palette/src/command_palette.rs πŸ”—

@@ -18,6 +18,15 @@ actions!(command_palette, [Toggle]);
 
 pub type CommandPalette = Picker<CommandPaletteDelegate>;
 
+pub type CommandPaletteInterceptor =
+    Box<dyn Fn(&str, &AppContext) -> Option<CommandInterceptResult>>;
+
+pub struct CommandInterceptResult {
+    pub action: Box<dyn Action>,
+    pub string: String,
+    pub positions: Vec<usize>,
+}
+
 pub struct CommandPaletteDelegate {
     actions: Vec<Command>,
     matches: Vec<StringMatch>,
@@ -136,7 +145,7 @@ impl PickerDelegate for CommandPaletteDelegate {
                     char_bag: command.name.chars().collect(),
                 })
                 .collect::<Vec<_>>();
-            let matches = if query.is_empty() {
+            let mut matches = if query.is_empty() {
                 candidates
                     .into_iter()
                     .enumerate()
@@ -158,6 +167,40 @@ impl PickerDelegate for CommandPaletteDelegate {
                 )
                 .await
             };
+            let intercept_result = cx.read(|cx| {
+                if cx.has_global::<CommandPaletteInterceptor>() {
+                    cx.global::<CommandPaletteInterceptor>()(&query, cx)
+                } else {
+                    None
+                }
+            });
+            if let Some(CommandInterceptResult {
+                action,
+                string,
+                positions,
+            }) = intercept_result
+            {
+                if let Some(idx) = matches
+                    .iter()
+                    .position(|m| actions[m.candidate_id].action.id() == action.id())
+                {
+                    matches.remove(idx);
+                }
+                actions.push(Command {
+                    name: string.clone(),
+                    action,
+                    keystrokes: vec![],
+                });
+                matches.insert(
+                    0,
+                    StringMatch {
+                        candidate_id: actions.len() - 1,
+                        string,
+                        positions,
+                        score: 0.0,
+                    },
+                )
+            }
             picker
                 .update(&mut cx, |picker, _| {
                     let delegate = picker.delegate_mut();
@@ -254,7 +297,7 @@ impl PickerDelegate for CommandPaletteDelegate {
     }
 }
 
-fn humanize_action_name(name: &str) -> String {
+pub fn humanize_action_name(name: &str) -> String {
     let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
     let mut result = String::with_capacity(capacity);
     for char in name.chars() {

crates/vim/src/command.rs πŸ”—

@@ -0,0 +1,219 @@
+use command_palette::{humanize_action_name, CommandInterceptResult};
+use gpui::{actions, impl_actions, Action, AppContext, AsyncAppContext, ViewContext};
+use itertools::Itertools;
+use serde::{Deserialize, Serialize};
+use workspace::{SaveBehavior, Workspace};
+
+use crate::{
+    motion::{motion, Motion},
+    normal::JoinLines,
+    Vim,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct GoToLine {
+    pub line: u32,
+}
+
+impl_actions!(vim, [GoToLine]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(|_: &mut Workspace, action: &GoToLine, cx| {
+        Vim::update(cx, |vim, cx| {
+            vim.push_operator(crate::state::Operator::Number(action.line as usize), cx)
+        });
+        motion(Motion::StartOfDocument, cx)
+    });
+}
+
+pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option<CommandInterceptResult> {
+    while query.starts_with(":") {
+        query = &query[1..];
+    }
+
+    let (name, action) = match query {
+        // :w
+        "w" | "wr" | "wri" | "writ" | "write" => (
+            "write",
+            workspace::Save {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "w!" | "wr!" | "wri!" | "writ!" | "write!" => (
+            "write",
+            workspace::Save {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+
+        // :q
+        "q" | "qu" | "qui" | "quit" => (
+            "quit",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::PromptOnWrite),
+            }
+            .boxed_clone(),
+        ),
+        "q!" | "qu!" | "qui!" | "quit!" => (
+            "quit!",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::DontSave),
+            }
+            .boxed_clone(),
+        ),
+
+        // :wq
+        "wq" => (
+            "wq",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "wq!" => (
+            "wq!",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+        // :x
+        "x" | "xi" | "xit" | "exi" | "exit" => (
+            "exit",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "x!" | "xi!" | "xit!" | "exi!" | "exit!" => (
+            "xit",
+            workspace::CloseActiveItem {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+
+        // :wa
+        "wa" | "wal" | "wall" => (
+            "wall",
+            workspace::SaveAll {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "wa!" | "wal!" | "wall!" => (
+            "wall!",
+            workspace::SaveAll {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+
+        // :qa
+        "qa" | "qal" | "qall" | "quita" | "quital" | "quitall" => (
+            "quitall",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::PromptOnWrite),
+            }
+            .boxed_clone(),
+        ),
+        "qa!" | "qal!" | "qall!" | "quita!" | "quital!" | "quitall!" => (
+            "quitall!",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::DontSave),
+            }
+            .boxed_clone(),
+        ),
+
+        // :cq
+        "cq" | "cqu" | "cqui" | "cquit" | "cq!" | "cqu!" | "cqui!" | "cquit!" => (
+            "cquit!",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::DontSave),
+            }
+            .boxed_clone(),
+        ),
+
+        // :xa
+        "xa" | "xal" | "xall" => (
+            "xall",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "xa!" | "xal!" | "xall!" => (
+            "zall!",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+
+        // :wqa
+        "wqa" | "wqal" | "wqall" => (
+            "wqall",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::PromptOnConflict),
+            }
+            .boxed_clone(),
+        ),
+        "wqa!" | "wqal!" | "wqall!" => (
+            "wqall!",
+            workspace::CloseAllItemsAndPanes {
+                save_behavior: Some(SaveBehavior::SilentlyOverwrite),
+            }
+            .boxed_clone(),
+        ),
+
+        "j" | "jo" | "joi" | "join" => ("join", JoinLines.boxed_clone()),
+
+        "sp" | "spl" | "spli" | "split" => ("split", workspace::SplitUp.boxed_clone()),
+        "vs" | "vsp" | "vspl" | "vspli" | "vsplit" => {
+            ("vsplit", workspace::SplitLeft.boxed_clone())
+        }
+        "cn" | "cne" | "cnex" | "cnext" => ("cnext", editor::GoToDiagnostic.boxed_clone()),
+        "cp" | "cpr" | "cpre" | "cprev" => ("cprev", editor::GoToPrevDiagnostic.boxed_clone()),
+
+        _ => {
+            if let Ok(line) = query.parse::<u32>() {
+                (query, GoToLine { line }.boxed_clone())
+            } else {
+                return None;
+            }
+        }
+    };
+
+    let string = ":".to_owned() + name;
+    let positions = generate_positions(&string, query);
+
+    Some(CommandInterceptResult {
+        action,
+        string,
+        positions,
+    })
+}
+
+fn generate_positions(string: &str, query: &str) -> Vec<usize> {
+    let mut positions = Vec::new();
+    let mut chars = query.chars().into_iter();
+
+    let Some(mut current) = chars.next() else {
+        return positions;
+    };
+
+    for (i, c) in string.chars().enumerate() {
+        if c == current {
+            positions.push(i);
+            if let Some(c) = chars.next() {
+                current = c;
+            } else {
+                break;
+            }
+        }
+    }
+
+    positions
+}

crates/vim/src/vim.rs πŸ”—

@@ -1,6 +1,7 @@
 #[cfg(test)]
 mod test;
 
+mod command;
 mod editor_events;
 mod insert;
 mod mode_indicator;
@@ -13,6 +14,7 @@ mod visual;
 
 use anyhow::Result;
 use collections::{CommandPaletteFilter, HashMap};
+use command_palette::CommandPaletteInterceptor;
 use editor::{movement, Editor, EditorMode, Event};
 use gpui::{
     actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, Action,
@@ -63,6 +65,7 @@ pub fn init(cx: &mut AppContext) {
     insert::init(cx);
     object::init(cx);
     motion::init(cx);
+    command::init(cx);
 
     // Vim Actions
     cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| {
@@ -469,6 +472,12 @@ impl Vim {
                 }
             });
 
+            if self.enabled {
+                cx.set_global::<CommandPaletteInterceptor>(Box::new(command::command_interceptor));
+            } else if cx.has_global::<CommandPaletteInterceptor>() {
+                let _ = cx.remove_global::<CommandPaletteInterceptor>();
+            }
+
             cx.update_active_window(|cx| {
                 if self.enabled {
                     let active_editor = cx

crates/workspace/src/pane.rs πŸ”—

@@ -78,10 +78,17 @@ pub struct CloseItemsToTheRightById {
 }
 
 #[derive(Clone, PartialEq, Debug, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
 pub struct CloseActiveItem {
     pub save_behavior: Option<SaveBehavior>,
 }
 
+#[derive(Clone, PartialEq, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CloseAllItems {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
 actions!(
     pane,
     [
@@ -92,7 +99,6 @@ actions!(
         CloseCleanItems,
         CloseItemsToTheLeft,
         CloseItemsToTheRight,
-        CloseAllItems,
         GoBack,
         GoForward,
         ReopenClosedItem,
@@ -103,7 +109,7 @@ actions!(
     ]
 );
 
-impl_actions!(pane, [ActivateItem, CloseActiveItem]);
+impl_actions!(pane, [ActivateItem, CloseActiveItem, CloseAllItems]);
 
 const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
@@ -829,14 +835,18 @@ impl Pane {
 
     pub fn close_all_items(
         &mut self,
-        _: &CloseAllItems,
+        action: &CloseAllItems,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         if self.items.is_empty() {
             return None;
         }
 
-        Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true))
+        Some(self.close_items(
+            cx,
+            action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite),
+            |_| true,
+        ))
     }
 
     pub fn close_items(
@@ -1175,7 +1185,12 @@ impl Pane {
                         ContextMenuItem::action("Close Clean Items", CloseCleanItems),
                         ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft),
                         ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight),
-                        ContextMenuItem::action("Close All Items", CloseAllItems),
+                        ContextMenuItem::action(
+                            "Close All Items",
+                            CloseAllItems {
+                                save_behavior: None,
+                            },
+                        ),
                     ]
                 } else {
                     // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command.
@@ -1219,7 +1234,12 @@ impl Pane {
                                 }
                             }
                         }),
-                        ContextMenuItem::action("Close All Items", CloseAllItems),
+                        ContextMenuItem::action(
+                            "Close All Items",
+                            CloseAllItems {
+                                save_behavior: None,
+                            },
+                        ),
                     ]
                 },
                 cx,

crates/workspace/src/workspace.rs πŸ”—

@@ -122,13 +122,11 @@ actions!(
         Open,
         NewFile,
         NewWindow,
-        CloseWindow,
         CloseInactiveTabsAndPanes,
         AddFolderToProject,
         Unfollow,
-        Save,
         SaveAs,
-        SaveAll,
+        ReloadActiveItem,
         ActivatePreviousPane,
         ActivateNextPane,
         FollowNextCollaborator,
@@ -158,6 +156,30 @@ pub struct ActivatePane(pub usize);
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct ActivatePaneInDirection(pub SplitDirection);
 
+#[derive(Clone, PartialEq, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SaveAll {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
+#[derive(Clone, PartialEq, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Save {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
+#[derive(Clone, PartialEq, Debug, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct CloseWindow {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
+#[derive(Clone, PartialEq, Debug, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct CloseAllItemsAndPanes {
+    pub save_behavior: Option<SaveBehavior>,
+}
+
 #[derive(Deserialize)]
 pub struct Toast {
     id: usize,
@@ -210,7 +232,16 @@ pub struct OpenTerminal {
 
 impl_actions!(
     workspace,
-    [ActivatePane, ActivatePaneInDirection, Toast, OpenTerminal]
+    [
+        ActivatePane,
+        ActivatePaneInDirection,
+        Toast,
+	OpenTerminal,
+        SaveAll,
+        Save,
+        CloseWindow,
+        CloseAllItemsAndPanes,
+    ]
 );
 
 pub type WorkspaceId = i64;
@@ -251,6 +282,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     cx.add_async_action(Workspace::follow_next_collaborator);
     cx.add_async_action(Workspace::close);
     cx.add_async_action(Workspace::close_inactive_items_and_panes);
+    cx.add_async_action(Workspace::close_all_items_and_panes);
     cx.add_global_action(Workspace::close_global);
     cx.add_global_action(restart);
     cx.add_async_action(Workspace::save_all);
@@ -1262,11 +1294,15 @@ impl Workspace {
 
     pub fn close(
         &mut self,
-        _: &CloseWindow,
+        action: &CloseWindow,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let window = cx.window();
-        let prepare = self.prepare_to_close(false, cx);
+        let prepare = self.prepare_to_close(
+            false,
+            action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite),
+            cx,
+        );
         Some(cx.spawn(|_, mut cx| async move {
             if prepare.await? {
                 window.remove(&mut cx);
@@ -1323,8 +1359,17 @@ impl Workspace {
         })
     }
 
-    fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
-        let save_all = self.save_all_internal(SaveBehavior::PromptOnConflict, cx);
+    fn save_all(
+        &mut self,
+        action: &SaveAll,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        let save_all = self.save_all_internal(
+            action
+                .save_behavior
+                .unwrap_or(SaveBehavior::PromptOnConflict),
+            cx,
+        );
         Some(cx.foreground().spawn(async move {
             save_all.await?;
             Ok(())
@@ -1691,24 +1736,52 @@ impl Workspace {
         &mut self,
         _: &CloseInactiveTabsAndPanes,
         cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        self.close_all_internal(true, SaveBehavior::PromptOnWrite, cx)
+    }
+
+    pub fn close_all_items_and_panes(
+        &mut self,
+        action: &CloseAllItemsAndPanes,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        self.close_all_internal(
+            false,
+            action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite),
+            cx,
+        )
+    }
+
+    fn close_all_internal(
+        &mut self,
+        retain_active_pane: bool,
+        save_behavior: SaveBehavior,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let current_pane = self.active_pane();
 
         let mut tasks = Vec::new();
 
-        if let Some(current_pane_close) = current_pane.update(cx, |pane, cx| {
-            pane.close_inactive_items(&CloseInactiveItems, cx)
-        }) {
-            tasks.push(current_pane_close);
-        };
+        if retain_active_pane {
+            if let Some(current_pane_close) = current_pane.update(cx, |pane, cx| {
+                pane.close_inactive_items(&CloseInactiveItems, cx)
+            }) {
+                tasks.push(current_pane_close);
+            };
+        }
 
         for pane in self.panes() {
-            if pane.id() == current_pane.id() {
+            if retain_active_pane && pane.id() == current_pane.id() {
                 continue;
             }
 
             if let Some(close_pane_items) = pane.update(cx, |pane: &mut Pane, cx| {
-                pane.close_all_items(&CloseAllItems, cx)
+                pane.close_all_items(
+                    &CloseAllItems {
+                        save_behavior: Some(save_behavior),
+                    },
+                    cx,
+                )
             }) {
                 tasks.push(close_pane_items)
             }

crates/zed/src/menus.rs πŸ”—

@@ -38,16 +38,31 @@ pub fn menus() -> Vec<Menu<'static>> {
                 MenuItem::action("Open Recent...", recent_projects::OpenRecent),
                 MenuItem::separator(),
                 MenuItem::action("Add Folder to Project…", workspace::AddFolderToProject),
-                MenuItem::action("Save", workspace::Save),
+                MenuItem::action(
+                    "Save",
+                    workspace::Save {
+                        save_behavior: None,
+                    },
+                ),
                 MenuItem::action("Save As…", workspace::SaveAs),
-                MenuItem::action("Save All", workspace::SaveAll),
+                MenuItem::action(
+                    "Save All",
+                    workspace::SaveAll {
+                        save_behavior: None,
+                    },
+                ),
                 MenuItem::action(
                     "Close Editor",
                     workspace::CloseActiveItem {
                         save_behavior: None,
                     },
                 ),
-                MenuItem::action("Close Window", workspace::CloseWindow),
+                MenuItem::action(
+                    "Close Window",
+                    workspace::CloseWindow {
+                        save_behavior: None,
+                    },
+                ),
             ],
         },
         Menu {