editor: Add action to toggle block comments (#48752)

ozacod and ozacod created

Closes #4751

## Testing
- Manually tested by comparing the behaviors with vscode.
- Those requirements are added to unit tests.

Release Notes:

- Added action to toggle block comments

---------

Co-authored-by: ozacod <ozacod@users.noreply.github.com>

Change summary

assets/keymaps/default-linux.json               |   2 
assets/keymaps/default-macos.json               |   2 
assets/keymaps/default-windows.json             |   2 
assets/keymaps/vim.json                         |   8 
crates/editor/src/actions.rs                    |   6 
crates/editor/src/editor.rs                     | 193 ++++++++++++
crates/editor/src/editor_block_comment_tests.rs | 293 +++++++++++++++++++
crates/editor/src/element.rs                    |   1 
crates/grammars/src/c/config.toml               |   1 
crates/grammars/src/cpp/config.toml             |   1 
crates/grammars/src/go/config.toml              |   1 
crates/grammars/src/javascript/config.toml      |   2 
crates/grammars/src/jsonc/config.toml           |   1 
crates/grammars/src/markdown/config.toml        |   2 
crates/grammars/src/python/config.toml          |   1 
crates/grammars/src/rust/config.toml            |   1 
crates/grammars/src/tsx/config.toml             |   2 
crates/vim/src/normal.rs                        |  41 ++
crates/vim/src/normal/toggle_comments.rs        |  71 ++++
crates/vim/src/state.rs                         |   4 
crates/vim/src/test.rs                          | 128 ++++++++
crates/vim/src/vim.rs                           |  10 
22 files changed, 770 insertions(+), 3 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -529,6 +529,8 @@
       "ctrl-k ctrl-b": "editor::BlameHover",
       "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
       "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }],
+      "ctrl-k ctrl-/": "editor::ToggleBlockComments",
+      "shift-alt-a": "editor::ToggleBlockComments",
       "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "f2": "editor::Rename",

assets/keymaps/default-macos.json 🔗

@@ -579,6 +579,8 @@
       "cmd-k cmd-i": "editor::Hover",
       "cmd-k cmd-b": "editor::BlameHover",
       "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }],
+      "cmd-k cmd-/": "editor::ToggleBlockComments",
+      "shift-alt-a": "editor::ToggleBlockComments",
       "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "f2": "editor::Rename",

assets/keymaps/default-windows.json 🔗

@@ -530,6 +530,8 @@
       "ctrl-k ctrl-f": "editor::FormatSelections",
       "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
       "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }],
+      "ctrl-k ctrl-/": "editor::ToggleBlockComments",
+      "shift-alt-a": "editor::ToggleBlockComments",
       "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
       "f2": "editor::Rename",

assets/keymaps/vim.json 🔗

@@ -263,6 +263,7 @@
       "] c": "editor::GoToHunk",
       "[ c": "editor::GoToPreviousHunk",
       "g c": "vim::PushToggleComments",
+      "g b": "vim::PushToggleBlockComments",
     },
   },
   {
@@ -319,6 +320,7 @@
       "a": ["vim::PushObject", { "around": true }],
       "g shift-r": ["vim::Paste", { "preserve_clipboard": true }],
       "g c": "vim::ToggleComments",
+      "g b": "vim::ToggleBlockComments",
       "g q": "vim::Rewrap",
       "g w": "vim::Rewrap",
       "g ?": "vim::ConvertToRot13",
@@ -791,6 +793,12 @@
       "c": "vim::CurrentLine",
     },
   },
