Add popover click to go to definition

Eric Holk and Tom Houlé created

Co-authored-by: Tom Houlé <tom@tomhoule.com>

Change summary

crates/editor/src/hover_popover.rs | 591 +++++++++++++++++++++++++++++++
crates/markdown/src/markdown.rs    | 138 +++++++
2 files changed, 720 insertions(+), 9 deletions(-)

Detailed changes

crates/editor/src/hover_popover.rs 🔗

@@ -1,12 +1,13 @@
 use crate::{
     ActiveDiagnostic, Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings,
-    EditorSnapshot, GlobalDiagnosticRenderer, HighlightKey, Hover,
+    EditorSnapshot, GlobalDiagnosticRenderer, GotoDefinitionKind, HighlightKey, Hover,
     display_map::{InlayOffset, ToDisplayPoint, is_invisible},
-    hover_links::{InlayHighlight, RangeInEditor},
+    hover_links::{HoverLink, InlayHighlight, RangeInEditor},
     movement::TextLayoutDetails,
     scroll::ScrollAmount,
 };
 use anyhow::Context as _;
+use collections::{HashMap, HashSet};
 use gpui::{
     AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
@@ -18,7 +19,7 @@ use language::{DiagnosticEntry, Language, LanguageRegistry};
 use lsp::DiagnosticSeverity;
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
-use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
+use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, LocationLink};
 use settings::Settings;
 use std::{borrow::Cow, cell::RefCell};
 use std::{ops::Range, sync::Arc, time::Duration};
@@ -187,6 +188,7 @@ pub fn hover_at_inlay(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(false)),
                     anchor: None,
+                    type_definitions: Default::default(),
                     _subscription: subscription,
                 };
 
@@ -306,6 +308,9 @@ fn show_hover(
             };
 
             let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?;
+            let type_def_request = cx.update(|_, cx| {
+                provider.definitions(&buffer, buffer_position, GotoDefinitionKind::Type, cx)
+            })?;
 
             if let Some(delay) = delay {
                 delay.await;
@@ -438,6 +443,29 @@ fn show_hover(
             } else {
                 Vec::new()
             };
+
+            let type_definitions = if let Some(type_def_request) = type_def_request {
+                type_def_request.await.ok().flatten().unwrap_or_default()
+            } else {
+                Vec::new()
+            };
+            let type_def_map: HashMap<SharedString, LocationLink> = cx.update(|_, cx| {
+                let mut map = HashMap::default();
+                for link in type_definitions {
+                    let name: String = link
+                        .target
+                        .buffer
+                        .read(cx)
+                        .text_for_range(link.target.range.clone())
+                        .collect();
+                    if !name.is_empty() {
+                        map.insert(SharedString::from(name), link);
+                    }
+                }
+                map
+            })?;
+            let type_def_map = Rc::new(type_def_map);
+
             let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?;
             let mut hover_highlights = Vec::with_capacity(hovers_response.len());
             let mut info_popovers = Vec::with_capacity(
@@ -466,6 +494,7 @@ fn show_hover(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
+                    type_definitions: Default::default(),
                     _subscription: subscription,
                 })
             }
@@ -507,6 +536,7 @@ fn show_hover(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
+                    type_definitions: type_def_map.clone(),
                     _subscription: subscription,
                 });
             }
@@ -772,6 +802,37 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
     cx.open_url(&link);
 }
 
