vim: Add `:norm` support (#33232)

AidanV created

Closes #21198

Release Notes:

- Adds support for `:norm`
- Allows for vim and zed style modified keys specified in issue
  - Vim style <C-w> and zed style <ctrl-w>
- Differs from vim in how multi-line is handled 
  - vim is sequential
  - zed is combinational (with multi-cursor)

Change summary

crates/editor/src/editor.rs                   |  18 +
crates/vim/src/command.rs                     | 195 ++++++++++++++++++++
crates/vim/src/insert.rs                      |   2 
crates/vim/test_data/test_normal_command.json |  64 ++++++
crates/workspace/src/workspace.rs             |  97 ++++++---
5 files changed, 328 insertions(+), 48 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -16968,7 +16968,7 @@ impl Editor {
         now: Instant,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) {
+    ) -> Option<TransactionId> {
         self.end_selection(window, cx);
         if let Some(tx_id) = self
             .buffer
@@ -16978,7 +16978,10 @@ impl Editor {
                 .insert_transaction(tx_id, self.selections.disjoint_anchors());
             cx.emit(EditorEvent::TransactionBegun {
                 transaction_id: tx_id,
-            })
+            });
+            Some(tx_id)
+        } else {
+            None
         }
     }
 
@@ -17006,6 +17009,17 @@ impl Editor {
         }
     }
 
+    pub fn modify_transaction_selection_history(
+        &mut self,
+        transaction_id: TransactionId,
+        modify: impl FnOnce(&mut (Arc<[Selection<Anchor>]>, Option<Arc<[Selection<Anchor>]>>)),
+    ) -> bool {
+        self.selection_history
+            .transaction_mut(transaction_id)
+            .map(modify)
+            .is_some()
+    }
+
     pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
         if self.selection_mark_mode {
             self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {

crates/vim/src/command.rs 🔗

@@ -6,7 +6,7 @@ use editor::{
     actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
     display_map::ToDisplayPoint,
 };
-use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
+use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
 use itertools::Itertools;
 use language::Point;
 use multi_buffer::MultiBufferRow;
@@ -202,6 +202,7 @@ actions!(
         ArgumentRequired
     ]
 );
+
 /// Opens the specified file for editing.
 #[derive(Clone, PartialEq, Action)]
 #[action(namespace = vim, no_json, no_register)]
@@ -209,6 +210,13 @@ struct VimEdit {
     pub filename: String,
 }
 
+#[derive(Clone, PartialEq, Action)]
+#[action(namespace = vim, no_json, no_register)]
+struct VimNorm {
+    pub range: Option<CommandRange>,
+    pub command: String,
+}
+
 #[derive(Debug)]
 struct WrappedAction(Box<dyn Action>);
 
@@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
         });
     });
 