+  {
+    "context": "vim_operator == gb",
+    "bindings": {
+      "c": "vim::CurrentLine",
+    },
+  },
   {
     "context": "vim_operator == gR",
     "bindings": {

crates/editor/src/actions.rs 🔗

@@ -150,6 +150,12 @@ pub struct ToggleComments {
     pub ignore_indent: bool,
 }
 
+/// Toggles block comment markers for the selected text.
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
+#[serde(deny_unknown_fields)]
+pub struct ToggleBlockComments;
+
 /// Moves the cursor up by a specified number of lines.
 #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
 #[action(namespace = editor)]

crates/editor/src/editor.rs 🔗

@@ -48,6 +48,8 @@ mod code_completion_tests;
 #[cfg(test)]
 mod edit_prediction_tests;
 #[cfg(test)]
+mod editor_block_comment_tests;
+#[cfg(test)]
 mod editor_tests;
 mod signature_help;
 #[cfg(any(test, feature = "test-support"))]
@@ -16462,6 +16464,197 @@ impl Editor {
         Ok(())
     }
 
+    pub fn toggle_block_comments(
+        &mut self,
+        _: &ToggleBlockComments,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.read_only(cx) {
+            return;
+        }
+        self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
+        self.transact(window, cx, |this, _window, cx| {
+            let mut selections = this
+                .selections
+                .all::<MultiBufferPoint>(&this.display_snapshot(cx));
+            let mut edits = Vec::new();
+            let snapshot = this.buffer.read(cx).read(cx);
+            let empty_str: Arc<str> = Arc::default();
+            let mut markers_inserted = Vec::new();
+
+            for selection in &mut selections {
+                let start_point = selection.start;
+                let end_point = selection.end;
+
+                let Some(language) =
+                    snapshot.language_scope_at(Point::new(start_point.row, start_point.column))
+                else {
+                    continue;
+                };
+
+                let Some(BlockCommentConfig {
+                    start: comment_start,
+                    end: comment_end,
+                    ..
+                }) = language.block_comment()
+                else {
+                    continue;
+                };
+
+                let prefix_needle = comment_start.trim_end().as_bytes();
+                let suffix_needle = comment_end.trim_start().as_bytes();
+
+                // Collect full lines spanning the selection as the search region
+                let region_start = Point::new(start_point.row, 0);
+                let region_end = Point::new(
+                    end_point.row,
+                    snapshot.line_len(MultiBufferRow(end_point.row)),
+                );
+                let region_bytes: Vec<u8> = snapshot
+                    .bytes_in_range(region_start..region_end)
+                    .flatten()
+                    .copied()
+                    .collect();
+
+                let region_start_offset = snapshot.point_to_offset(region_start);
+                let start_byte = snapshot.point_to_offset(start_point) - region_start_offset;
+                let end_byte = snapshot.point_to_offset(end_point) - region_start_offset;
+
+                let mut is_commented = false;
+                let mut prefix_range = start_point..start_point;
+                let mut suffix_range = end_point..end_point;
+
+                // Find rightmost /* at or before the selection end
+                if let Some(prefix_pos) = region_bytes[..end_byte.min(region_bytes.len())]
+                    .windows(prefix_needle.len())
+                    .rposition(|w| w == prefix_needle)
+                {
+                    let after_prefix = prefix_pos + prefix_needle.len();
+
+                    // Find the first */ after that /*
+                    if let Some(suffix_pos) = region_bytes[after_prefix..]
+                        .windows(suffix_needle.len())
+                        .position(|w| w == suffix_needle)
+                        .map(|p| p + after_prefix)
+                    {
+                        let suffix_end = suffix_pos + suffix_needle.len();
+
+                        // Case 1: /* ... */ surrounds the selection
+                        let markers_surround = prefix_pos <= start_byte
+                            && suffix_end >= end_byte
+                            && start_byte < suffix_end;
+
+                        // Case 2: selection contains /* ... */ (only whitespace padding)
+                        let selection_contains = start_byte <= prefix_pos
+                            && suffix_end <= end_byte
+                            && region_bytes[start_byte..prefix_pos]
+                                .iter()
+                                .all(|&b| b.is_ascii_whitespace())
+                            && region_bytes[suffix_end..end_byte]
+                                .iter()
+                                .all(|&b| b.is_ascii_whitespace());
+
+                        if markers_surround || selection_contains {
+                            is_commented = true;
+                            let prefix_pt =
+                                snapshot.offset_to_point(region_start_offset + prefix_pos);
+                            let suffix_pt =
+                                snapshot.offset_to_point(region_start_offset + suffix_pos);
+                            prefix_range = prefix_pt
+                                ..Point::new(
+                                    prefix_pt.row,
+                                    prefix_pt.column + prefix_needle.len() as u32,
+                                );
+                            suffix_range = suffix_pt
+                                ..Point::new(
+                                    suffix_pt.row,
+                                    suffix_pt.column + suffix_needle.len() as u32,
+                                );
+                        }
+                    }
+                }
+
+                if is_commented {
+                    // Also remove the space after /* and before */
+                    if snapshot
+                        .bytes_in_range(prefix_range.end..snapshot.max_point())
+                        .flatten()
+                        .next()
+                        == Some(&b' ')
+                    {
+                        prefix_range.end.column += 1;
+                    }
+                    if suffix_range.start.column > 0 {
+                        let before =
+                            Point::new(suffix_range.start.row, suffix_range.start.column - 1);
+                        if snapshot
+                            .bytes_in_range(before..suffix_range.start)
+                            .flatten()
+                            .next()
+                            == Some(&b' ')
+                        {
+                            suffix_range.start.column -= 1;
+                        }
+                    }
+
+                    edits.push((prefix_range, empty_str.clone()));
+                    edits.push((suffix_range, empty_str.clone()));
+                } else {
+                    let prefix: Arc<str> = if comment_start.ends_with(' ') {
+                        comment_start.clone()
+                    } else {
+                        format!("{} ", comment_start).into()
+                    };
+                    let suffix: Arc<str> = if comment_end.starts_with(' ') {
+                        comment_end.clone()
+                    } else {
+                        format!(" {}", comment_end).into()
+                    };
+
+                    edits.push((start_point..start_point, prefix.clone()));
+                    edits.push((end_point..end_point, suffix.clone()));
+                    markers_inserted.push((
+                        selection.id,
+                        prefix.len(),
+                        suffix.len(),
+                        selection.is_empty(),
+                        end_point.row,
+                    ));
+                }
+            }
+
+            drop(snapshot);
+            this.buffer.update(cx, |buffer, cx| {
+                buffer.edit(edits, None, cx);
+            });
+
+            let mut selections = this
+                .selections
+                .all::<MultiBufferPoint>(&this.display_snapshot(cx));
+            for selection in &mut selections {
+                if let Some((_, prefix_len, suffix_len, was_empty, suffix_row)) = markers_inserted
+                    .iter()
+                    .find(|(id, _, _, _, _)| *id == selection.id)
+                {
+                    if *was_empty {
+                        selection.start.column = selection
+                            .start
+                            .column
+                            .saturating_sub((*prefix_len + *suffix_len) as u32);
+                    } else {
+                        selection.start.column =
+                            selection.start.column.saturating_sub(*prefix_len as u32);
+                        if selection.end.row == *suffix_row {
+                            selection.end.column += *suffix_len as u32;
+                        }
+                    }
+                }
+            }
+            this.change_selections(Default::default(), _window, cx, |s| s.select(selections));
+        });
+    }
+
     pub fn toggle_comments(
         &mut self,
         action: &ToggleComments,

crates/editor/src/editor_block_comment_tests.rs 🔗

@@ -0,0 +1,293 @@
+use crate::ToggleBlockComments;
+use crate::editor_tests::init_test;
+use crate::test::editor_test_context::EditorTestContext;
+use gpui::TestAppContext;
+use indoc::indoc;
+use language::{BlockCommentConfig, Language, LanguageConfig};
+use std::sync::Arc;
+
+async fn setup_rust_context(cx: &mut TestAppContext) -> EditorTestContext {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let rust_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            block_comment: Some(BlockCommentConfig {
+                start: "/* ".into(),
+                prefix: "".into(),
+                end: " */".into(),
+                tab_size: 0,
+            }),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    ));
+
+    cx.language_registry().add(rust_language.clone());
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language(Some(rust_language), cx);
+    });
+
+    cx
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+        fn main() {
+            let x = «1ˇ» + 2;
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            let x = «/* 1 */ˇ» + 2;
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            let x = «1ˇ» + 2;
+        }
+    "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_with_selection(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+        fn main() {
+            «let x = 1 + 2;ˇ»
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            «/* let x = 1 + 2; */ˇ»
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            «let x = 1 + 2;ˇ»
+        }
+    "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_multiline(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+        «fn main() {
+            let x = 1;
+        }ˇ»
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        «/* fn main() {
+            let x = 1;
+        } */ˇ»
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        «fn main() {
+            let x = 1;
+        }ˇ»
+    "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_cursor_inside(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+            fn main() {
+                let x = /* 1ˇ */ + 2;
+            }
+        "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+            fn main() {
+                let x = 1ˇ + 2;
+            }
+        "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_multiple_cursors(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+            fn main() {
+                let x = «1ˇ» + 2;
+                let y = «3ˇ» + 4;
+            }
+        "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+            fn main() {
+                let x = «/* 1 */ˇ» + 2;
+                let y = «/* 3 */ˇ» + 4;
+            }
+        "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            let x = «1ˇ» + 2;
+            let y = «3ˇ» + 4;
+        }
+    "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_selection_ending_on_empty_line(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+        «fn main() {
+        ˇ»
+            let x = 1;
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        «/* fn main() {
+         */ˇ»
+            let x = 1;
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        «fn main() {
+        ˇ»
+            let x = 1;
+        }
+    "});
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_empty_selection_roundtrip(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state(indoc! {"
+        fn main() {
+            let x = ˇ1 + 2;
+        }
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state(indoc! {"
+        fn main() {
+            let x = ˇ1 + 2;
+        }
+    "});
+}
+
+// Multi-byte Unicode characters (√ is 3 bytes in UTF-8) must not cause
+// incorrect offset arithmetic or panics.
+#[gpui::test]
+async fn test_toggle_block_comments_unicode_before_selection(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state("let √ = «42ˇ»;");
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state("let √ = «/* 42 */ˇ»;");
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state("let √ = «42ˇ»;");
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_unicode_in_selection(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state("«√√√ˇ»");
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state("«/* √√√ */ˇ»");
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state("«√√√ˇ»");
+}
+
+#[gpui::test]
+async fn test_toggle_block_comments_cursor_inside_unicode_comment(cx: &mut TestAppContext) {
+    let mut cx = setup_rust_context(cx).await;
+
+    cx.set_state("/* √√√ˇ */");
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_block_comments(&ToggleBlockComments, window, cx);
+    });
+
+    cx.assert_editor_state("√√√ˇ");
+}

crates/editor/src/element.rs 🔗

@@ -397,6 +397,7 @@ impl EditorElement {
             editor.find_previous_match(action, window, cx).log_err();
         });
         register_action(editor, window, Editor::toggle_comments);
+        register_action(editor, window, Editor::toggle_block_comments);
         register_action(editor, window, Editor::select_larger_syntax_node);
         register_action(editor, window, Editor::select_smaller_syntax_node);
         register_action(editor, window, Editor::select_next_syntax_node);

crates/grammars/src/c/config.toml 🔗

@@ -16,4 +16,5 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
+block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 1 }
 documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/grammars/src/cpp/config.toml 🔗

@@ -18,4 +18,5 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
+block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 1 }
 documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/grammars/src/go/config.toml 🔗

