From 4e4bfd6f4ea40e88aedf819f41dcba3d20835dc9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 27 Aug 2025 17:07:32 -0400 Subject: [PATCH] editor: Add "Wrap Selections in Tag" action (#36948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the ability for a user to select one or more blocks of text and wrap each selection in an HTML tag — which works by placing multiple cursors inside the open and close tags so the appropriate element name can be typed in to all places simultaneously. This is similar to the emmet "Wrap with Abbreviation" functionality discussed in #15588 but is a simpler version that does not rely on Emmet's language server. Here's a preview of the feature in action: https://github.com/user-attachments/assets/1931e717-136c-4766-a585-e4ba939d9adf Some notes and questions: - The current implementation is a hardcoded with regards to supported languages. I'd love some direction on how much of this information to push into the relevant language structs. - I can see this feature as something that languages added by an extension would want to enable support for — is this something you'd want? - The syntax is hardcoded to support HTML/XML/JSX-like languages. I don't suppose this is a problem but figured I'd point it out anyway. - I called it "Wrap in tag" but open to whatever naming you feel is appropriate. - The implementation doesn't use `manipulate_lines` — I wasn't sure how make use of that without extra overhead / bookkeeping — does this seem fine? - I could also investigate adding wrap in abbreviation support by communicating with the Emmet language server but I think I'll need some direction on how to handle Emmet's custom LSP message. I could do this either in addition to or instead of this feature — though imo this feature is a nice "shortcut" regardless. Release Notes: - Added a new "Wrap Selections in Tag" action that lets you wrap one or more selections in tags based on language. Works in HTML, JSX, and similar languages, and places cursors inside both opening and closing tags so you can type the tag name once and apply it everywhere. --------- Co-authored-by: Smit Barmase --- crates/editor/src/actions.rs | 4 +- crates/editor/src/editor.rs | 80 +++++++++++++ crates/editor/src/editor_tests.rs | 123 ++++++++++++++++++++ crates/editor/src/element.rs | 3 + crates/language/src/language.rs | 16 +++ crates/languages/src/javascript/config.toml | 1 + crates/languages/src/tsx/config.toml | 1 + crates/languages/src/typescript/config.toml | 1 + extensions/html/languages/html/config.toml | 1 + 9 files changed, 229 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ce02c4d2bf..3cc6c28464 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -753,6 +753,8 @@ actions!( UniqueLinesCaseInsensitive, /// Removes duplicate lines (case-sensitive). UniqueLinesCaseSensitive, - UnwrapSyntaxNode + UnwrapSyntaxNode, + /// Wraps selections in tag specified by language. + WrapSelectionsInTag ] ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 52549902dd..2d96ddf7a4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10447,6 +10447,86 @@ impl Editor { }) } + fn enable_wrap_selections_in_tag(&self, cx: &App) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + for selection in self.selections.disjoint_anchors().iter() { + if snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.as_ref()) + .is_some() + { + return true; + } + } + false + } + + fn wrap_selections_in_tag( + &mut self, + _: &WrapSelectionsInTag, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + + let snapshot = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + let mut boundaries = Vec::new(); + + for selection in self.selections.all::(cx).iter() { + let Some(wrap_config) = snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.clone()) + else { + continue; + }; + + let open_tag = format!("{}{}", wrap_config.start_prefix, wrap_config.start_suffix); + let close_tag = format!("{}{}", wrap_config.end_prefix, wrap_config.end_suffix); + + let start_before = snapshot.anchor_before(selection.start); + let end_after = snapshot.anchor_after(selection.end); + + edits.push((start_before..start_before, open_tag)); + edits.push((end_after..end_after, close_tag)); + + boundaries.push(( + start_before, + end_after, + wrap_config.start_prefix.len(), + wrap_config.end_suffix.len(), + )); + } + + if edits.is_empty() { + return; + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + buffer.snapshot(cx) + }); + + let mut new_selections = Vec::with_capacity(boundaries.len() * 2); + for (start_before, end_after, start_prefix_len, end_suffix_len) in + boundaries.into_iter() + { + let open_offset = start_before.to_offset(&buffer) + start_prefix_len; + let close_offset = end_after.to_offset(&buffer).saturating_sub(end_suffix_len); + new_selections.push(open_offset..open_offset); + new_selections.push(close_offset..close_offset); + } + + this.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context) { let Some(project) = self.project.clone() else { return; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 2cfdb92593..85471c7ce9 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4403,6 +4403,129 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + "}); + + cx.set_state(indoc! {" + teˇst + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + te<«ˇ»>st + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + «testˇ» «testˇ» + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + <«ˇ»>test <«ˇ»>test + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + <«ˇ»>test + test + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + «testˇ» + "}); +} + #[gpui::test] async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 91034829f7..a63c18e003 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -585,6 +585,9 @@ impl EditorElement { register_action(editor, window, Editor::edit_log_breakpoint); register_action(editor, window, Editor::enable_breakpoint); register_action(editor, window, Editor::disable_breakpoint); + if editor.read(cx).enable_wrap_selections_in_tag(cx) { + register_action(editor, window, Editor::wrap_selections_in_tag); + } } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) { diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 7ae77c9141..b349122193 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -720,6 +720,9 @@ pub struct LanguageConfig { /// How to soft-wrap long lines of text. #[serde(default)] pub soft_wrap: Option, + /// When set, selections can be wrapped using prefix/suffix pairs on both sides. + #[serde(default)] + pub wrap_characters: Option, /// The name of a Prettier parser that will be used for this language when no file path is available. /// If there's a parser name in the language settings, that will be used instead. #[serde(default)] @@ -923,6 +926,7 @@ impl Default for LanguageConfig { hard_tabs: None, tab_size: None, soft_wrap: None, + wrap_characters: None, prettier_parser_name: None, hidden: false, jsx_tag_auto_close: None, @@ -932,6 +936,18 @@ impl Default for LanguageConfig { } } +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct WrapCharactersConfig { + /// Opening token split into a prefix and suffix. The first caret goes + /// after the prefix (i.e., between prefix and suffix). + pub start_prefix: String, + pub start_suffix: String, + /// Closing token split into a prefix and suffix. The second caret goes + /// after the prefix (i.e., between prefix and suffix). + pub end_prefix: String, + pub end_suffix: String, +} + fn auto_indent_using_last_non_empty_line_default() -> bool { true } diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index 0df57d985e..128eac0e4d 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -6,6 +6,7 @@ first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index 5849b9842f..b5ef5bd56d 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -4,6 +4,7 @@ path_suffixes = ["tsx"] line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/typescript/config.toml b/crates/languages/src/typescript/config.toml index d7e3e4bd3d..2344f6209d 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/languages/src/typescript/config.toml @@ -5,6 +5,7 @@ first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/extensions/html/languages/html/config.toml b/extensions/html/languages/html/config.toml index f74db2888e..388949d95c 100644 --- a/extensions/html/languages/html/config.toml +++ b/extensions/html/languages/html/config.toml @@ -3,6 +3,7 @@ grammar = "html" path_suffixes = ["html", "htm", "shtml"] autoclose_before = ">})" block_comment = { start = "", tab_size = 0 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true },