vim: Add motion support for toggle comments (#14919)

Benjamin Westphal created

### Summary

This PR adds support for count and object motions to the toggle comments
action in Vim mode. The relevant issue is
[#14337](https://github.com/zed-industries/zed/issues/14337).

For example, `2 g c j` will toggle comments three lines downward. `g c g
g` will toggle comments from the current cursor position up to the start
of the file.

Notably missing from this PR are `g c b` (toggle comments for the
current block) as well as `g c p` (toggle comments for the current
paragraph). These seem to be non-standard.

The new module `normal/toggle_comments.rs` has been copied almost
verbatim from `normal/indent.rs`. Maybe that ought to be abstracted over
but I feel I lack the overview.

Release Notes:

- vim: Added support for count and object motion to the toggle comments
action ([#14337](https://github.com/zed-industries/zed/issues/14337)).

Change summary

assets/keymaps/vim.json                  |  8 +++
crates/vim/src/normal.rs                 |  4 +
crates/vim/src/normal/toggle_comments.rs | 57 +++++++++++++++++++++++++
crates/vim/src/state.rs                  |  5 +
crates/vim/src/test.rs                   | 23 ++++++++++
crates/vim/src/vim.rs                    |  1 
6 files changed, 96 insertions(+), 2 deletions(-)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -253,7 +253,7 @@
       "[ d": "editor::GoToPrevDiagnostic",
       "] c": "editor::GoToHunk",
       "[ c": "editor::GoToPrevHunk",
-      "g c c": "vim::ToggleComments"
+      "g c": ["vim::PushOperator", "ToggleComments"]
     }
   },
   {
@@ -434,6 +434,12 @@
       "<": "vim::CurrentLine"
     }
   },
+  {
+    "context": "vim_operator == gc",
+    "bindings": {
+      "c": "vim::CurrentLine"
+    }
+  },
   {
     "context": "BufferSearchBar && !in_replace",
     "bindings": {

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

@@ -9,6 +9,7 @@ pub(crate) mod repeat;
 mod scroll;
 pub(crate) mod search;
 pub mod substitute;
+mod toggle_comments;
 pub(crate) mod yank;
 
 use std::collections::HashMap;
@@ -39,6 +40,7 @@ use self::{
     change::{change_motion, change_object},
     delete::{delete_motion, delete_object},
     indent::{indent_motion, indent_object, IndentDirection},
+    toggle_comments::{toggle_comments_motion, toggle_comments_object},
     yank::{yank_motion, yank_object},
 };
 
@@ -237,6 +239,7 @@ pub fn normal_motion(
             Some(Operator::OppositeCase) => {
                 change_case_motion(vim, motion, times, CaseTarget::OppositeCase, cx)
             }
+            Some(Operator::ToggleComments) => toggle_comments_motion(vim, motion, times, cx),
             Some(operator) => {
                 // Can't do anything for text objects, Ignoring
                 error!("Unexpected normal mode motion operator: {:?}", operator)
@@ -273,6 +276,7 @@ pub fn normal_object(object: Object, cx: &mut WindowContext) {
                         target: Some(SurroundsType::Object(object)),
                     });
                 }
+                Some(Operator::ToggleComments) => toggle_comments_object(vim, object, around, cx),
                 _ => {
                     // Can't do anything for namespace operators. Ignoring
                 }

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

@@ -0,0 +1,57 @@
+use crate::{motion::Motion, object::Object, Vim};
+use collections::HashMap;
+use editor::{display_map::ToDisplayPoint, Bias};
+use gpui::WindowContext;
+use language::SelectionGoal;
+
+pub fn toggle_comments_motion(
+    vim: &mut Vim,
+    motion: Motion,
+    times: Option<usize>,
+    cx: &mut WindowContext,
+) {
+    vim.stop_recording();
+    vim.update_active_editor(cx, |_, editor, cx| {
+        let text_layout_details = editor.text_layout_details(cx);
+        editor.transact(cx, |editor, cx| {
+            let mut selection_starts: HashMap<_, _> = Default::default();
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
+                    selection_starts.insert(selection.id, anchor);
+                    motion.expand_selection(map, selection, times, false, &text_layout_details);
+                });
+            });
+            editor.toggle_comments(&Default::default(), cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let anchor = selection_starts.remove(&selection.id).unwrap();
+                    selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
+                });
+            });
+        });
+    });
+}
+
+pub fn toggle_comments_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) {
+    vim.stop_recording();
+    vim.update_active_editor(cx, |_, editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            let mut original_positions: HashMap<_, _> = Default::default();
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
+                    original_positions.insert(selection.id, anchor);
+                    object.expand_selection(map, selection, around);
+                });
+            });
+            editor.toggle_comments(&Default::default(), cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let anchor = original_positions.remove(&selection.id).unwrap();
+                    selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
+                });
+            });
+        });
+    });
+}

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

@@ -71,6 +71,7 @@ pub enum Operator {
     Register,
     RecordRegister,
     ReplayRegister,
+    ToggleComments,
 }
 
 #[derive(Default, Clone)]
@@ -326,6 +327,7 @@ impl Operator {
             Operator::Register => "\"",
             Operator::RecordRegister => "q",
             Operator::ReplayRegister => "@",
+            Operator::ToggleComments => "gc",
         }
     }
 
@@ -351,7 +353,8 @@ impl Operator {
             | Operator::Uppercase
             | Operator::Object { .. }
             | Operator::ChangeSurrounds { target: None }
-            | Operator::OppositeCase => false,
+            | Operator::OppositeCase
+            | Operator::ToggleComments => false,
         }
     }
 }

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

@@ -1268,6 +1268,29 @@ async fn test_toggle_comments(cx: &mut gpui::TestAppContext) {
           "},
         Mode::Normal,
     );
+
+    // works with count
+    cx.simulate_keystrokes("g c 2 j");
+    cx.assert_state(
+        indoc! {"
+            // // Λ‡one
+            // two
+            // three
+            "},
+        Mode::Normal,
+    );
+
+    // works with motion object
+    cx.simulate_keystrokes("shift-g");
+    cx.simulate_keystrokes("g c g g");
+    cx.assert_state(
+        indoc! {"
+            // one
+            two
+            three
+            Λ‡"},
+        Mode::Normal,
+    );
 }
 
 #[gpui::test]

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

@@ -677,6 +677,7 @@ impl Vim {
                 | Operator::Lowercase
                 | Operator::Uppercase
                 | Operator::OppositeCase
+                | Operator::ToggleComments
         ) {
             self.start_recording(cx)
         };