editor: Add "Wrap Selections in Tag" action (#36948)
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 <heysmitbarmase@gmail.com>
This commit is contained in:
@@ -753,6 +753,8 @@ actions!(
|
||||
UniqueLinesCaseInsensitive,
|
||||
/// Removes duplicate lines (case-sensitive).
|
||||
UniqueLinesCaseSensitive,
|
||||
UnwrapSyntaxNode
|
||||
UnwrapSyntaxNode,
|
||||
/// Wraps selections in tag specified by language.
|
||||
WrapSelectionsInTag
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
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::<Point>(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<Self>) {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
|
||||
@@ -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(),
|
||||
end_suffix: ">".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(),
|
||||
end_suffix: ">".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, |_| {});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -720,6 +720,9 @@ pub struct LanguageConfig {
|
||||
/// How to soft-wrap long lines of text.
|
||||
#[serde(default)]
|
||||
pub soft_wrap: Option<SoftWrap>,
|
||||
/// When set, selections can be wrapped using prefix/suffix pairs on both sides.
|
||||
#[serde(default)]
|
||||
pub wrap_characters: Option<WrapCharactersConfig>,
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -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 = "</", end_suffix = ">" }
|
||||
autoclose_before = ";:.,=}])>"
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
|
||||
@@ -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 = "</", end_suffix = ">" }
|
||||
autoclose_before = ";:.,=}])>"
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
|
||||
@@ -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 = "</", end_suffix = ">" }
|
||||
autoclose_before = ";:.,=}])>"
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
|
||||
@@ -3,6 +3,7 @@ grammar = "html"
|
||||
path_suffixes = ["html", "htm", "shtml"]
|
||||
autoclose_before = ">})"
|
||||
block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
|
||||
wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
{ start = "[", end = "]", close = true, newline = true },
|
||||
|
||||
Reference in New Issue
Block a user