+    Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
+        let keystrokes = action
+            .command
+            .chars()
+            .map(|c| Keystroke::parse(&c.to_string()).unwrap())
+            .collect();
+        vim.switch_mode(Mode::Normal, true, window, cx);
+        let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
+            editor.selections.disjoint_anchors()
+        });
+        if let Some(range) = &action.range {
+            let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
+                let range = range.buffer_range(vim, editor, window, cx)?;
+                editor.change_selections(
+                    SelectionEffects::no_scroll().nav_history(false),
+                    window,
+                    cx,
+                    |s| {
+                        s.select_ranges(
+                            (range.start.0..=range.end.0)
+                                .map(|line| Point::new(line, 0)..Point::new(line, 0)),
+                        );
+                    },
+                );
+                anyhow::Ok(())
+            });
+            if let Some(Err(err)) = result {
+                log::error!("Error selecting range: {}", err);
+                return;
+            }
+        };
+
+        let Some(workspace) = vim.workspace(window) else {
+            return;
+        };
+        let task = workspace.update(cx, |workspace, cx| {
+            workspace.send_keystrokes_impl(keystrokes, window, cx)
+        });
+        let had_range = action.range.is_some();
+
+        cx.spawn_in(window, async move |vim, cx| {
+            task.await;
+            vim.update_in(cx, |vim, window, cx| {
+                vim.update_editor(window, cx, |_, editor, window, cx| {
+                    if had_range {
+                        editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+                            s.select_anchor_ranges([s.newest_anchor().range()]);
+                        })
+                    }
+                });
+                if matches!(vim.mode, Mode::Insert | Mode::Replace) {
+                    vim.normal_before(&Default::default(), window, cx);
+                } else {
+                    vim.switch_mode(Mode::Normal, true, window, cx);
+                }
+                vim.update_editor(window, cx, |_, editor, _, cx| {
+                    if let Some(first_sel) = initial_selections {
+                        if let Some(tx_id) = editor
+                            .buffer()
+                            .update(cx, |multi, cx| multi.last_transaction_id(cx))
+                        {
+                            let last_sel = editor.selections.disjoint_anchors();
+                            editor.modify_transaction_selection_history(tx_id, |old| {
+                                old.0 = first_sel;
+                                old.1 = Some(last_sel);
+                            });
+                        }
+                    }
+                });
+            })
+            .ok();
+        })
+        .detach();
+    });
+
     Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
         let Some(workspace) = vim.workspace(window) else {
             return;
@@ -675,14 +758,15 @@ impl VimCommand {
         } else {
             return None;
         };
-        if !args.is_empty() {
+
+        let action = if args.is_empty() {
+            action
+        } else {
             // if command does not accept args and we have args then we should do no action
-            if let Some(args_fn) = &self.args {
-                args_fn.deref()(action, args)
-            } else {
-                None
-            }
-        } else if let Some(range) = range {
+            self.args.as_ref()?(action, args)?
+        };
+
+        if let Some(range) = range {
             self.range.as_ref().and_then(|f| f(action, range))
         } else {
             Some(action)
@@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
             save_intent: Some(SaveIntent::Skip),
             close_pinned: true,
         }),
+        VimCommand::new(
+            ("norm", "al"),
+            VimNorm {
+                command: "".into(),
+                range: None,
+            },
+        )
+        .args(|_, args| {
+            Some(
+                VimNorm {
+                    command: args,
+                    range: None,
+                }
+                .boxed_clone(),
+            )
+        })
+        .range(|action, range| {
+            let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
+            action.range.replace(range.clone());
+            Some(Box::new(action))
+        }),
         VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
         VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
         VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
@@ -2298,4 +2403,78 @@ mod test {
         });
         assert!(mark.is_none())
     }
