vim: Add support for `<count>gt` and `<count>gT` (#38570)

Ran Benita created

Vim mode currently supports `gt` (go to next tab) and `gT` (go to
previous tab) but not with count. Implement the expected behavior as
defined by vim:

- `<count>gt` moves to tab `<count>`
- `<count>gT` moves to previous tab `<count>` times (with wraparound)

Release Notes:

- Improved vim `gt` and `gT` to support count, e.g. `5gt` - go to tab 5,
`8gT` - go to 8th previous tab with wraparound.

Change summary

assets/keymaps/vim.json  |   4 
crates/vim/src/normal.rs | 132 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 133 insertions(+), 3 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -95,8 +95,8 @@
       "g g": "vim::StartOfDocument",
       "g h": "editor::Hover",
       "g B": "editor::BlameHover",
-      "g t": "pane::ActivateNextItem",
-      "g shift-t": "pane::ActivatePreviousItem",
+      "g t": "vim::GoToTab",
+      "g shift-t": "vim::GoToPreviousTab",
       "g d": "editor::GoToDefinition",
       "g shift-d": "editor::GoToDeclaration",
       "g y": "editor::GoToTypeDefinition",

crates/vim/src/normal.rs 🔗

@@ -28,7 +28,7 @@ use editor::Editor;
 use editor::{Anchor, SelectionEffects};
 use editor::{Bias, ToPoint};
 use editor::{display_map::ToDisplayPoint, movement};
-use gpui::{Context, Window, actions};
+use gpui::{Action, Context, Window, actions};
 use language::{Point, SelectionGoal};
 use log::error;
 use multi_buffer::MultiBufferRow;
@@ -94,6 +94,10 @@ actions!(
         Redo,
         /// Undoes all changes to the most recently changed line.
         UndoLastLine,
+        /// Go to tab page (with count support).
+        GoToTab,
+        /// Go to previous tab page (with count support).
+        GoToPreviousTab,
     ]
 );
 
@@ -116,6 +120,8 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::toggle_comments);
     Vim::action(editor, cx, Vim::paste);
     Vim::action(editor, cx, Vim::show_location);
+    Vim::action(editor, cx, Vim::go_to_tab);
+    Vim::action(editor, cx, Vim::go_to_previous_tab);
 
     Vim::action(editor, cx, |vim, _: &DeleteLeft, window, cx| {
         vim.record_current_action(cx);
@@ -984,6 +990,54 @@ impl Vim {
             self.switch_mode(Mode::Insert, true, window, cx);
         }
     }
+
+    fn go_to_tab(&mut self, _: &GoToTab, window: &mut Window, cx: &mut Context<Self>) {
+        let count = Vim::take_count(cx);
+        Vim::take_forced_motion(cx);
+
+        if let Some(tab_index) = count {
+            // <count>gt goes to tab <count> (1-based).
+            let zero_based_index = tab_index.saturating_sub(1);
+            window.dispatch_action(
+                workspace::pane::ActivateItem(zero_based_index).boxed_clone(),
+                cx,
+            );
+        } else {
+            // If no count is provided, go to the next tab.
+            window.dispatch_action(workspace::pane::ActivateNextItem.boxed_clone(), cx);
+        }
+    }
+
+    fn go_to_previous_tab(
+        &mut self,
+        _: &GoToPreviousTab,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let count = Vim::take_count(cx);
+        Vim::take_forced_motion(cx);
+
+        if let Some(count) = count {
+            // gT with count goes back that many tabs with wraparound (not the same as gt!).
+            if let Some(workspace) = self.workspace(window) {
+                let pane = workspace.read(cx).active_pane().read(cx);
+                let item_count = pane.items().count();
+                if item_count > 0 {
+                    let current_index = pane.active_item_index();
+                    let target_index = (current_index as isize - count as isize)
+                        .rem_euclid(item_count as isize)
+                        as usize;
+                    window.dispatch_action(
+                        workspace::pane::ActivateItem(target_index).boxed_clone(),
+                        cx,
+                    );
+                }
+            }
+        } else {
+            // No count provided, go to the previous tab.
+            window.dispatch_action(workspace::pane::ActivatePreviousItem.boxed_clone(), cx);
+        }
+    }
 }
 #[cfg(test)]
 mod test {
@@ -2119,4 +2173,80 @@ mod test {
             Mode::Normal,
         );
     }
+
+    #[gpui::test]
+    async fn test_go_to_tab_with_count(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Open 4 tabs.
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.items(cx).count(), 4);
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 3);
+        });
+
+        cx.simulate_keystrokes("1 g t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 0);
+        });
+
+        cx.simulate_keystrokes("3 g t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 2);
+        });
+
+        cx.simulate_keystrokes("4 g t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 3);
+        });
+
+        cx.simulate_keystrokes("1 g t");
+        cx.simulate_keystrokes("g t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 1);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_go_to_previous_tab_with_count(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Open 4 tabs.
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.simulate_keystrokes(": tabnew");
+        cx.simulate_keystrokes("enter");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.items(cx).count(), 4);
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 3);
+        });
+
+        cx.simulate_keystrokes("2 g shift-t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 1);
+        });
+
+        cx.simulate_keystrokes("g shift-t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 0);
+        });
+
+        // Wraparound: gT from first tab should go to last.
+        cx.simulate_keystrokes("g shift-t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 3);
+        });
+
+        cx.simulate_keystrokes("6 g shift-t");
+        cx.workspace(|workspace, _, cx| {
+            assert_eq!(workspace.active_pane().read(cx).active_item_index(), 1);
+        });
+    }
 }