@@ -17,4 +17,5 @@ brackets = [
 tab_size = 4
 hard_tabs = true
 debuggers = ["Delve"]
+block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 1 }
 documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/grammars/src/javascript/config.toml 🔗

@@ -36,7 +36,7 @@ linked_edit_characters = ["."]
 
 [overrides.element]
 line_comments = { remove = true }
-block_comment = { start = "{/* ", prefix = "", end = "*/}", tab_size = 0 }
+block_comment = { start = "{/* ", prefix = "", end = "*/}", tab_size = 1 }
 opt_into_language_servers = ["emmet-language-server"]
 
 [overrides.string]

crates/grammars/src/jsonc/config.toml 🔗

@@ -2,6 +2,7 @@ name = "JSONC"
 grammar = "jsonc"
 path_suffixes = ["jsonc", "bun.lock", "devcontainer.json", "pyrightconfig.json", "tsconfig.json", "luaurc", "swcrc", "babelrc", "eslintrc", "stylelintrc", "jshintrc"]
 line_comments = ["// "]
+block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 1 }
 autoclose_before = ",]}"
 brackets = [
     { start = "{", end = "}", close = true, surround = true, newline = true },

crates/grammars/src/markdown/config.toml 🔗

@@ -3,7 +3,7 @@ grammar = "markdown"
 path_suffixes = ["md", "mdx", "mdwn", "mdc", "markdown", "MD"]
 modeline_aliases = ["md"]
 completion_query_characters = ["-"]
-block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
+block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 1 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },

