Improve context menu experience in the markdown preview (#59993)

* removes useless "no items" pop-up and does not show any menu at all if
no actions are applicable.
This required adding `pub fn maybe_menu` into the 

* adds actions to copy rendered text and markdown text: in fact,
`cmd/ctrl-c` work already but we did not expose it

Before:


https://github.com/user-attachments/assets/906f2bfd-bf19-48de-bbb5-85bb33431f60

After:


https://github.com/user-attachments/assets/2c039c4d-2d42-4689-9ac1-12e3b10119cf

Release Notes:

- Improved context menu experience in the markdown preview
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-06-27 10:09:13 +00:00
1 parent 8eb85ac9e2
commit 18c28c3e63
3 files changed
+114 -22

No files matched your search

+61 -15
View File
@@ -399,7 +399,8 @@ pub struct Markdown {
wrapped_code_blocks: HashSet<usize>,
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
context_menu_link: Option<SharedString>,
context_menu_selected_text: Option<String>,
context_menu_selected_text: Option<SharedString>,
context_menu_selected_markdown: Option<SharedString>,
search_highlights: Vec<Range<usize>>,
active_search_highlight: Option<usize>,
}
@@ -592,6 +593,7 @@ impl Markdown {
code_block_scroll_handles: BTreeMap::default(),
context_menu_link: None,
context_menu_selected_text: None,
context_menu_selected_markdown: None,
search_highlights: Vec::new(),
active_search_highlight: None,
};
@@ -864,8 +866,8 @@ impl Markdown {
}
fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = self.context_menu_selected_text.take() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
if let Some(text) = self.context_menu_selected_markdown.take() {
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
return;
}
if self.selection.end <= self.selection.start {
@@ -875,8 +877,23 @@ impl Markdown {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn capture_for_context_menu(&mut self, link: Option<SharedString>) {
self.context_menu_selected_text = self.selected_text();
fn capture_for_context_menu(
&mut self,
link: Option<SharedString>,
rendered_text: Option<&RenderedText>,
) {
let range = self.selection.start..self.selection.end;
if range.end > range.start {
self.context_menu_selected_markdown =
Some(SharedString::new(&self.source[range.clone()]));
self.context_menu_selected_text = rendered_text
.map(|text| text.text_for_range(range))
.map(SharedString::new)
.or_else(|| self.context_menu_selected_markdown.clone());
} else {
self.context_menu_selected_markdown = None;
self.context_menu_selected_text = None;
}
self.context_menu_link = link;
}
@@ -887,6 +904,18 @@ impl Markdown {
self.context_menu_link.as_ref()
}
/// Returns the rendered (plain) text that was selected when the most recent
/// context menu invocation happened.
pub fn context_menu_selected_text(&self) -> Option<&SharedString> {
self.context_menu_selected_text.as_ref()
}
/// Returns the raw markdown source that was selected when the most recent
/// context menu invocation happened.
pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> {
self.context_menu_selected_markdown.as_ref()
}
fn parse(&mut self, cx: &mut Context<Self>) {
if self.source.is_empty() {
self.should_reparse = false;
@@ -1730,7 +1759,7 @@ impl MarkdownElement {
.ok()
.and_then(|ix| rendered_text.link_for_source_index(ix))
.map(|link| link.destination_url.clone());
markdown.capture_for_context_menu(link);
markdown.capture_for_context_menu(link, Some(&rendered_text));
}
}
});
@@ -5194,30 +5223,47 @@ mod tests {
fn test_capture_for_context_menu(cx: &mut TestAppContext) {
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| Markdown::new("text".into(), None, None, cx));
let markdown = cx.new(|cx| Markdown::new("some text".into(), None, None, cx));
cx.run_until_parked();
// Simulates right-clicking on a link
// Simulates right-clicking on a link, with "text" selected
let url: SharedString = "https://example.com".into();
markdown.update(cx, |md, _cx| {
md.capture_for_context_menu(Some(url.clone()));
md.selection.start = 5;
md.selection.end = 9;
md.capture_for_context_menu(Some(url.clone()), None);
});
cx.update(|_window, cx| {
let markdown = markdown.read(cx);
assert_eq!(
markdown.context_menu_link().map(SharedString::as_ref),
Some("https://example.com")
);
assert_eq!(
markdown
.read(cx)
.context_menu_link()
.context_menu_selected_markdown()
.map(SharedString::as_ref),
Some("https://example.com")
Some("text")
);
assert_eq!(
markdown
.context_menu_selected_text()
.map(SharedString::as_ref),
Some("text")
);
});
// Simulates right-clicking on plain text — link is cleared
// Simulates right-clicking on plain text with no selection — everything is cleared
markdown.update(cx, |md, _cx| {
md.capture_for_context_menu(None);
md.selection.start = 0;
md.selection.end = 0;
md.capture_for_context_menu(None, None);
});
cx.update(|_window, cx| {
assert!(markdown.read(cx).context_menu_link().is_none());
let markdown = markdown.read(cx);
assert!(markdown.context_menu_link().is_none());
assert!(markdown.context_menu_selected_markdown().is_none());
assert!(markdown.context_menu_selected_text().is_none());
});
}
@@ -1260,12 +1260,48 @@ impl Render for MarkdownPreviewView {
let max_width = MarkdownPreviewSettings::get_global(cx).max_width;
let content = right_click_menu("markdown-preview-context-menu")
.trigger(move |_, _, _| markdown_element)
.menu(move |window, cx| {
.maybe_menu(move |window, cx| {
let focus = window.focused(cx);
let context_menu_link =
markdown.read(cx).context_menu_link().cloned();
ContextMenu::build(window, cx, move |menu, _, _cx| {
let markdown = markdown.read(cx);
let context_menu_link = markdown.context_menu_link().cloned();
let selected_text =
markdown.context_menu_selected_text().cloned();
let selected_markdown =
markdown.context_menu_selected_markdown().cloned();
if context_menu_link.is_none()
&& selected_text.is_none()
&& selected_markdown.is_none()
{
return None;
}
Some(ContextMenu::build(window, cx, move |menu, _, _cx| {
menu.when_some(focus, |menu, focus| menu.context(focus))
.when_some(selected_text, |menu, text| {
menu.entry(
"Copy",
Some(Box::new(markdown::Copy)),
move |_, cx| {
cx.write_to_clipboard(
ClipboardItem::new_string(
text.to_string(),
),
);
},
)
})
.when_some(selected_markdown, |menu, text| {
menu.entry(
"Copy as Markdown",
Some(Box::new(markdown::CopyAsMarkdown)),
move |_, cx| {
cx.write_to_clipboard(
ClipboardItem::new_string(
text.to_string(),
),
);
},
)
})
.when_some(context_menu_link, |menu, url| {
menu.entry("Copy Link", None, move |_, cx| {
cx.write_to_clipboard(
@@ -1273,7 +1309,7 @@ impl Render for MarkdownPreviewView {
);
})
})
})
}))
});
div()
.w_full()
+12 -2
View File
@@ -10,13 +10,21 @@ use gpui::{
pub struct RightClickMenu<M: ManagedView> {
id: ElementId,
child_builder: Option<Box<dyn FnOnce(bool, &mut Window, &mut App) -> AnyElement + 'static>>,
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
anchor: Option<Anchor>,
attach: Option<Anchor>,
}
impl<M: ManagedView> RightClickMenu<M> {
pub fn menu(mut self, f: impl Fn(&mut Window, &mut App) -> Entity<M> + 'static) -> Self {
self.menu_builder = Some(Rc::new(move |window, cx| Some(f(window, cx))));
self
}
pub fn maybe_menu(
mut self,
f: impl Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static,
) -> Self {
self.menu_builder = Some(Rc::new(f));
self
}
@@ -243,7 +251,9 @@ impl<M: ManagedView> Element for RightClickMenu<M> {
cx.stop_propagation();
window.prevent_default();
let new_menu = (builder)(window, cx);
let Some(new_menu) = (builder)(window, cx) else {
return;
};
let menu2 = menu.clone();
let previous_focus_handle = window.focused(cx);