Detailed changes
@@ -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 },