From 481407479d3c7e1a0943090d63b606ff3b02d064 Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Wed, 22 Apr 2026 02:26:45 +0100 Subject: [PATCH] markdown: Add "Copy Link" to right-click context menu (#53758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Closes #53741 Right-clicking on a link in any Markdown view showed no way to copy the URL. The right-click handler already detected links for left-click navigation, but the context menu was never extended to surface a link-specific action. Video of the manual test below : [Screencast from 2026-04-13 00-29-49.webm](https://github.com/user-attachments/assets/fbde09ab-78da-4366-b1e0-e15e0d43442b) ## How to Review - **`crates/markdown/src/markdown.rs`** — Added a `context_menu_link: Option` field to `Markdown`. Added `capture_for_context_menu(link)` (replaces the old `capture_selection_for_context_menu`) which saves both selected text and the hovered link together. Added a `context_menu_link()` accessor. Updated the capture-phase right-click handler to detect the link under the cursor via `rendered_text.link_for_source_index`. Added a `event.button != MouseButton::Right` guard to the bubble-phase `MouseDownEvent` handler to prevent selection logic from running on right-click. - **`crates/agent_ui/src/conversation_view/thread_view.rs`** — In `render_message_context_menu`, after computing `has_selection`, also reads `context_menu_link` from the same markdown chunks. Adds a "Copy Link" entry with a separator at the top of the menu when a link URL is present. - **`crates/markdown_preview/src/markdown_preview_view.rs`** — Wraps the markdown element in a `right_click_menu` with a "Copy Link" entry (when a link is present). Edit: There was a mention of a "Copy" and "Copy as Markdown" buttons. After discussion, it was decided that I would re-add them fully fleshed out in a separate PR ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added "Copy Link" to the right-click context menu when clicking on a link in Markdown views (agent panel and Markdown preview) --- .../src/conversation_view/thread_view.rs | 34 ++++-- crates/markdown/src/markdown.rs | 109 +++++++++++++++++- .../src/markdown_preview_view.rs | 30 ++++- 3 files changed, 154 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 8457e036450021afb2e4838898e8c618471a53e4..fc4e4d50c6226317eb1c48d6a26f1cf1ba683b3e 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -5744,15 +5744,15 @@ impl ThreadView { let this = entity.read(cx); let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; - let has_selection = this - .thread - .read(cx) - .entries() - .get(entry_ix) - .and_then(|entry| match &entry { - AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), - _ => None, - }) + let chunks = + this.thread.read(cx).entries().get(entry_ix).and_then( + |entry| match &entry { + AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), + _ => None, + }, + ); + + let has_selection = chunks .map(|chunks| { chunks.iter().any(|chunk| { let md = match chunk { @@ -5764,6 +5764,16 @@ impl ThreadView { }) .unwrap_or(false); + let context_menu_link = chunks.and_then(|chunks| { + chunks.iter().find_map(|chunk| { + let md = match chunk { + AssistantMessageChunk::Message { block } => block.markdown(), + AssistantMessageChunk::Thought { block } => block.markdown(), + }; + md.and_then(|m| m.read(cx).context_menu_link().cloned()) + }) + }); + let copy_this_agent_response = ContextMenuEntry::new("Copy This Agent Response").handler({ let entity = entity.clone(); @@ -5815,6 +5825,12 @@ impl ThreadView { }); menu.when_some(focus, |menu, focus| menu.context(focus)) + .when_some(context_menu_link, |menu, url| { + menu.entry("Copy Link", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + }) + .separator() + }) .action_disabled_when( !has_selection, "Copy Selection", diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 5e1bb5729a81a914c1ef8a09d2c0abec7b52858a..560ee02b8cf7b4210c3bf4013f58fff670144700 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -263,6 +263,7 @@ pub struct Markdown { mermaid_state: MermaidState, copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, + context_menu_link: Option, context_menu_selected_text: Option, search_highlights: Vec>, active_search_highlight: Option, @@ -434,6 +435,7 @@ impl Markdown { mermaid_state: MermaidState::default(), copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), + context_menu_link: None, context_menu_selected_text: None, search_highlights: Vec::new(), active_search_highlight: None, @@ -656,8 +658,16 @@ impl Markdown { cx.write_to_clipboard(ClipboardItem::new_string(text)); } - fn capture_selection_for_context_menu(&mut self) { + fn capture_for_context_menu(&mut self, link: Option) { self.context_menu_selected_text = self.selected_text(); + self.context_menu_link = link; + } + + /// Returns the URL of the link that was most recently right-clicked, if any. + /// This is set during a right-click mouse-down event and can be read by parent + /// views to include a "Copy Link" item in their context menus. + pub fn context_menu_link(&self) -> Option<&SharedString> { + self.context_menu_link.as_ref() } fn parse(&mut self, cx: &mut Context) { @@ -1336,13 +1346,18 @@ impl MarkdownElement { self.on_mouse_event(window, cx, { let hitbox = hitbox.clone(); - move |markdown, event: &MouseDownEvent, phase, window, _| { + let rendered_text = rendered_text.clone(); + move |markdown, event: &MouseDownEvent, phase, window, _cx| { if phase.capture() && event.button == MouseButton::Right && hitbox.is_hovered(window) { - // Capture selected text so it survives until menu item is clicked - markdown.capture_selection_for_context_menu(); + let link = rendered_text + .source_index_for_position(event.position) + .ok() + .and_then(|ix| rendered_text.link_for_source_index(ix)) + .map(|link| link.destination_url.clone()); + markdown.capture_for_context_menu(link); } } }); @@ -1352,7 +1367,7 @@ impl MarkdownElement { let hitbox = hitbox.clone(); move |markdown, event: &MouseDownEvent, phase, window, cx| { if hitbox.is_hovered(window) { - if phase.bubble() { + if phase.bubble() && event.button != MouseButton::Right { let position_result = rendered_text.source_index_for_position(event.position); @@ -3516,6 +3531,90 @@ mod tests { assert!(!has_code_block(&Markdown::escape(diagnostic))); } + #[gpui::test] + fn test_link_detected_for_source_index(cx: &mut TestAppContext) { + let rendered = render_markdown("[Click here](https://example.com)", cx); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "https://example.com"); + + // Source index 1 ('C' in "Click") is inside the link's source range + let link = rendered.link_for_source_index(1); + assert!(link.is_some()); + assert_eq!(link.unwrap().destination_url, "https://example.com"); + + // A source index past the end of the link range returns None + let past_end = rendered.links[0].source_range.end; + assert!(rendered.link_for_source_index(past_end).is_none()); + } + + #[gpui::test] + fn test_link_for_source_index_ignores_plain_text(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world", cx); + + assert!(rendered.links.is_empty()); + assert!(rendered.link_for_source_index(0).is_none()); + assert!(rendered.link_for_source_index(5).is_none()); + } + + #[gpui::test] + fn test_context_menu_link_initial_state(cx: &mut TestAppContext) { + struct TestWindow; + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = + cx.new(|cx| Markdown::new("Hello [world](https://example.com)".into(), None, None, cx)); + cx.run_until_parked(); + + cx.update(|_window, cx| { + assert!(markdown.read(cx).context_menu_link().is_none()); + }); + } + + #[gpui::test] + fn test_capture_for_context_menu(cx: &mut TestAppContext) { + struct TestWindow; + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new("text".into(), None, None, cx)); + cx.run_until_parked(); + + // Simulates right-clicking on a link + let url: SharedString = "https://example.com".into(); + markdown.update(cx, |md, _cx| { + md.capture_for_context_menu(Some(url.clone())); + }); + cx.update(|_window, cx| { + assert_eq!( + markdown + .read(cx) + .context_menu_link() + .map(SharedString::as_ref), + Some("https://example.com") + ); + }); + + // Simulates right-clicking on plain text — link is cleared + markdown.update(cx, |md, _cx| { + md.capture_for_context_menu(None); + }); + cx.update(|_window, cx| { + assert!(markdown.read(cx).context_menu_link().is_none()); + }); + } + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 8230e332253b4a018b280d9ef35b812ccb6bcc33..2b7379b0b40ccf4eae170cacdc0558f74f5874f6 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -9,9 +9,9 @@ use anyhow::Result; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, - IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, - SharedUri, Subscription, Task, WeakEntity, Window, point, + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, + InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, + ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, }; use language::LanguageRegistry; use markdown::{ @@ -21,7 +21,7 @@ use markdown::{ use project::search::SearchQuery; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{WithScrollbar, prelude::*}; +use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; use util::markdown::split_local_url_fragment; use util::normalize_path; use workspace::item::{Item, ItemBufferKind, ItemHandle}; @@ -900,7 +900,27 @@ impl Render for MarkdownPreviewView { .overflow_y_scroll() .track_scroll(&self.scroll_handle) .p_4() - .child(self.render_markdown_element(window, cx)), + .child({ + let markdown_element = self.render_markdown_element(window, cx); + let markdown = self.markdown.clone(); + right_click_menu("markdown-preview-context-menu") + .trigger(move |_, _, _| markdown_element) + .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| { + menu.when_some(focus, |menu, focus| menu.context(focus)) + .when_some(context_menu_link, |menu, url| { + menu.entry("Copy Link", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + url.to_string(), + )); + }) + }) + }) + }) + }), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx) }