crates/grammars/src/python/config.toml 🔗

@@ -4,6 +4,7 @@ path_suffixes = ["py", "pyi", "mpy"]
 first_line_pattern = '^#!.*((\bpython[0-9.]*\b)|(\buv run\b))'
 modeline_aliases = ["py"]
 line_comments = ["# "]
+block_comment = { start = "\"\"\"", end = "\"\"\"", prefix = "", tab_size = 1 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "f\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] },

crates/grammars/src/rust/config.toml 🔗

@@ -17,4 +17,5 @@ brackets = [
 ]
 collapsed_placeholder = " /* ... */ "
 debuggers = ["CodeLLDB", "GDB"]
+block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
 documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/grammars/src/tsx/config.toml 🔗

@@ -35,7 +35,7 @@ linked_edit_characters = ["."]
 
 [overrides.element]
 line_comments = { remove = true }
-block_comment = { start = "{/*", prefix = "", end = "*/}", tab_size = 0 }
+block_comment = { start = "{/*", prefix = "", end = "*/}", tab_size = 1 }
 opt_into_language_servers = ["emmet-language-server"]
 
 [overrides.string]

crates/vim/src/normal.rs 🔗

@@ -88,6 +88,8 @@ actions!(
         ConvertToRot47,
         /// Toggles comments for selected lines.
         ToggleComments,
+        /// Toggles block comments for selected lines.
+        ToggleBlockComments,
         /// Shows the current location in the file.
         ShowLocation,
         /// Undoes the last change.
@@ -125,6 +127,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::yank_line);
     Vim::action(editor, cx, Vim::yank_to_end_of_line);
     Vim::action(editor, cx, Vim::toggle_comments);
+    Vim::action(editor, cx, Vim::toggle_block_comments);
     Vim::action(editor, cx, Vim::paste);
     Vim::action(editor, cx, Vim::show_location);
 
@@ -463,6 +466,9 @@ impl Vim {
             Some(Operator::ToggleComments) => {
                 self.toggle_comments_motion(motion, times, forced_motion, window, cx)
             }
+            Some(Operator::ToggleBlockComments) => {
+                self.toggle_block_comments_motion(motion, times, forced_motion, window, cx)
+            }
             Some(Operator::ReplaceWithRegister) => {
                 self.replace_with_register_motion(motion, times, forced_motion, window, cx)
             }
@@ -533,6 +539,9 @@ impl Vim {
                 Some(Operator::ToggleComments) => {
                     self.toggle_comments_object(object, around, times, window, cx)
                 }
+                Some(Operator::ToggleBlockComments) => {
+                    self.toggle_block_comments_object(object, around, times, window, cx)
+                }
                 Some(Operator::ReplaceWithRegister) => {
                     self.replace_with_register_object(object, around, window, cx)
                 }
@@ -985,6 +994,38 @@ impl Vim {
         }
     }
 
+    fn toggle_block_comments(
+        &mut self,
+        _: &ToggleBlockComments,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.record_current_action(cx);
+        self.store_visual_marks(window, cx);
+        let is_visual_line = self.mode == Mode::VisualLine;
+        self.update_editor(cx, |vim, editor, cx| {
+            editor.transact(window, cx, |editor, window, cx| {
+                let original_positions = vim.save_selection_starts(editor, cx);
+                if is_visual_line {
+                    editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                        s.move_with(&mut |map, selection| {
+                            let start_row = selection.start.to_point(map).row;
+                            let end_row = selection.end.to_point(map).row;
+                            let end_col = map.buffer_snapshot().line_len(MultiBufferRow(end_row));
+                            selection.start = Point::new(start_row, 0).to_display_point(map);
+                            selection.end = Point::new(end_row, end_col).to_display_point(map);
+                        });
+                    });
+                }
+                editor.toggle_block_comments(&Default::default(), window, cx);
+                vim.restore_selection_cursors(editor, window, cx, original_positions);
+            });
+        });
+        if self.mode.is_visual() {
+            self.switch_mode(Mode::Normal, true, window, cx)
+        }
+    }
+
     pub(crate) fn normal_replace(
         &mut self,
         text: Arc<str>,

crates/vim/src/normal/toggle_comments.rs 🔗

@@ -71,4 +71,75 @@ impl Vim {
             });
         });
     }
