Detailed changes
@@ -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",
@@ -263,6 +263,7 @@ pub struct Markdown {
mermaid_state: MermaidState,
copied_code_blocks: HashSet<ElementId>,
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
+ context_menu_link: Option<SharedString>,
context_menu_selected_text: Option<String>,
search_highlights: Vec<Range<usize>>,
active_search_highlight: Option<usize>,
@@ -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<SharedString>) {
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<Self>) {
@@ -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<Self>) -> 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<Self>) -> 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<Vec<(usize, usize)>>) {
assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch");
@@ -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)
}