From 24f4b66fa6ab76424d6151d5556003fa50b6275b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 4 Jun 2026 19:34:13 -0400 Subject: [PATCH] Add action to toggle block quotes in Markdown (#58383) This PR adds an action to toggle block quotes on and off for the selected lines while in Markdown. I'm using my own binding in the demo video, this PR does not bind it to anything. https://github.com/user-attachments/assets/5c1f4121-e863-459d-8a9b-8eefdbfb888e When there's a mixture of quoted and "unquoted" lines, we normalize to quoted: https://github.com/user-attachments/assets/ca003844-0c4f-4cae-a7d0-a9d355258f2d While adding this, I noticed that `editor.rs` had been through some refactoring in order to reduce its size. Because of this, I introduced `markdown_actions.rs`, as I intend to follow up with some other markdown toggling actions (eg: `markdown: toggle bold`, `markdown: toggle italic`, etc.) The implementation reuses the existing `manipulate_mutable_lines` code that all line manipulating actions run through. Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [X] Unsafe blocks (if any) have justifying comments - [X] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Release Notes: - Added a `markdown: toggle block quote` action --- crates/editor/src/actions.rs | 9 +++ crates/editor/src/editor.rs | 1 + crates/editor/src/editor_tests.rs | 96 +++++++++++++++++++++++++++ crates/editor/src/element.rs | 1 + crates/editor/src/markdown_actions.rs | 56 ++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 crates/editor/src/markdown_actions.rs 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") + } +}