+fn navigate_to_hover_type(
+    editor: gpui::WeakEntity<Editor>,
+    type_definitions: &HashMap<SharedString, LocationLink>,
+    word: SharedString,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    let Some(location_link) = type_definitions.get(&word).cloned() else {
+        return;
+    };
+    let Some(editor) = editor.upgrade() else {
+        return;
+    };
+    editor.update(cx, |editor, cx| {
+        let nav_entry = editor
+            .hover_state
+            .triggered_from
+            .and_then(|anchor| editor.navigation_entry(anchor, cx));
+        editor
+            .navigate_to_hover_links(
+                Some(GotoDefinitionKind::Type),
+                vec![HoverLink::Text(location_link)],
+                nav_entry,
+                false,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx);
+    });
+}
+
 #[derive(Default)]
 pub struct HoverState {
     pub info_popovers: Vec<InfoPopover>,
@@ -887,6 +948,7 @@ pub struct InfoPopover {
     pub scroll_handle: ScrollHandle,
     pub keyboard_grace: Rc<RefCell<bool>>,
     pub anchor: Option<Anchor>,
+    pub type_definitions: Rc<HashMap<SharedString, LocationLink>>,
     _subscription: Option<Subscription>,
 }
 
@@ -911,6 +973,10 @@ impl InfoPopover {
                 cx.stop_propagation();
             })
             .when_some(self.parsed_content.clone(), |this, markdown| {
+                let editor = cx.entity().downgrade();
+                let type_definitions = self.type_definitions.clone();
+                let clickable_words: HashSet<SharedString> =
+                    type_definitions.keys().cloned().collect();
                 this.child(
                     div()
                         .id("info-md-container")
@@ -926,6 +992,16 @@ impl InfoPopover {
                                     border: false,
                                 })
                                 .on_url_click(open_markdown_url)
+                                .clickable_code_words(clickable_words)
+                                .on_code_block_click(move |word, window, cx| {
+                                    navigate_to_hover_type(
+                                        editor.clone(),
+                                        &type_definitions,
+                                        word,
+                                        window,
+                                        cx,
+                                    );
+                                })
                                 .p_2(),
                         ),
                 )
@@ -2005,4 +2081,513 @@ mod tests {
             InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
         );
     }
