diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f5d5e6d5ab69d690bd5f3aee29bf9aa493cf0059..08ad1f8d295a36875af920c9ddcd92cc236f3749 100644 --- a/crates/editor/src/hover_popover.rs +++ b/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 = 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, + type_definitions: &HashMap, + 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, @@ -887,6 +948,7 @@ pub struct InfoPopover { pub scroll_handle: ScrollHandle, pub keyboard_grace: Rc>, pub anchor: Option, + pub type_definitions: Rc>, _subscription: Option, } @@ -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 = + 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::(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::( + 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::>() + ); + + // 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::(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::( + 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); + 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); + 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); + 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); + 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); + 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); + let variable = Wrapper(foo::Baz, bar::Baz); + "}); + + cx.set_request_handler::(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::( + 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::>() + ); + + // 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 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::>() + ); + }); + + // 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); + 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::(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::( + 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::>() + ); + + // 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" + ); + }); + } } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 087b7153704c215ec27eae653879ffe9f11ebf09..85b329940115681017fb6a9183837d782856fa8b 100644 --- a/crates/markdown/src/markdown.rs +++ b/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, + pressed_code_block_word: Option, autoscroll_request: Option, parsed_markdown: ParsedMarkdown, images_by_source_offset: HashMap>, @@ -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>, + on_code_block_click: Option>, + clickable_code_words: Option>>, } 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) -> Self { + self.clickable_code_words = Some(Rc::new(words)); + self + } + fn paint_selection( &self, bounds: Bounds, @@ -819,6 +839,15 @@ impl MarkdownElement { } } + fn is_clickable_code_word( + word: &SharedString, + clickable_code_words: &Option>>, + ) -> 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) -> Option { + 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)]