diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ce02c4d2bf..9b6d829d2f 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 in an HTML tag. + WrapInTag ] ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 29e009fdf8..621af1394e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10447,6 +10447,132 @@ impl Editor { }) } + pub fn supports_wrap_in_tag(&self, cx: &App) -> bool { + let Some((_, buffer, _)) = self.active_excerpt(cx) else { + return false; + }; + + let Some(language) = buffer.read(cx).language() else { + return false; + }; + + let tag_languages = vec![ + "astro", + "html", + "javascript", + "typescript", + "svelte", + "tsx", + "vue.js", + ]; + + let lsp_id = language.lsp_id(); + + return tag_languages.into_iter().any(|s| s == lsp_id); + } + + pub fn wrap_in_tag(&mut self, _: &WrapInTag, window: &mut Window, cx: &mut Context) { + if !self.supports_wrap_in_tag(cx) { + return; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut edits = Vec::new(); + + let selections = self.selections.all::(cx); + let mut selections = selections.iter(); + + let mut new_selections = Vec::new(); + let mut selection_id = 0; + let mut last_row = 0; + let mut col_offset = 0; + + while let Some(selection) = selections.next() { + let mut start_point = selection.start; + let mut end_point = selection.end; + + let mut text = buffer + .text_for_range(start_point..end_point) + .collect::(); + + text.insert_str(0, "<>"); + text.push_str(""); + + edits.push((start_point..end_point, text)); + + // When this selection is on a different row than the previous one + // there's no need to offset subsequent selections + if end_point.row != last_row { + col_offset = 0; + } + + start_point.column += col_offset; + end_point.column += col_offset; + + // Put cursor into the opening tag + start_point.column += 1; + col_offset += 1; + new_selections.push(Selection { + id: selection_id, + start: start_point, + end: start_point, + goal: SelectionGoal::None, + reversed: false, + }); + + selection_id += 1; + + // When start and end are on the same row then the column + // needs to be offset by two additional chars. + if start_point.row == end_point.row { + end_point.column += 2; + col_offset += 2; + } + + // Put another cursor into the closing tag + end_point.column += 2; + col_offset += 2; + + new_selections.push(Selection { + id: selection_id, + start: end_point, + end: end_point, + goal: SelectionGoal::None, + reversed: false, + }); + + selection_id += 1; + last_row = end_point.row; + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + buffer.snapshot(cx) + }); + + // Recalculate offsets on newly edited buffer + let new_selections = new_selections + .iter() + .map(|s| Selection { + id: s.id, + start: buffer.point_to_offset(s.start), + end: buffer.point_to_offset(s.end), + goal: s.goal, + reversed: s.reversed, + }) + .collect(); + + this.change_selections(Default::default(), window, cx, |s| { + s.select(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..dbab2ccbf7 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4403,6 +4403,109 @@ 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(), + ..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_in_tag(&WrapInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_in_tag(&WrapInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + "}); +} + +#[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(), + ..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_in_tag(&WrapInTag, 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_in_tag(&WrapInTag, 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_in_tag(&WrapInTag, 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 4f3580da07..c5609f0033 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).supports_wrap_in_tag(cx) { + register_action(editor, window, Editor::wrap_in_tag); + } } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) {