diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 2ed935eb34..e4e280851d 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -397,6 +397,15 @@ actions!( ] ); +actions!( + markdown, + [ + /// Toggles a block quote (`> `) prefix on the selected lines (or the + /// current line) while in Markdown files. + ToggleBlockQuote, + ] +); + actions!( editor, [ diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1931146eb1..8eca77a5fa 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -66,6 +66,7 @@ mod config; mod diagnostics; mod edit_prediction; mod input; +mod markdown_actions; mod navigation; mod rewrap; mod selection; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8049ba14b0..1ce8c26a1b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -38027,3 +38027,99 @@ async fn test_toggle_diagnostics_persists_across_settings_change(cx: &mut TestAp ); }); } + +#[gpui::test] +async fn test_toggle_markdown_block_quote(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // No-op with no language + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + // No-op in non-Markdown language (Rust) + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); + + // Line is quoted with an empty selection + cx.set_state(indoc! {" + helˇlo world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> hello worldˇ» + "}); + + // Line is unquoted with an empty selection + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); + + // Multi-line selection is quoted, including blank lines + cx.set_state(indoc! {" + «first + + thirdˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > + > thirdˇ» + "}); + + // Multi-line selection is unquoted, including blank lines + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + + thirdˇ» + "}); + + // A multi-line selection, including a mixture of quoted and unquoted lines + // and a mixture of empty and non-empty lines, normalizes each line to a + // single quote. + cx.set_state(indoc! {" + «> first + second + > + + > third + >fourthˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > second + > + > + > third + > fourthˇ» + "}); + + // A multi-line selection is unquoted. + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + second + + + third + fourthˇ» + "}); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0ac3b2691d..29fce5e6ad 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -571,6 +571,7 @@ impl EditorElement { register_action(editor, window, Editor::redo); register_action(editor, window, Editor::toggle_comments); register_action(editor, window, Editor::toggle_block_comments); + register_action(editor, window, Editor::toggle_markdown_block_quote); register_action(editor, window, Editor::unwrap_syntax_node); register_action(editor, window, Editor::accept_next_word_edit_prediction); register_action(editor, window, Editor::accept_next_line_edit_prediction); diff --git a/crates/editor/src/markdown_actions.rs b/crates/editor/src/markdown_actions.rs new file mode 100644 index 0000000000..2b6af38d0a --- /dev/null +++ b/crates/editor/src/markdown_actions.rs @@ -0,0 +1,56 @@ +use super::*; + +impl Editor { + pub fn toggle_markdown_block_quote( + &mut self, + _: &ToggleBlockQuote, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_mutable_lines_in_markdown(window, cx, |lines| { + let all_lines_quoted = lines.iter().all(|line| line.starts_with('>')); + + for line in lines.iter_mut() { + let stripped_line = match line.strip_prefix("> ").or_else(|| line.strip_prefix('>')) + { + Some(rest) => rest.to_string(), + None => line.to_string(), + }; + + *line = if all_lines_quoted { + Cow::Owned(stripped_line) + } else if stripped_line.trim().is_empty() { + Cow::Borrowed(">") + } else { + Cow::Owned(format!("> {stripped_line}")) + }; + } + }); + } + + fn manipulate_mutable_lines_in_markdown( + &mut self, + window: &mut Window, + cx: &mut Context, + callback: Fn, + ) where + Fn: FnMut(&mut Vec>), + { + if !self.is_in_markdown_language(cx) { + return; + } + + self.manipulate_mutable_lines(window, cx, callback); + } + + fn is_in_markdown_language(&self, cx: &mut App) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + let head = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + snapshot + .language_at(head) + .is_some_and(|language| language.name() == "Markdown") + } +}