fix vim repeat (#8513)

Conrad Irwin created

Release Notes:

- vim: Fixed `.` when multiple windows are open
([#7446](https://github.com/zed-industries/zed/issues/7446)).
- vim: Fixed switching to normal mode after `J`, `<` or `>` in visual
mode ([#4439](https://github.com/zed-industries/zed/issues/4439))
- vim: Added `ctrl-t` and `ctrl-d` for indent/outdent in insert mode.

- Fixed indent/outdent/join lines appearing to work in read-only buffers
([#8423](https://github.com/zed-industries/zed/issues/8423))
- Fixed indent with an empty selection when the cursor was in column 0

Change summary

assets/keymaps/vim.json                 | 12 ++-
crates/editor/src/editor.rs             | 11 +++
crates/vim/src/normal.rs                | 31 +++++++++++
crates/vim/src/test.rs                  |  4 
crates/vim/src/test/vim_test_context.rs |  1 
crates/vim/src/vim.rs                   | 71 ++++++++++++--------------
crates/zed/src/zed.rs                   |  2 
7 files changed, 83 insertions(+), 49 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -342,8 +342,8 @@
       "r": ["vim::PushOperator", "Replace"],
       "s": "vim::Substitute",
       "shift-s": "vim::SubstituteLine",
-      "> >": "editor::Indent",
-      "< <": "editor::Outdent",
+      "> >": "vim::Indent",
+      "< <": "vim::Outdent",
       "ctrl-pagedown": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePrevItem"
     }
@@ -460,8 +460,8 @@
       "ctrl-c": ["vim::SwitchMode", "Normal"],
       "escape": ["vim::SwitchMode", "Normal"],
       "ctrl-[": ["vim::SwitchMode", "Normal"],
-      ">": "editor::Indent",
-      "<": "editor::Outdent",
+      ">": "vim::Indent",
+      "<": "vim::Outdent",
       "i": [
         "vim::PushOperator",
         {
@@ -492,7 +492,9 @@
       "ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific
       "ctrl-x ctrl-z": "editor::Cancel",
       "ctrl-w": "editor::DeleteToPreviousWordStart",
-      "ctrl-u": "editor::DeleteToBeginningOfLine"
+      "ctrl-u": "editor::DeleteToBeginningOfLine",
+      "ctrl-t": "vim::Indent",
+      "ctrl-d": "vim::Outdent"
     }
   },
   {

crates/editor/src/editor.rs ๐Ÿ”—

@@ -4474,6 +4474,9 @@ impl Editor {
     }
 
     pub fn indent(&mut self, _: &Indent, cx: &mut ViewContext<Self>) {
+        if self.read_only(cx) {
+            return;
+        }
         let mut selections = self.selections.all::<Point>(cx);
         let mut prev_edited_row = 0;
         let mut row_delta = 0;
@@ -4516,7 +4519,7 @@ impl Editor {
 
         // If a selection ends at the beginning of a line, don't indent
         // that last line.
-        if selection.end.column == 0 {
+        if selection.end.column == 0 && selection.end.row > selection.start.row {
             end_row -= 1;
         }
 
@@ -4567,6 +4570,9 @@ impl Editor {
     }
 
     pub fn outdent(&mut self, _: &Outdent, cx: &mut ViewContext<Self>) {
+        if self.read_only(cx) {
+            return;
+        }
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let selections = self.selections.all::<Point>(cx);
         let mut deletion_ranges = Vec::new();
@@ -4707,6 +4713,9 @@ impl Editor {
     }
 
     pub fn join_lines(&mut self, _: &JoinLines, cx: &mut ViewContext<Self>) {
+        if self.read_only(cx) {
+            return;
+        }
         let mut row_ranges = Vec::<Range<u32>>::new();
         for selection in self.selections.all::<Point>(cx) {
             let start = selection.start.row;

crates/vim/src/normal.rs ๐Ÿ”—

@@ -51,6 +51,8 @@ actions!(
         ConvertToUpperCase,
         ConvertToLowerCase,
         JoinLines,
+        Indent,
+        Outdent,
     ]
 );
 
@@ -125,7 +127,34 @@ pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace
                         editor.join_lines(&Default::default(), cx)
                     }
                 })
-            })
+            });
+            if vim.state().mode.is_visual() {
+                vim.switch_mode(Mode::Normal, false, cx)
+            }
+        });
+    });
+
+    workspace.register_action(|_: &mut Workspace, _: &Indent, cx| {
+        Vim::update(cx, |vim, cx| {
+            vim.record_current_action(cx);
+            vim.update_active_editor(cx, |_, editor, cx| {
+                editor.transact(cx, |editor, cx| editor.indent(&Default::default(), cx))
+            });
+            if vim.state().mode.is_visual() {
+                vim.switch_mode(Mode::Normal, false, cx)
+            }
+        });
+    });
+
+    workspace.register_action(|_: &mut Workspace, _: &Outdent, cx| {
+        Vim::update(cx, |vim, cx| {
+            vim.record_current_action(cx);
+            vim.update_active_editor(cx, |_, editor, cx| {
+                editor.transact(cx, |editor, cx| editor.outdent(&Default::default(), cx))
+            });
+            if vim.state().mode.is_visual() {
+                vim.switch_mode(Mode::Normal, false, cx)
+            }
         });
     });
 

crates/vim/src/test.rs ๐Ÿ”—

@@ -163,9 +163,9 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
     cx.simulate_keystrokes(["<", "<"]);
     cx.assert_editor_state("aa\nbห‡b\ncc");
 
-    // works in visuial mode
+    // works in visual mode
     cx.simulate_keystrokes(["shift-v", "down", ">"]);
-    cx.assert_editor_state("aa\n    bยซb\n    ccห‡ยป");
+    cx.assert_editor_state("aa\n    bb\n    cห‡c");
 }
 
 #[gpui::test]