+
+    #[gpui::test]
+    async fn test_normal_command(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+            The quick
+            brown« fox
+            jumpsˇ» over
+            the lazy dog
+        "})
+            .await;
+
+        cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown word
+            jumps worˇd
+            the lazy dog
+        "});
+
+        cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown word
+            jumps tesˇt
+            the lazy dog
+        "});
+
+        cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
+            .await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown word
+            lˇaumps test
+            the lazy dog
+        "});
+
+        cx.set_shared_state(indoc! {"
+            ˇThe quick
+            brown fox
+            jumps over
+            the lazy dog
+        "})
+            .await;
+
+        cx.simulate_shared_keystrokes("c i w M y escape").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            Mˇy quick
+            brown fox
+            jumps over
+            the lazy dog
+        "});
+
+        cx.simulate_shared_keystrokes(": n o r m space u").await;
+        cx.simulate_shared_keystrokes("enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            ˇThe quick
+            brown fox
+            jumps over
+            the lazy dog
+        "});
+        // Once ctrl-v to input character literals is added there should be a test for redo
+    }
 }

crates/vim/src/insert.rs 🔗

@@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 }
 
 impl Vim {
-    fn normal_before(
+    pub(crate) fn normal_before(
         &mut self,
         action: &NormalBefore,
         window: &mut Window,

crates/vim/test_data/test_normal_command.json 🔗

@@ -0,0 +1,64 @@
+{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"w"}
+{"Key":"C"}
+{"Key":"w"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"d"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"_"}
+{"Key":"w"}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"t"}
+{"Key":"e"}
+{"Key":"s"}
+{"Key":"t"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
+{"Key":"_"}
+{"Key":"l"}
+{"Key":"v"}
+{"Key":"l"}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"s"}
+{"Key":"l"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
+{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"M"}
+{"Key":"y"}
+{"Key":"escape"}
+{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"u"}
+{"Key":"enter"}
+{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}

crates/workspace/src/workspace.rs 🔗

@@ -32,7 +32,7 @@ use futures::{
         mpsc::{self, UnboundedReceiver, UnboundedSender},
         oneshot,
     },
-    future::try_join_all,
+    future::{Shared, try_join_all},
 };
 use gpui::{
     Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
@@ -87,7 +87,7 @@ use std::{
     borrow::Cow,
     cell::RefCell,
     cmp,
-    collections::hash_map::DefaultHasher,
+    collections::{VecDeque, hash_map::DefaultHasher},
     env,
     hash::{Hash, Hasher},
     path::{Path, PathBuf},
@@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
     ) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
 >;
 
+#[derive(Default)]
+struct DispatchingKeystrokes {
+    dispatched: HashSet<Vec<Keystroke>>,
+    queue: VecDeque<Keystroke>,
+    task: Option<Shared<Task<()>>>,
+}
+
 /// Collects everything project-related for a certain window opened.
 /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
 ///
@@ -1080,7 +1087,7 @@ pub struct Workspace {
     leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: Option<WorkspaceId>,
     app_state: Arc<AppState>,
-    dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>,
+    dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
     _subscriptions: Vec<Subscription>,
     _apply_leader_updates: Task<Result<()>>,
     _observe_current_user: Task<Result<()>>,
@@ -2311,49 +2318,65 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let mut state = self.dispatching_keystrokes.borrow_mut();
-        if !state.0.insert(action.0.clone()) {
-            cx.propagate();
-            return;
-        }
-        let mut keystrokes: Vec<Keystroke> = action
+        let keystrokes: Vec<Keystroke> = action
             .0
             .split(' ')
             .flat_map(|k| Keystroke::parse(k).log_err())
             .collect();
-        keystrokes.reverse();
+        let _ = self.send_keystrokes_impl(keystrokes, window, cx);
+    }
+
+    pub fn send_keystrokes_impl(
+        &mut self,
+        keystrokes: Vec<Keystroke>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Shared<Task<()>> {
+        let mut state = self.dispatching_keystrokes.borrow_mut();
+        if !state.dispatched.insert(keystrokes.clone()) {
+            cx.propagate();
+            return state.task.clone().unwrap();
+        }
 
-        state.1.append(&mut keystrokes);
-        drop(state);
+        state.queue.extend(keystrokes);
 
         let keystrokes = self.dispatching_keystrokes.clone();
-        window
-            .spawn(cx, async move |cx| {
-                // limit to 100 keystrokes to avoid infinite recursion.
-                for _ in 0..100 {
-                    let Some(keystroke) = keystrokes.borrow_mut().1.pop() else {
-                        keystrokes.borrow_mut().0.clear();
-                        return Ok(());
-                    };
-                    cx.update(|window, cx| {
-                        let focused = window.focused(cx);
-                        window.dispatch_keystroke(keystroke.clone(), cx);
-                        if window.focused(cx) != focused {
-                            // dispatch_keystroke may cause the focus to change.
-                            // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
-                            // And we need that to happen before the next keystroke to keep vim mode happy...
-                            // (Note that the tests always do this implicitly, so you must manually test with something like:
-                            //   "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
-                            // )
-                            window.draw(cx).clear();
+        if state.task.is_none() {
+            state.task = Some(
+                window
+                    .spawn(cx, async move |cx| {
+                        // limit to 100 keystrokes to avoid infinite recursion.
+                        for _ in 0..100 {
+                            let mut state = keystrokes.borrow_mut();
+                            let Some(keystroke) = state.queue.pop_front() else {
+                                state.dispatched.clear();
+                                state.task.take();
+                                return;
+                            };
+                            drop(state);
+                            cx.update(|window, cx| {
+                                let focused = window.focused(cx);
+                                window.dispatch_keystroke(keystroke.clone(), cx);
+                                if window.focused(cx) != focused {
+                                    // dispatch_keystroke may cause the focus to change.
+                                    // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
+                                    // And we need that to happen before the next keystroke to keep vim mode happy...
+                                    // (Note that the tests always do this implicitly, so you must manually test with something like:
+                                    //   "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
+                                    // )
+                                    window.draw(cx).clear();
+                                }
+                            })
+                            .ok();
                         }
-                    })?;
-                }
 
-                *keystrokes.borrow_mut() = Default::default();
-                anyhow::bail!("over 100 keystrokes passed to send_keystrokes");
-            })
-            .detach_and_log_err(cx);
+                        *keystrokes.borrow_mut() = Default::default();
+                        log::error!("over 100 keystrokes passed to send_keystrokes");
+                    })
+                    .shared(),
+            );
+        }
+        state.task.clone().unwrap()
     }
 
     fn save_all_internal(