+
+    #[gpui::test]
+    async fn test_hover_popover_type_definitions_populated(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let vaˇriable: foo::Baz = foo::Baz;
+        "});
+
+        let hover_point = cx.display_point(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let vaˇriable: foo::Baz = foo::Baz;
+        "});
+
+        cx.update_editor(|editor, window, cx| {
+            let snapshot = editor.snapshot(window, cx);
+            let anchor = snapshot
+                .buffer_snapshot()
+                .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+            hover_at(editor, Some(anchor), window, cx)
+        });
+
+        let symbol_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let «variable»: foo::Baz = foo::Baz;
+        "});
+        let target_selection_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct «Baz»;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let variable: foo::Baz = foo::Baz;
+        "});
+
+        cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+            Ok(Some(lsp::Hover {
+                contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                    kind: lsp::MarkupKind::Markdown,
+                    value: "```rust\nBaz\n```".to_string(),
+                }),
+                range: Some(symbol_range),
+            }))
+        });
+
+        cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+            move |url, _, _| async move {
+                Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range: target_selection_range,
+                        target_selection_range,
+                    },
+                ])))
+            },
+        );
+
+        cx.background_executor
+            .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+        cx.run_until_parked();
+
+        cx.editor(|editor, _, cx| {
+            assert!(editor.hover_state.visible());
+            assert_eq!(
+                editor.hover_state.info_popovers.len(),
+                1,
+                "Expected exactly one hover popover"
+            );
+            let popover = &editor.hover_state.info_popovers[0];
+            assert_eq!(
+                popover.type_definitions.len(),
+                1,
+                "Expected one type definition entry"
+            );
+            assert!(
+                popover.type_definitions.contains_key("Baz"),
+                "Expected type_definitions to contain 'Baz', got keys: {:?}",
+                popover.type_definitions.keys().collect::<Vec<_>>()
+            );
+
+            // Verify the hover text rendered correctly too
+            let rendered_text = popover.get_rendered_text(cx);
+            assert!(
+                rendered_text.contains("Baz"),
+                "Hover text should contain 'Baz', got: {rendered_text}"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hover_popover_navigate_to_type(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let vaˇriable: foo::Baz = foo::Baz;
+        "});
+
+        let hover_point = cx.display_point(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let vaˇriable: foo::Baz = foo::Baz;
+        "});
+
+        cx.update_editor(|editor, window, cx| {
+            let snapshot = editor.snapshot(window, cx);
+            let anchor = snapshot
+                .buffer_snapshot()
+                .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+            hover_at(editor, Some(anchor), window, cx)
+        });
+
+        let symbol_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let «variable»: foo::Baz = foo::Baz;
+        "});
+        // Point to foo::Baz specifically (line 2), not bar::Baz (line 5)
+        let target_selection_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct «Baz»;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let variable: foo::Baz = foo::Baz;
+        "});
+
+        cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+            Ok(Some(lsp::Hover {
+                contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                    kind: lsp::MarkupKind::Markdown,
+                    value: "```rust\nBaz\n```".to_string(),
+                }),
+                range: Some(symbol_range),
+            }))
+        });
+
+        cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+            move |url, _, _| async move {
+                Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range: target_selection_range,
+                        target_selection_range,
+                    },
+                ])))
+            },
+        );
+
+        cx.background_executor
+            .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+        cx.run_until_parked();
+
+        // Verify the hover is visible and has the type definition
+        cx.editor(|editor, _, _| {
+            assert!(editor.hover_state.visible());
+            assert!(
+                editor.hover_state.info_popovers[0]
+                    .type_definitions
+                    .contains_key("Baz")
+            );
+        });
+
+        // Extract the data we need before navigating (navigate_to_hover_type
+        // internally calls editor.update(), so we can't call it from within
+        // update_editor without causing a re-entrant borrow panic).
+        let (type_definitions, editor_weak) = cx.editor(|editor, _, cx| {
+            (
+                editor.hover_state.info_popovers[0].type_definitions.clone(),
+                cx.entity().downgrade(),
+            )
+        });
+
+        let window_handle = cx.window;
+        cx.update_window(window_handle, |_, window, cx| {
+            navigate_to_hover_type(editor_weak, &type_definitions, "Baz".into(), window, cx);
+        })
+        .unwrap();
+        cx.run_until_parked();
+
+        // Verify cursor moved to foo::Baz definition, not bar::Baz
+        cx.assert_editor_state(indoc! {"
+            mod foo {
+                pub struct «Bazˇ»;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            let variable: foo::Baz = foo::Baz;
+        "});
+    }
+
+    #[gpui::test]
+    async fn test_hover_popover_multiple_type_definitions(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct Wrapper<T, U>(T, U);
+            let vaˇriable = Wrapper(foo::Baz, bar::Baz);
+        "});
+
+        let hover_point = cx.display_point(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct Wrapper<T, U>(T, U);
+            let vaˇriable = Wrapper(foo::Baz, bar::Baz);
+        "});
+
+        cx.update_editor(|editor, window, cx| {
+            let snapshot = editor.snapshot(window, cx);
+            let anchor = snapshot
+                .buffer_snapshot()
+                .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+            hover_at(editor, Some(anchor), window, cx)
+        });
+
+        let symbol_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct Wrapper<T, U>(T, U);
+            let «variable» = Wrapper(foo::Baz, bar::Baz);
+        "});
+        let wrapper_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct «Wrapper»<T, U>(T, U);
+            let variable = Wrapper(foo::Baz, bar::Baz);
+        "});
+        let foo_baz_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct «Baz»;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct Wrapper<T, U>(T, U);
+            let variable = Wrapper(foo::Baz, bar::Baz);
+        "});
+        let bar_baz_range = cx.lsp_range(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct «Baz»;
+            }
+            struct Wrapper<T, U>(T, U);
+            let variable = Wrapper(foo::Baz, bar::Baz);
+        "});
+
+        cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+            Ok(Some(lsp::Hover {
+                contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                    kind: lsp::MarkupKind::Markdown,
+                    value: "```rust\nWrapper\n```".to_string(),
+                }),
+                range: Some(symbol_range),
+            }))
+        });
+
+        cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+            move |url, _, _| async move {
+                Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range: wrapper_range,
+                        target_selection_range: wrapper_range,
+                    },
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range: foo_baz_range,
+                        target_selection_range: foo_baz_range,
+                    },
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range: bar_baz_range,
+                        target_selection_range: bar_baz_range,
+                    },
+                ])))
+            },
+        );
+
+        cx.background_executor
+            .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+        cx.run_until_parked();
+
+        cx.editor(|editor, _, _| {
+            assert!(editor.hover_state.visible());
+            let popover = &editor.hover_state.info_popovers[0];
+            let type_defs = &popover.type_definitions;
+
+            // "Wrapper" is unique so it must be present
+            assert!(
+                type_defs.contains_key("Wrapper"),
+                "Expected 'Wrapper' in type_definitions, got keys: {:?}",
+                type_defs.keys().collect::<Vec<_>>()
+            );
+
+            // When multiple types share the same short name (e.g., foo::Baz and bar::Baz),
+            // only one entry survives in the HashMap. This is acceptable because the hover
+            // text would typically show qualified paths in this case. A future enhancement
+            // could use Vec<LocationLink> or leverage rust-analyzer's hover actions extension
+            // to handle this precisely.
+            assert!(
+                type_defs.contains_key("Baz"),
+                "Expected 'Baz' in type_definitions (one of the two should survive)"
+            );
+            assert_eq!(
+                type_defs.len(),
+                2,
+                "Expected 2 entries (Wrapper + one Baz), got: {:?}",
+                type_defs.keys().collect::<Vec<_>>()
+            );
+        });
+
+        // Navigation to "Wrapper" should work correctly
+        let (type_definitions, editor_weak) = cx.editor(|editor, _, cx| {
+            (
+                editor.hover_state.info_popovers[0].type_definitions.clone(),
+                cx.entity().downgrade(),
+            )
+        });
+
+        let window_handle = cx.window;
+        cx.update_window(window_handle, |_, window, cx| {
+            navigate_to_hover_type(editor_weak, &type_definitions, "Wrapper".into(), window, cx);
+        })
+        .unwrap();
+        cx.run_until_parked();
+
+        cx.assert_editor_state(indoc! {"
+            mod foo {
+                pub struct Baz;
+            }
+            mod bar {
+                pub struct Baz;
+            }
+            struct «Wrapperˇ»<T, U>(T, U);
+            let variable = Wrapper(foo::Baz, bar::Baz);
+        "});
+    }
+
+    #[gpui::test]
+    async fn test_hover_popover_type_definition_request_error(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+            struct MyStruct;
+            let vaˇriable = MyStruct;
+        "});
+
+        let hover_point = cx.display_point(indoc! {"
+            struct MyStruct;
+            let vaˇriable = MyStruct;
+        "});
+
+        cx.update_editor(|editor, window, cx| {
+            let snapshot = editor.snapshot(window, cx);
+            let anchor = snapshot
+                .buffer_snapshot()
+                .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
+            hover_at(editor, Some(anchor), window, cx)
+        });
+
+        let symbol_range = cx.lsp_range(indoc! {"
+            struct MyStruct;
+            let «variable» = MyStruct;
+        "});
+
+        // Hover succeeds normally
+        cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
+            Ok(Some(lsp::Hover {
+                contents: lsp::HoverContents::Markup(lsp::MarkupContent {
+                    kind: lsp::MarkupKind::Markdown,
+                    value: "some basic docs".to_string(),
+                }),
+                range: Some(symbol_range),
+            }))
+        });
+
+        // Type definition request returns an error
+        cx.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(
+            move |_, _, _| async move { Err(anyhow::anyhow!("LSP server error: request failed")) },
+        );
+
+        cx.background_executor
+            .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
+        cx.run_until_parked();
+
+        // The hover popover should still be visible with the hover content,
+        // but type_definitions should be empty due to the error
+        cx.editor(|editor, _, cx| {
+            assert!(
+                editor.hover_state.visible(),
+                "Hover should still be visible even when type definition request fails"
+            );
+            assert_eq!(
+                editor.hover_state.info_popovers.len(),
+                1,
+                "Expected exactly one hover popover"
+            );
+            let popover = &editor.hover_state.info_popovers[0];
+            assert!(
+                popover.type_definitions.is_empty(),
+                "type_definitions should be empty when the LSP request errors, got: {:?}",
+                popover.type_definitions.keys().collect::<Vec<_>>()
+            );
+
+            // The hover text should still render correctly
+            let rendered_text = popover.get_rendered_text(cx);
+            assert_eq!(
+                rendered_text, "some basic docs",
+                "Hover content should be unaffected by type definition error"
+            );
+        });
+    }
 }

crates/markdown/src/markdown.rs 🔗

@@ -32,7 +32,7 @@ use gpui::{
     MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
     Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
 };
-use language::{CharClassifier, Language, LanguageRegistry, Rope};
+use language::{CharClassifier, CharKind, Language, LanguageRegistry, Rope};
 use parser::CodeBlockMetadata;
 use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown};
 use pulldown_cmark::Alignment;
@@ -237,6 +237,7 @@ pub struct Markdown {
     source: SharedString,
     selection: Selection,
     pressed_link: Option<RenderedLink>,
+    pressed_code_block_word: Option<SharedString>,
     autoscroll_request: Option<usize>,
     parsed_markdown: ParsedMarkdown,
     images_by_source_offset: HashMap<usize, Arc<Image>>,
@@ -305,6 +306,7 @@ impl Markdown {
             source,
             selection: Selection::default(),
             pressed_link: None,
+            pressed_code_block_word: None,
             autoscroll_request: None,
             should_reparse: false,
             images_by_source_offset: Default::default(),
@@ -330,6 +332,7 @@ impl Markdown {
             source,
             selection: Selection::default(),
             pressed_link: None,
+            pressed_code_block_word: None,
             autoscroll_request: None,
             should_reparse: false,
             parsed_markdown: ParsedMarkdown::default(),
@@ -702,6 +705,8 @@ pub struct MarkdownElement {
     style: MarkdownStyle,
     code_block_renderer: CodeBlockRenderer,
     on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+    on_code_block_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+    clickable_code_words: Option<Rc<HashSet<SharedString>>>,
 }
 
 impl MarkdownElement {
@@ -715,6 +720,8 @@ impl MarkdownElement {
                 border: false,
             },
             on_url_click: None,
+            on_code_block_click: None,
+            clickable_code_words: None,
         }
     }
 
@@ -752,6 +759,19 @@ impl MarkdownElement {
         self
     }
 
+    pub fn on_code_block_click(
+        mut self,
+        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_code_block_click = Some(Box::new(handler));
+        self
+    }
+
+    pub fn clickable_code_words(mut self, words: HashSet<SharedString>) -> Self {
+        self.clickable_code_words = Some(Rc::new(words));
+        self
+    }
+
     fn paint_selection(
         &self,
         bounds: Bounds<Pixels>,
@@ -819,6 +839,15 @@ impl MarkdownElement {
         }
     }
 
+    fn is_clickable_code_word(
+        word: &SharedString,
+        clickable_code_words: &Option<Rc<HashSet<SharedString>>>,
+    ) -> bool {
+        clickable_code_words
+            .as_ref()
+            .map_or(true, |set| set.contains(word))
+    }
+
     fn paint_mouse_listeners(
         &mut self,
         hitbox: &Hitbox,
@@ -830,14 +859,23 @@ impl MarkdownElement {
             return;
         }
 
+        let has_code_block_click = self.on_code_block_click.is_some();
+        let clickable_code_words = self.clickable_code_words.take();
         let is_hovering_link = hitbox.is_hovered(window)
             && !self.markdown.read(cx).selection.pending
             && rendered_text
                 .link_for_position(window.mouse_position())
                 .is_some();
+        let is_hovering_code_block_word = has_code_block_click
+            && hitbox.is_hovered(window)
+            && !self.markdown.read(cx).selection.pending
+            && rendered_text
+                .code_block_word_for_position(window.mouse_position())
+                .filter(|word| Self::is_clickable_code_word(word, &clickable_code_words))
+                .is_some();
 
         if !self.style.prevent_mouse_interaction {
-            if is_hovering_link {
+            if is_hovering_link || is_hovering_code_block_word {
                 window.set_cursor_style(CursorStyle::PointingHand, hitbox);
             } else {
                 window.set_cursor_style(CursorStyle::IBeam, hitbox);
@@ -845,6 +883,7 @@ impl MarkdownElement {
         }
 
         let on_open_url = self.on_url_click.take();
+        let on_code_block_click = self.on_code_block_click.take();
 
         self.on_mouse_event(window, cx, {
             let hitbox = hitbox.clone();
@@ -862,11 +901,20 @@ impl MarkdownElement {
         self.on_mouse_event(window, cx, {
             let rendered_text = rendered_text.clone();
             let hitbox = hitbox.clone();
+            let clickable_code_words = clickable_code_words.clone();
             move |markdown, event: &MouseDownEvent, phase, window, cx| {
                 if hitbox.is_hovered(window) {
                     if phase.bubble() {
                         if let Some(link) = rendered_text.link_for_position(event.position) {
                             markdown.pressed_link = Some(link.clone());
+                        } else if has_code_block_click
+                            && let Some(word) = rendered_text
+                                .code_block_word_for_position(event.position)
+                                .filter(|word| {
+                                    Self::is_clickable_code_word(word, &clickable_code_words)
+                                })
+                        {
+                            markdown.pressed_code_block_word = Some(word);
                         } else {
                             let source_index =
                                 match rendered_text.source_index_for_position(event.position) {
@@ -910,6 +958,7 @@ impl MarkdownElement {
                 } else if phase.capture() && event.button == MouseButton::Left {
                     markdown.selection = Selection::default();
                     markdown.pressed_link = None;
+                    markdown.pressed_code_block_word = None;
                     cx.notify();
                 }
             }
@@ -917,7 +966,8 @@ impl MarkdownElement {
         self.on_mouse_event(window, cx, {
             let rendered_text = rendered_text.clone();
             let hitbox = hitbox.clone();
-            let was_hovering_link = is_hovering_link;
+            let clickable_code_words = clickable_code_words.clone();
+            let was_hovering_clickable = is_hovering_link || is_hovering_code_block_word;
             move |markdown, event: &MouseMoveEvent, phase, window, cx| {
                 if phase.capture() {
                     return;
@@ -933,9 +983,16 @@ impl MarkdownElement {
                     markdown.autoscroll_request = Some(source_index);
                     cx.notify();
                 } else {
-                    let is_hovering_link = hitbox.is_hovered(window)
-                        && rendered_text.link_for_position(event.position).is_some();
-                    if is_hovering_link != was_hovering_link {
+                    let is_hovering_clickable = hitbox.is_hovered(window)
+                        && (rendered_text.link_for_position(event.position).is_some()
+                            || (has_code_block_click
+                                && rendered_text
+                                    .code_block_word_for_position(event.position)
+                                    .filter(|word| {
+                                        Self::is_clickable_code_word(word, &clickable_code_words)
+                                    })
+                                    .is_some()));
+                    if is_hovering_clickable != was_hovering_clickable {
                         cx.notify();
                     }
                 }
@@ -953,6 +1010,18 @@ impl MarkdownElement {
                         } else {
                             cx.open_url(&pressed_link.destination_url);
                         }
+                    } else if let Some(pressed_word) = markdown.pressed_code_block_word.take()
+                        && rendered_text
+                            .code_block_word_for_position(event.position)
+                            .filter(|word| {
+                                Self::is_clickable_code_word(word, &clickable_code_words)
+                            })
+                            .as_ref()
+                            == Some(&pressed_word)
+                    {
+                        if let Some(on_click) = on_code_block_click.as_ref() {
+                            on_click(pressed_word, window, cx);
+                        }
                     }
                 } else if markdown.selection.pending {
                     markdown.selection.pending = false;
@@ -2224,6 +2293,63 @@ impl RenderedText {
             .iter()
             .find(|link| link.source_range.contains(&source_index))
     }
+
+    fn code_block_word_for_position(&self, position: Point<Pixels>) -> Option<SharedString> {
+        let source_index = self.source_index_for_position(position).ok()?;
+
+        for line in self.lines.iter() {
+            if source_index > line.source_end {
+                continue;
+            }
+
+            // Only return words for code block lines
+            if line.language.is_none() {
+                return None;
+            }
+
+            let line_rendered_start = line.source_mappings.first()?.rendered_index;
+            let rendered_index_in_line =
+                line.rendered_index_for_source_index(source_index) - line_rendered_start;
+            let text = line.layout.text();
+
+            let scope = line.language.as_ref().map(|l| l.default_scope());
+            let classifier = CharClassifier::new(scope);
+
+            // Check that we're on a word character
+            let char_at_cursor = text[rendered_index_in_line..].chars().next()?;
+            if classifier.kind(char_at_cursor) != CharKind::Word {
+                return None;
+            }
+
+            // Find word boundaries
+            let mut start = rendered_index_in_line;
+            for c in text[..rendered_index_in_line].chars().rev() {
+                if classifier.kind(c) == CharKind::Word {
+                    start -= c.len_utf8();
+                } else {
+                    break;
+                }
+            }
+
+            let mut end = rendered_index_in_line;
+            for c in text[rendered_index_in_line..].chars() {
+                if classifier.kind(c) == CharKind::Word {
+                    end += c.len_utf8();
+                } else {
+                    break;
+                }
+            }
+
+            let word = &text[start..end];
+            if word.is_empty() {
+                return None;
+            }
+
+            return Some(SharedString::from(word.to_string()));
+        }
+
+        None
+    }
 }
 
 #[cfg(test)]