crates/vim/src/test/vim_test_context.rs ๐Ÿ”—

@@ -67,7 +67,6 @@ impl VimTestContext {
 
         // Setup search toolbars and keypress hook
         cx.update_workspace(|workspace, cx| {
-            observe_keystrokes(cx);
             workspace.active_pane().update(cx, |pane, cx| {
                 pane.toolbar().update(cx, |toolbar, cx| {
                     let buffer_search_bar = cx.new_view(BufferSearchBar::new);

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

@@ -22,8 +22,8 @@ use editor::{
     Editor, EditorEvent, EditorMode,
 };
 use gpui::{
-    actions, impl_actions, Action, AppContext, EntityId, Global, Subscription, View, ViewContext,
-    WeakView, WindowContext,
+    actions, impl_actions, Action, AppContext, EntityId, Global, KeystrokeEvent, Subscription,
+    View, ViewContext, WeakView, WindowContext,
 };
 use language::{CursorShape, Point, Selection, SelectionGoal};
 pub use mode_indicator::ModeIndicator;
@@ -76,6 +76,7 @@ pub fn init(cx: &mut AppContext) {
     VimModeSetting::register(cx);
     VimSettings::register(cx);
 
+    cx.observe_keystrokes(observe_keystrokes).detach();
     editor_events::init(cx);
 
     cx.observe_new_views(|workspace: &mut Workspace, cx| register(workspace, cx))
@@ -135,46 +136,42 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
     visual::register(workspace, cx);
 }
 
-/// Registers a keystroke observer to observe keystrokes for the Vim integration.
-pub fn observe_keystrokes(cx: &mut WindowContext) {
-    cx.observe_keystrokes(|keystroke_event, cx| {
-        if let Some(action) = keystroke_event
-            .action
-            .as_ref()
-            .map(|action| action.boxed_clone())
-        {
-            Vim::update(cx, |vim, _| {
-                if vim.workspace_state.recording {
-                    vim.workspace_state
-                        .recorded_actions
-                        .push(ReplayableAction::Action(action.boxed_clone()));
-
-                    if vim.workspace_state.stop_recording_after_next_action {
-                        vim.workspace_state.recording = false;
-                        vim.workspace_state.stop_recording_after_next_action = false;
-                    }
-                }
-            });
+/// Called whenever an keystroke is typed so vim can observe all actions
+/// and keystrokes accordingly.
+fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext) {
+    if let Some(action) = keystroke_event
+        .action
+        .as_ref()
+        .map(|action| action.boxed_clone())
+    {
+        Vim::update(cx, |vim, _| {
+            if vim.workspace_state.recording {
+                vim.workspace_state
+                    .recorded_actions
+                    .push(ReplayableAction::Action(action.boxed_clone()));
 
-            // Keystroke is handled by the vim system, so continue forward
-            if action.name().starts_with("vim::") {
-                return;
+                if vim.workspace_state.stop_recording_after_next_action {
+                    vim.workspace_state.recording = false;
+                    vim.workspace_state.stop_recording_after_next_action = false;
+                }
             }
-        } else if cx.has_pending_keystrokes() {
+        });
+
+        // Keystroke is handled by the vim system, so continue forward
+        if action.name().starts_with("vim::") {
             return;
         }
+    } else if cx.has_pending_keystrokes() {
+        return;
+    }
 
-        Vim::update(cx, |vim, cx| match vim.active_operator() {
-            Some(
-                Operator::FindForward { .. } | Operator::FindBackward { .. } | Operator::Replace,
-            ) => {}
-            Some(_) => {
-                vim.clear_operator(cx);
-            }
-            _ => {}
-        });
-    })
-    .detach()
+    Vim::update(cx, |vim, cx| match vim.active_operator() {
+        Some(Operator::FindForward { .. } | Operator::FindBackward { .. } | Operator::Replace) => {}
+        Some(_) => {
+            vim.clear_operator(cx);
+        }
+        _ => {}
+    });
 }
 
 /// The state pertaining to Vim mode.

crates/zed/src/zed.rs ๐Ÿ”—

@@ -142,8 +142,6 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
 
         auto_update::notify_of_any_new_update(cx);
 
-        vim::observe_keystrokes(cx);
-
         let handle = cx.view().downgrade();
         cx.on_window_should_close(move |cx| {
             handle