+
+    pub fn toggle_block_comments_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        forced_motion: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_editor(cx, |_, editor, cx| {
+            let text_layout_details = editor.text_layout_details(window, cx);
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                let mut selection_starts: HashMap<_, _> = Default::default();
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.move_with(&mut |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,
+                            &text_layout_details,
+                            forced_motion,
+                        );
+                    });
+                });
+                editor.toggle_block_comments(&Default::default(), window, cx);
+                editor.set_clip_at_line_ends(true, cx);
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.move_with(&mut |map, selection| {
+                        let anchor = selection_starts.remove(&selection.id).unwrap();
+                        selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None);
+                    });
+                });
+            });
+        });
+    }
+
+    pub fn toggle_block_comments_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        times: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                let mut original_positions: HashMap<_, _> = Default::default();
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.move_with(&mut |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, times);
+                    });
+                });
+                editor.toggle_block_comments(&Default::default(), window, cx);
+                editor.set_clip_at_line_ends(true, cx);
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.move_with(&mut |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 🔗

@@ -138,6 +138,7 @@ pub enum Operator {
     RecordRegister,
     ReplayRegister,
     ToggleComments,
+    ToggleBlockComments,
     ReplaceWithRegister,
     Exchange,
     HelixMatch,
@@ -1078,6 +1079,7 @@ impl Operator {
             Operator::RecordRegister => "q",
             Operator::ReplayRegister => "@",
             Operator::ToggleComments => "gc",
+            Operator::ToggleBlockComments => "gb",
             Operator::HelixMatch => "helix_m",
             Operator::HelixNext { .. } => "helix_next",
             Operator::HelixPrevious { .. } => "helix_previous",
@@ -1157,6 +1159,7 @@ impl Operator {
             | Operator::ChangeSurrounds { target: None, .. }
             | Operator::OppositeCase
             | Operator::ToggleComments
+            | Operator::ToggleBlockComments
             | Operator::HelixMatch
             | Operator::HelixNext { .. }
             | Operator::HelixPrevious { .. } => false,
@@ -1180,6 +1183,7 @@ impl Operator {
             | Operator::Rot13
             | Operator::Rot47
             | Operator::ToggleComments
+            | Operator::ToggleBlockComments
             | Operator::ReplaceWithRegister
             | Operator::Rewrap
             | Operator::ShellCommand

crates/vim/src/test.rs 🔗

@@ -1694,6 +1694,134 @@ async fn test_toggle_comments(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[perf]
+#[gpui::test]
+async fn test_toggle_block_comments(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    let language = std::sync::Arc::new(language::Language::new(
+        language::LanguageConfig {
+            block_comment: Some(language::BlockCommentConfig {
+                start: "/* ".into(),
+                prefix: "".into(),
+                end: " */".into(),
+                tab_size: 1,
+            }),
+            ..Default::default()
+        },
+        Some(language::tree_sitter_rust::LANGUAGE.into()),
+    ));
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // works in normal mode with current-line shorthand
+    cx.set_state(
+        indoc! {"
+        ˇone
+        two
+        three
+        "},
+        Mode::Normal,
+    );
+    cx.simulate_keystrokes("g b c");
+    cx.assert_state(
+        indoc! {"
+        /* ˇone */
+        two
+        three
+        "},
+        Mode::Normal,
+    );
+
+    // toggle off with cursor inside the comment
+    cx.simulate_keystrokes("g b c");
+    cx.assert_state(
+        indoc! {"
+        ˇone
+        two
+        three
+        "},
+        Mode::Normal,
+    );
+
+    // works in visual line mode (wraps full lines)
+    cx.simulate_keystrokes("shift-v j g b");
+    cx.assert_state(
+        indoc! {"
+        /* ˇone
+        two */
+        three
+        "},
+        Mode::Normal,
+    );
+
+    // works in visual mode and restores the cursor to the selection start
+    cx.set_state(
+        indoc! {"
+        «oneˇ»
+        two
+        three
+        "},
+        Mode::Visual,
+    );
+    cx.simulate_keystrokes("g b");
+    cx.assert_state(
+        indoc! {"
+        /* ˇone */
+        two
+        three
+        "},
+        Mode::Normal,
+    );
+
+    // works with multiple visual selections and restores each cursor
+    cx.set_state(
+        indoc! {"
+        «oneˇ» «twoˇ»
+        three
+        "},
+        Mode::Visual,
+    );
+    cx.simulate_keystrokes("g b");
+    cx.assert_state(
+        indoc! {"
+        /* ˇone */ /* ˇtwo */
+        three
+        "},
+        Mode::Normal,
+    );
+
+    // works with count
+    cx.set_state(
+        indoc! {"
+        ˇone
+        two
+        three
+        "},
+        Mode::Normal,
+    );
+    cx.simulate_keystrokes("g b 2 j");
+    cx.assert_state(
+        indoc! {"
+        /* ˇone
+        two
+        three */
+        "},
+        Mode::Normal,
+    );
+
+    // works with motion object
+    cx.simulate_keystrokes("shift-g");
+    cx.simulate_keystrokes("g b g g");
+    cx.assert_state(
+        indoc! {"
+        one
+        two
+        three
+        ˇ"},
+        Mode::Normal,
+    );
+}
+
 #[perf]
 #[gpui::test]
 async fn test_find_multibyte(cx: &mut gpui::TestAppContext) {

crates/vim/src/vim.rs 🔗

@@ -247,6 +247,8 @@ actions!(
         PushReplaceWithRegister,
         /// Toggles comments.
         PushToggleComments,
+        /// Toggles block comments.
+        PushToggleBlockComments,
         /// Selects (count) next menu item
         MenuSelectNext,
         /// Selects (count) previous menu item
@@ -899,6 +901,14 @@ impl Vim {
                 vim.push_operator(Operator::ToggleComments, window, cx)
             });
 
+            Vim::action(
+                editor,
+                cx,
+                |vim, _: &PushToggleBlockComments, window, cx| {
+                    vim.push_operator(Operator::ToggleBlockComments, window, cx)
+                },
+            );
+
             Vim::action(editor, cx, |vim, _: &ClearOperators, window, cx| {
                 vim.clear_operator(window, cx)
             });