Open URLs with cmd-click (#7312)

Conrad Irwin and fdionisi created

Release Notes:

- Added ability to cmd-click on URLs in all buffers

---------

Co-authored-by: fdionisi <code@fdionisi.me>

Change summary

Cargo.lock                                    |  10 
crates/editor/Cargo.toml                      |   5 
crates/editor/src/display_map.rs              |   4 
crates/editor/src/display_map/inlay_map.rs    |   2 
crates/editor/src/editor.rs                   |  57 
crates/editor/src/element.rs                  | 128 +--
crates/editor/src/hover_links.rs              | 684 +++++++++-----------
crates/editor/src/hover_popover.rs            |   4 
crates/editor/src/items.rs                    |   9 
crates/editor/src/test/editor_test_context.rs |  30 
crates/gpui/src/app/test_context.rs           |  42 +
crates/gpui/src/platform/keystroke.rs         |  30 
crates/gpui/src/platform/test/platform.rs     |   6 
13 files changed, 495 insertions(+), 516 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2416,6 +2416,7 @@ dependencies = [
  "itertools 0.10.5",
  "language",
  "lazy_static",
+ "linkify",
  "log",
  "lsp",
  "multi_buffer",
@@ -4134,6 +4135,15 @@ dependencies = [
  "safemem",
 ]
 
+[[package]]
+name = "linkify"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "linkme"
 version = "0.3.17"

crates/editor/Cargo.toml 🔗

@@ -20,7 +20,7 @@ test-support = [
     "util/test-support",
     "workspace/test-support",
     "tree-sitter-rust",
-    "tree-sitter-typescript"
+    "tree-sitter-typescript",
 ]
 
 [dependencies]
@@ -33,13 +33,14 @@ convert_case = "0.6.0"
 copilot = { path = "../copilot" }
 db = { path = "../db" }
 futures.workspace = true
-fuzzy = {  path = "../fuzzy" }
+fuzzy = { path = "../fuzzy" }
 git = { path = "../git" }
 gpui = { path = "../gpui" }
 indoc = "1.0.4"
 itertools = "0.10"
 language = { path = "../language" }
 lazy_static.workspace = true
+linkify = "0.10.0"
 log.workspace = true
 lsp = { path = "../lsp" }
 multi_buffer = { path = "../multi_buffer" }

crates/editor/src/display_map.rs 🔗

@@ -25,8 +25,8 @@ mod wrap_map;
 
 use crate::EditorStyle;
 use crate::{
-    link_go_to_definition::InlayHighlight, movement::TextLayoutDetails, Anchor, AnchorRangeExt,
-    InlayId, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
+    hover_links::InlayHighlight, movement::TextLayoutDetails, Anchor, AnchorRangeExt, InlayId,
+    MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
 };
 pub use block_map::{BlockMap, BlockPoint};
 use collections::{BTreeMap, HashMap, HashSet};

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -1168,7 +1168,7 @@ mod tests {
     use super::*;
     use crate::{
         display_map::{InlayHighlights, TextHighlights},
-        link_go_to_definition::InlayHighlight,
+        hover_links::InlayHighlight,
         InlayId, MultiBuffer,
     };
     use gpui::AppContext;

crates/editor/src/editor.rs 🔗

@@ -22,9 +22,9 @@ mod inlay_hint_cache;
 mod debounced_delay;
 mod git;
 mod highlight_matching_bracket;
+mod hover_links;
 mod hover_popover;
 pub mod items;
-mod link_go_to_definition;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
@@ -77,7 +77,7 @@ use language::{
     Language, OffsetRangeExt, Point, Selection, SelectionGoal, TransactionId,
 };
 
-use link_go_to_definition::{GoToDefinitionLink, InlayHighlight, LinkGoToDefinitionState};
+use hover_links::{HoverLink, HoveredLinkState, InlayHighlight};
 use lsp::{DiagnosticSeverity, LanguageServerId};
 use mouse_context_menu::MouseContextMenu;
 use movement::TextLayoutDetails;
@@ -402,7 +402,7 @@ pub struct Editor {
     remote_id: Option<ViewId>,
     hover_state: HoverState,
     gutter_hovered: bool,
-    link_go_to_definition_state: LinkGoToDefinitionState,
+    hovered_link_state: Option<HoveredLinkState>,
     copilot_state: CopilotState,
     inlay_hint_cache: InlayHintCache,
     next_inlay_id: usize,
@@ -1477,7 +1477,7 @@ impl Editor {
             leader_peer_id: None,
             remote_id: None,
             hover_state: Default::default(),
-            link_go_to_definition_state: Default::default(),
+            hovered_link_state: Default::default(),
             copilot_state: Default::default(),
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
             gutter_hovered: false,
@@ -7243,11 +7243,8 @@ impl Editor {
         cx.spawn(|editor, mut cx| async move {
             let definitions = definitions.await?;
             editor.update(&mut cx, |editor, cx| {
-                editor.navigate_to_definitions(
-                    definitions
-                        .into_iter()
-                        .map(GoToDefinitionLink::Text)
-                        .collect(),
+                editor.navigate_to_hover_links(
+                    definitions.into_iter().map(HoverLink::Text).collect(),
                     split,
                     cx,
                 );
@@ -7257,9 +7254,9 @@ impl Editor {
         .detach_and_log_err(cx);
     }
 
-    pub fn navigate_to_definitions(
+    pub fn navigate_to_hover_links(
         &mut self,
-        mut definitions: Vec<GoToDefinitionLink>,
+        mut definitions: Vec<HoverLink>,
         split: bool,
         cx: &mut ViewContext<Editor>,
     ) {
@@ -7271,10 +7268,14 @@ impl Editor {
         if definitions.len() == 1 {
             let definition = definitions.pop().unwrap();
             let target_task = match definition {
-                GoToDefinitionLink::Text(link) => Task::Ready(Some(Ok(Some(link.target)))),
-                GoToDefinitionLink::InlayHint(lsp_location, server_id) => {
+                HoverLink::Text(link) => Task::Ready(Some(Ok(Some(link.target)))),
+                HoverLink::InlayHint(lsp_location, server_id) => {
                     self.compute_target_location(lsp_location, server_id, cx)
                 }
+                HoverLink::Url(url) => {
+                    cx.open_url(&url);
+                    Task::ready(Ok(None))
+                }
             };
             cx.spawn(|editor, mut cx| async move {
                 let target = target_task.await.context("target resolution task")?;
@@ -7325,29 +7326,27 @@ impl Editor {
                         let title = definitions
                             .iter()
                             .find_map(|definition| match definition {
-                                GoToDefinitionLink::Text(link) => {
-                                    link.origin.as_ref().map(|origin| {
-                                        let buffer = origin.buffer.read(cx);
-                                        format!(
-                                            "Definitions for {}",
-                                            buffer
-                                                .text_for_range(origin.range.clone())
-                                                .collect::<String>()
-                                        )
-                                    })
-                                }
-                                GoToDefinitionLink::InlayHint(_, _) => None,
+                                HoverLink::Text(link) => link.origin.as_ref().map(|origin| {
+                                    let buffer = origin.buffer.read(cx);
+                                    format!(
+                                        "Definitions for {}",
+                                        buffer
+                                            .text_for_range(origin.range.clone())
+                                            .collect::<String>()
+                                    )
+                                }),
+                                HoverLink::InlayHint(_, _) => None,
+                                HoverLink::Url(_) => None,
                             })
                             .unwrap_or("Definitions".to_string());
                         let location_tasks = definitions
                             .into_iter()
                             .map(|definition| match definition {
-                                GoToDefinitionLink::Text(link) => {
-                                    Task::Ready(Some(Ok(Some(link.target))))
-                                }
-                                GoToDefinitionLink::InlayHint(lsp_location, server_id) => {
+                                HoverLink::Text(link) => Task::Ready(Some(Ok(Some(link.target)))),
+                                HoverLink::InlayHint(lsp_location, server_id) => {
                                     editor.compute_target_location(lsp_location, server_id, cx)
                                 }
+                                HoverLink::Url(_) => Task::ready(Ok(None)),
                             })
                             .collect::<Vec<_>>();
                         (title, location_tasks)

crates/editor/src/element.rs 🔗

@@ -9,11 +9,6 @@ use crate::{
         self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
     },
     items::BufferSearchHighlights,
-    link_go_to_definition::{
-        go_to_fetched_definition, go_to_fetched_type_definition, show_link_definition,
-        update_go_to_definition_link, update_inlay_link_and_hover_points, GoToDefinitionTrigger,
-        LinkGoToDefinitionState,
-    },
     mouse_context_menu,
     scroll::scroll_amount::ScrollAmount,
     CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode,
@@ -337,7 +332,14 @@ impl EditorElement {
         register_action(view, cx, Editor::display_cursor_names);
     }
 
-    fn register_key_listeners(&self, cx: &mut ElementContext) {
+    fn register_key_listeners(
+        &self,
+        cx: &mut ElementContext,
+        text_bounds: Bounds<Pixels>,
+        layout: &LayoutState,
+    ) {
+        let position_map = layout.position_map.clone();
+        let stacking_order = cx.stacking_order().clone();
         cx.on_key_event({
             let editor = self.editor.clone();
             move |event: &ModifiersChangedEvent, phase, cx| {
@@ -345,46 +347,41 @@ impl EditorElement {
                     return;
                 }
 
-                if editor.update(cx, |editor, cx| Self::modifiers_changed(editor, event, cx)) {
-                    cx.stop_propagation();
-                }
+                editor.update(cx, |editor, cx| {
+                    Self::modifiers_changed(
+                        editor,
+                        event,
+                        &position_map,
+                        text_bounds,
+                        &stacking_order,
+                        cx,
+                    )
+                })
             }
         });
     }
 
-    pub(crate) fn modifiers_changed(
+    fn modifiers_changed(
         editor: &mut Editor,
         event: &ModifiersChangedEvent,
+        position_map: &PositionMap,
+        text_bounds: Bounds<Pixels>,
+        stacking_order: &StackingOrder,
         cx: &mut ViewContext<Editor>,
-    ) -> bool {
-        let pending_selection = editor.has_pending_selection();
-
-        if let Some(point) = &editor.link_go_to_definition_state.last_trigger_point {
-            if event.command && !pending_selection {
-                let point = point.clone();
-                let snapshot = editor.snapshot(cx);
-                let kind = point.definition_kind(event.shift);
-
-                show_link_definition(kind, editor, point, snapshot, cx);
-                return false;
-            }
-        }
-
+    ) {
+        let mouse_position = cx.mouse_position();
+        if !text_bounds.contains(&mouse_position)
+            || !cx.was_top_layer(&mouse_position, stacking_order)
         {
-            if editor.link_go_to_definition_state.symbol_range.is_some()
-                || !editor.link_go_to_definition_state.definitions.is_empty()
-            {
-                editor.link_go_to_definition_state.symbol_range.take();
-                editor.link_go_to_definition_state.definitions.clear();
-                cx.notify();
-            }
-
-            editor.link_go_to_definition_state.task = None;
-
-            editor.clear_highlights::<LinkGoToDefinitionState>(cx);
+            return;
         }
 
-        false
+        editor.update_hovered_link(
+            position_map.point_for_position(text_bounds, mouse_position),
+            &position_map.snapshot,
+            event.modifiers,
+            cx,
+        )
     }
 
     fn mouse_left_down(
@@ -485,13 +482,7 @@ impl EditorElement {
             && cx.was_top_layer(&event.position, stacking_order)
         {
             let point = position_map.point_for_position(text_bounds, event.position);
-            let could_be_inlay = point.as_valid().is_none();
-            let split = event.modifiers.alt;
-            if event.modifiers.shift || could_be_inlay {
-                go_to_fetched_type_definition(editor, point, split, cx);
-            } else {
-                go_to_fetched_definition(editor, point, split, cx);
-            }
+            editor.handle_click_hovered_link(point, event.modifiers, cx);
 
             cx.stop_propagation();
         } else if end_selection {
@@ -564,31 +555,14 @@ impl EditorElement {
         if text_hovered && was_top {
             let point_for_position = position_map.point_for_position(text_bounds, event.position);
 
-            match point_for_position.as_valid() {
-                Some(point) => {
-                    update_go_to_definition_link(
-                        editor,
-                        Some(GoToDefinitionTrigger::Text(point)),
-                        modifiers.command,
-                        modifiers.shift,
-                        cx,
-                    );
-                    hover_at(editor, Some(point), cx);
-                    Self::update_visible_cursor(editor, point, position_map, cx);
-                }
-                None => {
-                    update_inlay_link_and_hover_points(
-                        &position_map.snapshot,
-                        point_for_position,
-                        editor,
-                        modifiers.command,
-                        modifiers.shift,
-                        cx,
-                    );
-                }
+            editor.update_hovered_link(point_for_position, &position_map.snapshot, modifiers, cx);
+
+            if let Some(point) = point_for_position.as_valid() {
+                hover_at(editor, Some(point), cx);
+                Self::update_visible_cursor(editor, point, position_map, cx);
             }
         } else {
-            update_go_to_definition_link(editor, None, modifiers.command, modifiers.shift, cx);
+            editor.hide_hovered_link(cx);
             hover_at(editor, None, cx);
             if gutter_hovered && was_top {
                 cx.stop_propagation();
@@ -930,13 +904,13 @@ impl EditorElement {
                     if self
                         .editor
                         .read(cx)
-                        .link_go_to_definition_state
-                        .definitions
-                        .is_empty()
+                        .hovered_link_state
+                        .as_ref()
+                        .is_some_and(|hovered_link_state| !hovered_link_state.links.is_empty())
                     {
-                        cx.set_cursor_style(CursorStyle::IBeam);
-                    } else {
                         cx.set_cursor_style(CursorStyle::PointingHand);
+                    } else {
+                        cx.set_cursor_style(CursorStyle::IBeam);
                     }
                 }
 
@@ -3105,9 +3079,9 @@ impl Element for EditorElement {
                     let key_context = self.editor.read(cx).key_context(cx);
                     cx.with_key_dispatch(Some(key_context), Some(focus_handle.clone()), |_, cx| {
                         self.register_actions(cx);
-                        self.register_key_listeners(cx);
 
                         cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+                            self.register_key_listeners(cx, text_bounds, &layout);
                             cx.handle_input(
                                 &focus_handle,
                                 ElementInputHandler::new(bounds, self.editor.clone()),
@@ -3224,16 +3198,6 @@ pub struct PointForPosition {
 }
 
 impl PointForPosition {
-    #[cfg(test)]
-    pub fn valid(valid: DisplayPoint) -> Self {
-        Self {
-            previous_valid: valid,
-            next_valid: valid,
-            exact_unclipped: valid,
-            column_overshoot_after_line_end: 0,
-        }
-    }
-
     pub fn as_valid(&self) -> Option<DisplayPoint> {
         if self.previous_valid == self.exact_unclipped && self.next_valid == self.exact_unclipped {
             Some(self.previous_valid)

crates/editor/src/link_go_to_definition.rs → crates/editor/src/hover_links.rs 🔗

@@ -1,12 +1,11 @@
 use crate::{
-    display_map::DisplaySnapshot,
     element::PointForPosition,
     hover_popover::{self, InlayHover},
-    Anchor, DisplayPoint, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId,
-    SelectPhase,
+    Anchor, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, InlayId, SelectPhase,
 };
-use gpui::{px, Task, ViewContext};
+use gpui::{px, AsyncWindowContext, Model, Modifiers, Task, ViewContext};
 use language::{Bias, ToOffset};
+use linkify::{LinkFinder, LinkKind};
 use lsp::LanguageServerId;
 use project::{
     HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink,
@@ -16,12 +15,12 @@ use std::ops::Range;
 use theme::ActiveTheme as _;
 use util::TryFutureExt;
 
-#[derive(Debug, Default)]
-pub struct LinkGoToDefinitionState {
-    pub last_trigger_point: Option<TriggerPoint>,
+#[derive(Debug)]
+pub struct HoveredLinkState {
+    pub last_trigger_point: TriggerPoint,
+    pub preferred_kind: LinkDefinitionKind,
     pub symbol_range: Option<RangeInEditor>,
-    pub kind: Option<LinkDefinitionKind>,
-    pub definitions: Vec<GoToDefinitionLink>,
+    pub links: Vec<HoverLink>,
     pub task: Option<Task<Option<()>>>,
 }
 
@@ -56,14 +55,9 @@ impl RangeInEditor {
     }
 }
 
-#[derive(Debug)]
-pub enum GoToDefinitionTrigger {
-    Text(DisplayPoint),
-    InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
-}
-
 #[derive(Debug, Clone)]
-pub enum GoToDefinitionLink {
+pub enum HoverLink {
+    Url(String),
     Text(LocationLink),
     InlayHint(lsp::Location, LanguageServerId),
 }
@@ -75,26 +69,13 @@ pub(crate) struct InlayHighlight {
     pub range: Range<usize>,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub enum TriggerPoint {
     Text(Anchor),
     InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
 }
 
 impl TriggerPoint {
-    pub fn definition_kind(&self, shift: bool) -> LinkDefinitionKind {
-        match self {
-            TriggerPoint::Text(_) => {
-                if shift {
-                    LinkDefinitionKind::Type
-                } else {
-                    LinkDefinitionKind::Symbol
-                }
-            }
-            TriggerPoint::InlayHint(_, _, _) => LinkDefinitionKind::Type,
-        }
-    }
-
     fn anchor(&self) -> &Anchor {
         match self {
             TriggerPoint::Text(anchor) => anchor,
@@ -103,69 +84,88 @@ impl TriggerPoint {
     }
 }
 
-pub fn update_go_to_definition_link(
-    editor: &mut Editor,
-    origin: Option<GoToDefinitionTrigger>,
-    cmd_held: bool,
-    shift_held: bool,
-    cx: &mut ViewContext<Editor>,
-) {
-    let pending_nonempty_selection = editor.has_pending_nonempty_selection();
-
-    // Store new mouse point as an anchor
-    let snapshot = editor.snapshot(cx);
-    let trigger_point = match origin {
-        Some(GoToDefinitionTrigger::Text(p)) => {
-            Some(TriggerPoint::Text(snapshot.buffer_snapshot.anchor_before(
-                p.to_offset(&snapshot.display_snapshot, Bias::Left),
-            )))
-        }
-        Some(GoToDefinitionTrigger::InlayHint(p, lsp_location, language_server_id)) => {
-            Some(TriggerPoint::InlayHint(p, lsp_location, language_server_id))
+impl Editor {
+    pub(crate) fn update_hovered_link(
+        &mut self,
+        point_for_position: PointForPosition,
+        snapshot: &EditorSnapshot,
+        modifiers: Modifiers,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if !modifiers.command || self.has_pending_selection() {
+            self.hide_hovered_link(cx);
+            return;
         }
-        None => None,
-    };
 
-    // If the new point is the same as the previously stored one, return early
-    if let (Some(a), Some(b)) = (
-        &trigger_point,
-        &editor.link_go_to_definition_state.last_trigger_point,
-    ) {
-        match (a, b) {
-            (TriggerPoint::Text(anchor_a), TriggerPoint::Text(anchor_b)) => {
-                if anchor_a.cmp(anchor_b, &snapshot.buffer_snapshot).is_eq() {
-                    return;
-                }
+        match point_for_position.as_valid() {
+            Some(point) => {
+                let trigger_point = TriggerPoint::Text(
+                    snapshot
+                        .buffer_snapshot
+                        .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)),
+                );
+
+                show_link_definition(modifiers.shift, self, trigger_point, snapshot, cx);
             }
-            (TriggerPoint::InlayHint(range_a, _, _), TriggerPoint::InlayHint(range_b, _, _)) => {
-                if range_a == range_b {
-                    return;
-                }
+            None => {
+                update_inlay_link_and_hover_points(
+                    &snapshot,
+                    point_for_position,
+                    self,
+                    modifiers.command,
+                    modifiers.shift,
+                    cx,
+                );
             }
-            _ => {}
         }
     }
 
-    editor.link_go_to_definition_state.last_trigger_point = trigger_point.clone();
-
-    if pending_nonempty_selection {
-        hide_link_definition(editor, cx);
-        return;
+    pub(crate) fn hide_hovered_link(&mut self, cx: &mut ViewContext<Self>) {
+        self.hovered_link_state.take();
+        self.clear_highlights::<HoveredLinkState>(cx);
     }
 
-    if cmd_held {
-        if let Some(trigger_point) = trigger_point {
-            let kind = trigger_point.definition_kind(shift_held);
-            show_link_definition(kind, editor, trigger_point, snapshot, cx);
-            return;
+    pub(crate) fn handle_click_hovered_link(
+        &mut self,
+        point: PointForPosition,
+        modifiers: Modifiers,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        if let Some(hovered_link_state) = self.hovered_link_state.take() {
+            self.hide_hovered_link(cx);
+            if !hovered_link_state.links.is_empty() {
+                if !self.focus_handle.is_focused(cx) {
+                    cx.focus(&self.focus_handle);
+                }
+
+                self.navigate_to_hover_links(hovered_link_state.links, modifiers.alt, cx);
+                return;
+            }
         }
-    }
 
-    hide_link_definition(editor, cx);
+        // We don't have the correct kind of link cached, set the selection on
+        // click and immediately trigger GoToDefinition.
+        self.select(
+            SelectPhase::Begin {
+                position: point.next_valid,
+                add: false,
+                click_count: 1,
+            },
+            cx,
+        );
+
+        if point.as_valid().is_some() {
+            if modifiers.shift {
+                self.go_to_type_definition(&GoToTypeDefinition, cx)
+            } else {
+                self.go_to_definition(&GoToDefinition, cx)
+            }
+        }
+    }
 }
 
 pub fn update_inlay_link_and_hover_points(
-    snapshot: &DisplaySnapshot,
+    snapshot: &EditorSnapshot,
     point_for_position: PointForPosition,
     editor: &mut Editor,
     cmd_held: bool,
@@ -306,18 +306,20 @@ pub fn update_inlay_link_and_hover_points(
                                     if let Some((language_server_id, location)) =
                                         hovered_hint_part.location
                                     {
-                                        go_to_definition_updated = true;
-                                        update_go_to_definition_link(
-                                            editor,
-                                            Some(GoToDefinitionTrigger::InlayHint(
-                                                highlight,
-                                                location,
-                                                language_server_id,
-                                            )),
-                                            cmd_held,
-                                            shift_held,
-                                            cx,
-                                        );
+                                        if cmd_held && !editor.has_pending_nonempty_selection() {
+                                            go_to_definition_updated = true;
+                                            show_link_definition(
+                                                shift_held,
+                                                editor,
+                                                TriggerPoint::InlayHint(
+                                                    highlight,
+                                                    location,
+                                                    language_server_id,
+                                                ),
+                                                snapshot,
+                                                cx,
+                                            );
+                                        }
                                     }
                                 }
                             }
@@ -330,7 +332,7 @@ pub fn update_inlay_link_and_hover_points(
     }
 
     if !go_to_definition_updated {
-        update_go_to_definition_link(editor, None, cmd_held, shift_held, cx);
+        editor.hide_hovered_link(cx)
     }
     if !hover_updated {
         hover_popover::hover_at(editor, None, cx);
@@ -344,113 +346,149 @@ pub enum LinkDefinitionKind {
 }
 
 pub fn show_link_definition(
-    definition_kind: LinkDefinitionKind,
+    shift_held: bool,
     editor: &mut Editor,
     trigger_point: TriggerPoint,
-    snapshot: EditorSnapshot,
+    snapshot: &EditorSnapshot,
     cx: &mut ViewContext<Editor>,
 ) {
-    let same_kind = editor.link_go_to_definition_state.kind == Some(definition_kind);
-    if !same_kind {
-        hide_link_definition(editor, cx);
-    }
+    let preferred_kind = match trigger_point {
+        TriggerPoint::Text(_) if !shift_held => LinkDefinitionKind::Symbol,
+        _ => LinkDefinitionKind::Type,
+    };
+
+    let (mut hovered_link_state, is_cached) =
+        if let Some(existing) = editor.hovered_link_state.take() {
+            (existing, true)
+        } else {
+            (
+                HoveredLinkState {
+                    last_trigger_point: trigger_point.clone(),
+                    symbol_range: None,
+                    preferred_kind,
+                    links: vec![],
+                    task: None,
+                },
+                false,
+            )
+        };
 
     if editor.pending_rename.is_some() {
         return;
     }
 
     let trigger_anchor = trigger_point.anchor();
-    let (buffer, buffer_position) = if let Some(output) = editor
+    let Some((buffer, buffer_position)) = editor
         .buffer
         .read(cx)
         .text_anchor_for_position(trigger_anchor.clone(), cx)
-    {
-        output
-    } else {
+    else {
         return;
     };
 
-    let excerpt_id = if let Some((excerpt_id, _, _)) = editor
+    let Some((excerpt_id, _, _)) = editor
         .buffer()
         .read(cx)
         .excerpt_containing(trigger_anchor.clone(), cx)
-    {
-        excerpt_id
-    } else {
+    else {
         return;
     };
 
-    let project = if let Some(project) = editor.project.clone() {
-        project
-    } else {
+    let Some(project) = editor.project.clone() else {
         return;
     };
 
-    // Don't request again if the location is within the symbol region of a previous request with the same kind
-    if let Some(symbol_range) = &editor.link_go_to_definition_state.symbol_range {
-        if same_kind && symbol_range.point_within_range(&trigger_point, &snapshot) {
+    let same_kind = hovered_link_state.preferred_kind == preferred_kind
+        || hovered_link_state
+            .links
+            .first()
+            .is_some_and(|d| matches!(d, HoverLink::Url(_)));
+
+    if same_kind {
+        if is_cached && (&hovered_link_state.last_trigger_point == &trigger_point)
+            || hovered_link_state
+                .symbol_range
+                .as_ref()
+                .is_some_and(|symbol_range| {
+                    symbol_range.point_within_range(&trigger_point, &snapshot)
+                })
+        {
+            editor.hovered_link_state = Some(hovered_link_state);
             return;
         }
+    } else {
+        editor.hide_hovered_link(cx)
     }
 
-    let task = cx.spawn(|this, mut cx| {
+    let snapshot = snapshot.buffer_snapshot.clone();
+    hovered_link_state.task = Some(cx.spawn(|this, mut cx| {
         async move {
             let result = match &trigger_point {
                 TriggerPoint::Text(_) => {
-                    // query the LSP for definition info
-                    project
-                        .update(&mut cx, |project, cx| match definition_kind {
-                            LinkDefinitionKind::Symbol => {
-                                project.definition(&buffer, buffer_position, cx)
-                            }
-
-                            LinkDefinitionKind::Type => {
-                                project.type_definition(&buffer, buffer_position, cx)
-                            }
-                        })?
-                        .await
-                        .ok()
-                        .map(|definition_result| {
+                    if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
+                        this.update(&mut cx, |_, _| {
+                            let start =
+                                snapshot.anchor_in_excerpt(excerpt_id.clone(), url_range.start);
+                            let end = snapshot.anchor_in_excerpt(excerpt_id.clone(), url_range.end);
                             (
-                                definition_result.iter().find_map(|link| {
-                                    link.origin.as_ref().map(|origin| {
-                                        let start = snapshot.buffer_snapshot.anchor_in_excerpt(
-                                            excerpt_id.clone(),
-                                            origin.range.start,
-                                        );
-                                        let end = snapshot.buffer_snapshot.anchor_in_excerpt(
-                                            excerpt_id.clone(),
-                                            origin.range.end,
-                                        );
-                                        RangeInEditor::Text(start..end)
-                                    })
-                                }),
-                                definition_result
-                                    .into_iter()
-                                    .map(GoToDefinitionLink::Text)
-                                    .collect(),
+                                Some(RangeInEditor::Text(start..end)),
+                                vec![HoverLink::Url(url)],
                             )
                         })
+                        .ok()
+                    } else {
+                        // query the LSP for definition info
+                        project
+                            .update(&mut cx, |project, cx| match preferred_kind {
+                                LinkDefinitionKind::Symbol => {
+                                    project.definition(&buffer, buffer_position, cx)
+                                }
+
+                                LinkDefinitionKind::Type => {
+                                    project.type_definition(&buffer, buffer_position, cx)
+                                }
+                            })?
+                            .await
+                            .ok()
+                            .map(|definition_result| {
+                                (
+                                    definition_result.iter().find_map(|link| {
+                                        link.origin.as_ref().map(|origin| {
+                                            let start = snapshot.anchor_in_excerpt(
+                                                excerpt_id.clone(),
+                                                origin.range.start,
+                                            );
+                                            let end = snapshot.anchor_in_excerpt(
+                                                excerpt_id.clone(),
+                                                origin.range.end,
+                                            );
+                                            RangeInEditor::Text(start..end)
+                                        })
+                                    }),
+                                    definition_result.into_iter().map(HoverLink::Text).collect(),
+                                )
+                            })
+                    }
                 }
                 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
                     Some(RangeInEditor::Inlay(highlight.clone())),
-                    vec![GoToDefinitionLink::InlayHint(
-                        lsp_location.clone(),
-                        *server_id,
-                    )],
+                    vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
                 )),
             };
 
             this.update(&mut cx, |this, cx| {
                 // Clear any existing highlights
-                this.clear_highlights::<LinkGoToDefinitionState>(cx);
-                this.link_go_to_definition_state.kind = Some(definition_kind);
-                this.link_go_to_definition_state.symbol_range = result
+                this.clear_highlights::<HoveredLinkState>(cx);
+                let Some(hovered_link_state) = this.hovered_link_state.as_mut() else {
+                    return;
+                };
+                hovered_link_state.preferred_kind = preferred_kind;
+                hovered_link_state.symbol_range = result
                     .as_ref()
                     .and_then(|(symbol_range, _)| symbol_range.clone());
 
                 if let Some((symbol_range, definitions)) = result {
-                    this.link_go_to_definition_state.definitions = definitions.clone();
+                    hovered_link_state.links = definitions.clone();
 
                     let buffer_snapshot = buffer.read(cx).snapshot();
 
@@ -459,7 +497,7 @@ pub fn show_link_definition(
                     let any_definition_does_not_contain_current_location =
                         definitions.iter().any(|definition| {
                             match &definition {
-                                GoToDefinitionLink::Text(link) => {
+                                HoverLink::Text(link) => {
                                     if link.target.buffer == buffer {
                                         let range = &link.target.range;
                                         // Expand range by one character as lsp definition ranges include positions adjacent
@@ -481,7 +519,8 @@ pub fn show_link_definition(
                                         true
                                     }
                                 }
-                                GoToDefinitionLink::InlayHint(_, _) => true,
+                                HoverLink::InlayHint(_, _) => true,
+                                HoverLink::Url(_) => true,
                             }
                         });
 
@@ -497,7 +536,6 @@ pub fn show_link_definition(
                         let highlight_range =
                             symbol_range.unwrap_or_else(|| match &trigger_point {
                                 TriggerPoint::Text(trigger_anchor) => {
-                                    let snapshot = &snapshot.buffer_snapshot;
                                     // If no symbol range returned from language server, use the surrounding word.
                                     let (offset_range, _) =
                                         snapshot.surrounding_word(*trigger_anchor);
@@ -512,21 +550,14 @@ pub fn show_link_definition(
                             });
 
                         match highlight_range {
-                            RangeInEditor::Text(text_range) => this
-                                .highlight_text::<LinkGoToDefinitionState>(
-                                    vec![text_range],
-                                    style,
-                                    cx,
-                                ),
+                            RangeInEditor::Text(text_range) => {
+                                this.highlight_text::<HoveredLinkState>(vec![text_range], style, cx)
+                            }
                             RangeInEditor::Inlay(highlight) => this
-                                .highlight_inlays::<LinkGoToDefinitionState>(
-                                    vec![highlight],
-                                    style,
-                                    cx,
-                                ),
+                                .highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
                         }
                     } else {
-                        hide_link_definition(this, cx);
+                        this.hide_hovered_link(cx);
                     }
                 }
             })?;
@@ -534,78 +565,68 @@ pub fn show_link_definition(
             Ok::<_, anyhow::Error>(())
         }
         .log_err()
-    });
+    }));
 
-    editor.link_go_to_definition_state.task = Some(task);
+    editor.hovered_link_state = Some(hovered_link_state);
 }
 
-pub fn hide_link_definition(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
-    if editor.link_go_to_definition_state.symbol_range.is_some()
-        || !editor.link_go_to_definition_state.definitions.is_empty()
-    {
-        editor.link_go_to_definition_state.symbol_range.take();
-        editor.link_go_to_definition_state.definitions.clear();
-        cx.notify();
-    }
+fn find_url(
+    buffer: &Model<language::Buffer>,
+    position: text::Anchor,
+    mut cx: AsyncWindowContext,
+) -> Option<(Range<text::Anchor>, String)> {
+    const LIMIT: usize = 2048;
 
-    editor.link_go_to_definition_state.task = None;
-
-    editor.clear_highlights::<LinkGoToDefinitionState>(cx);
-}
-
-pub fn go_to_fetched_definition(
-    editor: &mut Editor,
-    point: PointForPosition,
-    split: bool,
-    cx: &mut ViewContext<Editor>,
-) {
-    go_to_fetched_definition_of_kind(LinkDefinitionKind::Symbol, editor, point, split, cx);
-}
+    let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
+        return None;
+    };
 
-pub fn go_to_fetched_type_definition(
-    editor: &mut Editor,
-    point: PointForPosition,
-    split: bool,
-    cx: &mut ViewContext<Editor>,
-) {
-    go_to_fetched_definition_of_kind(LinkDefinitionKind::Type, editor, point, split, cx);
-}
+    let offset = position.to_offset(&snapshot);
+    let mut token_start = offset;
+    let mut token_end = offset;
+    let mut found_start = false;
+    let mut found_end = false;
 
-fn go_to_fetched_definition_of_kind(
-    kind: LinkDefinitionKind,
-    editor: &mut Editor,
-    point: PointForPosition,
-    split: bool,
-    cx: &mut ViewContext<Editor>,
-) {
-    let cached_definitions = editor.link_go_to_definition_state.definitions.clone();
-    hide_link_definition(editor, cx);
-    let cached_definitions_kind = editor.link_go_to_definition_state.kind;
-
-    let is_correct_kind = cached_definitions_kind == Some(kind);
-    if !cached_definitions.is_empty() && is_correct_kind {
-        if !editor.focus_handle.is_focused(cx) {
-            cx.focus(&editor.focus_handle);
+    for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
+        if ch.is_whitespace() {
+            found_start = true;
+            break;
         }
+        token_start -= ch.len_utf8();
+    }
+    if !found_start {
+        return None;
+    }
 
-        editor.navigate_to_definitions(cached_definitions, split, cx);
-    } else {
-        editor.select(
-            SelectPhase::Begin {
-                position: point.next_valid,
-                add: false,
-                click_count: 1,
-            },
-            cx,
-        );
+    for ch in snapshot
+        .chars_at(offset)
+        .take(LIMIT - (offset - token_start))
+    {
+        if ch.is_whitespace() {
+            found_end = true;
+            break;
+        }
+        token_end += ch.len_utf8();
+    }
+    if !found_end {
+        return None;
+    }
 
-        if point.as_valid().is_some() {
-            match kind {
-                LinkDefinitionKind::Symbol => editor.go_to_definition(&GoToDefinition, cx),
-                LinkDefinitionKind::Type => editor.go_to_type_definition(&GoToTypeDefinition, cx),
-            }
+    let mut finder = LinkFinder::new();
+    finder.kinds(&[LinkKind::Url]);
+    let input = snapshot
+        .text_for_range(token_start..token_end)
+        .collect::<String>();
+
+    let relative_offset = offset - token_start;
+    for link in finder.links(&input) {
+        if link.start() <= relative_offset && link.end() >= relative_offset {
+            let range = snapshot.anchor_before(token_start + link.start())
+                ..snapshot.anchor_after(token_start + link.end());
+            return Some((range, link.as_str().to_string()));
         }
     }
+    None
 }
 
 #[cfg(test)]
@@ -616,16 +637,18 @@ mod tests {
         editor_tests::init_test,
         inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
         test::editor_lsp_test_context::EditorLspTestContext,
+        DisplayPoint,
     };
     use futures::StreamExt;
-    use gpui::{Modifiers, ModifiersChangedEvent};
+    use gpui::Modifiers;
     use indoc::indoc;
     use language::language_settings::InlayHintSettings;
     use lsp::request::{GotoDefinition, GotoTypeDefinition};
     use util::assert_set_eq;
+    use workspace::item::Item;
 
     #[gpui::test]
-    async fn test_link_go_to_type_definition(cx: &mut gpui::TestAppContext) {
+    async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
         init_test(cx, |_| {});
 
         let mut cx = EditorLspTestContext::new_rust(
@@ -642,12 +665,9 @@ mod tests {
             struct A;
             let vˇariable = A;
         "});
+        let screen_coord = cx.editor(|editor, cx| editor.pixel_position_of_cursor(cx));
 
         // Basic hold cmd+shift, expect highlight in region if response contains type definition
-        let hover_point = cx.display_point(indoc! {"
-            struct A;
-            let vˇariable = A;
-        "});
         let symbol_range = cx.lsp_range(indoc! {"
             struct A;
             let «variable» = A;
@@ -657,6 +677,8 @@ mod tests {
             let variable = A;
         "});
 
+        cx.run_until_parked();
+
         let mut requests =
             cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
                 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
@@ -669,70 +691,28 @@ mod tests {
                 ])))
             });
 
-        // Press cmd+shift to trigger highlight
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                true,
-                cx,
-            );
-        });
+        cx.cx
+            .cx
+            .simulate_mouse_move(screen_coord.unwrap(), Modifiers::command_shift());
+
         requests.next().await;
-        cx.background_executor.run_until_parked();
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.run_until_parked();
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
             struct A;
             let «variable» = A;
         "});
 
-        // Unpress shift causes highlight to go away (normal goto-definition is not valid here)
-        cx.update_editor(|editor, cx| {
-            crate::element::EditorElement::modifiers_changed(
-                editor,
-                &ModifiersChangedEvent {
-                    modifiers: Modifiers {
-                        command: true,
-                        ..Default::default()
-                    },
-                    ..Default::default()
-                },
-                cx,
-            );
-        });
+        cx.simulate_modifiers_change(Modifiers::command());
+        cx.run_until_parked();
         // Assert no link highlights
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
-            struct A;
-            let variable = A;
-        "});
-
-        // Cmd+shift click without existing definition requests and jumps
-        let hover_point = cx.display_point(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
             struct A;
-            let vˇariable = A;
-        "});
-        let target_range = cx.lsp_range(indoc! {"
-            struct «A»;
             let variable = A;
         "});
 
-        let mut requests =
-            cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
-                Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
-                    lsp::LocationLink {
-                        origin_selection_range: None,
-                        target_uri: url,
-                        target_range,
-                        target_selection_range: target_range,
-                    },
-                ])))
-            });
-
-        cx.update_editor(|editor, cx| {
-            go_to_fetched_type_definition(editor, PointForPosition::valid(hover_point), false, cx);
-        });
-        requests.next().await;
-        cx.background_executor.run_until_parked();
+        cx.cx
+            .cx
+            .simulate_click(screen_coord.unwrap(), Modifiers::command_shift());
 
         cx.assert_editor_state(indoc! {"
             struct «Aˇ»;
@@ -741,7 +721,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) {
+    async fn test_hover_links(cx: &mut gpui::TestAppContext) {
         init_test(cx, |_| {});
 
         let mut cx = EditorLspTestContext::new_rust(
@@ -759,7 +739,7 @@ mod tests {
             "});
 
         // Basic hold cmd, expect highlight in region if response contains definition
-        let hover_point = cx.display_point(indoc! {"
+        let hover_point = cx.pixel_position(indoc! {"
                 fn test() { do_wˇork(); }
                 fn do_work() { test(); }
             "});
@@ -783,65 +763,42 @@ mod tests {
             ])))
         });
 
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                false,
-                cx,
-            );
-        });
+        cx.simulate_mouse_move(hover_point, Modifiers::command());
         requests.next().await;
         cx.background_executor.run_until_parked();
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { «do_work»(); }
                 fn do_work() { test(); }
             "});
 
         // Unpress cmd causes highlight to go away
-        cx.update_editor(|editor, cx| {
-            crate::element::EditorElement::modifiers_changed(editor, &Default::default(), cx);
-        });
-
-        // Assert no link highlights
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.simulate_modifiers_change(Modifiers::none());
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { test(); }
             "});
 
-        // Response without source range still highlights word
-        cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_trigger_point = None);
         let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
             Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
                 lsp::LocationLink {
-                    // No origin range
-                    origin_selection_range: None,
+                    origin_selection_range: Some(symbol_range),
                     target_uri: url.clone(),
                     target_range,
                     target_selection_range: target_range,
                 },
             ])))
         });
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                false,
-                cx,
-            );
-        });
+
+        cx.simulate_mouse_move(hover_point, Modifiers::command());
         requests.next().await;
         cx.background_executor.run_until_parked();
-
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { «do_work»(); }
                 fn do_work() { test(); }
             "});
 
         // Moving mouse to location with no response dismisses highlight
-        let hover_point = cx.display_point(indoc! {"
+        let hover_point = cx.pixel_position(indoc! {"
                 fˇn test() { do_work(); }
                 fn do_work() { test(); }
             "});
@@ -851,42 +808,26 @@ mod tests {
                 // No definitions returned
                 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
             });
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                false,
-                cx,
-            );
-        });
+        cx.simulate_mouse_move(hover_point, Modifiers::command());
+
         requests.next().await;
         cx.background_executor.run_until_parked();
 
         // Assert no link highlights
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { test(); }
             "});
 
-        // Move mouse without cmd and then pressing cmd triggers highlight
-        let hover_point = cx.display_point(indoc! {"
+        // // Move mouse without cmd and then pressing cmd triggers highlight
+        let hover_point = cx.pixel_position(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { teˇst(); }
             "});
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                false,
-                false,
-                cx,
-            );
-        });
-        cx.background_executor.run_until_parked();
+        cx.simulate_mouse_move(hover_point, Modifiers::none());
 
         // Assert no link highlights
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { test(); }
             "});
@@ -910,73 +851,44 @@ mod tests {
                 },
             ])))
         });
-        cx.update_editor(|editor, cx| {
-            crate::element::EditorElement::modifiers_changed(
-                editor,
-                &ModifiersChangedEvent {
-                    modifiers: Modifiers {
-                        command: true,
-                        ..Default::default()
-                    },
-                },
-                cx,
-            );
-        });
+
+        cx.simulate_modifiers_change(Modifiers::command());
+
         requests.next().await;
         cx.background_executor.run_until_parked();
 
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { «test»(); }
             "});
 
-        cx.cx.cx.deactivate_window();
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.deactivate_window();
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { test(); }
             "});
 
-        // Moving the mouse restores the highlights.
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                false,
-                cx,
-            );
-        });
+        cx.simulate_mouse_move(hover_point, Modifiers::command());
         cx.background_executor.run_until_parked();
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { «test»(); }
             "});
 
         // Moving again within the same symbol range doesn't re-request
-        let hover_point = cx.display_point(indoc! {"
+        let hover_point = cx.pixel_position(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { tesˇt(); }
             "});
-        cx.update_editor(|editor, cx| {
-            update_go_to_definition_link(
-                editor,
-                Some(GoToDefinitionTrigger::Text(hover_point)),
-                true,
-                false,
-                cx,
-            );
-        });
+        cx.simulate_mouse_move(hover_point, Modifiers::command());
         cx.background_executor.run_until_parked();
-        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
                 fn test() { do_work(); }
                 fn do_work() { «test»(); }
             "});
 
         // Cmd click with existing definition doesn't re-request and dismisses highlight
-        cx.update_editor(|editor, cx| {
-            go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx);
-        });
-        // Assert selection moved to to definition
+        cx.simulate_click(hover_point, Modifiers::command());
         cx.lsp
             .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
                 // Empty definition response to make sure we aren't hitting the lsp and using

crates/editor/src/hover_popover.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     display_map::{InlayOffset, ToDisplayPoint},
-    link_go_to_definition::{InlayHighlight, RangeInEditor},
+    hover_links::{InlayHighlight, RangeInEditor},
     Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSettings, EditorSnapshot, EditorStyle,
     ExcerptId, Hover, RangeToAnchorExt,
 };
@@ -605,8 +605,8 @@ mod tests {
     use crate::{
         editor_tests::init_test,
         element::PointForPosition,
+        hover_links::update_inlay_link_and_hover_points,
         inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
-        link_go_to_definition::update_inlay_link_and_hover_points,
         test::editor_lsp_test_context::EditorLspTestContext,
         InlayId,
     };

crates/editor/src/items.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
-    editor_settings::SeedQuerySetting, link_go_to_definition::hide_link_definition,
-    persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, Editor, EditorEvent, EditorSettings,
-    ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _,
+    editor_settings::SeedQuerySetting, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll,
+    Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot,
+    NavigationData, ToPoint as _,
 };
 use anyhow::{anyhow, Context as _, Result};
 use collections::HashSet;
@@ -682,8 +682,7 @@ impl Item for Editor {
     }
 
     fn workspace_deactivated(&mut self, cx: &mut ViewContext<Self>) {
-        hide_link_definition(self, cx);
-        self.link_go_to_definition_state.last_trigger_point = None;
+        self.hide_hovered_link(cx);
     }
 
     fn is_dirty(&self, cx: &AppContext) -> bool {

crates/editor/src/test/editor_test_context.rs 🔗

@@ -4,7 +4,8 @@ use crate::{
 use collections::BTreeMap;
 use futures::Future;
 use gpui::{
-    AnyWindowHandle, AppContext, Keystroke, ModelContext, View, ViewContext, VisualTestContext,
+    AnyWindowHandle, AppContext, Keystroke, ModelContext, Pixels, Point, View, ViewContext,
+    VisualTestContext,
 };
 use indoc::indoc;
 use itertools::Itertools;
@@ -187,6 +188,31 @@ impl EditorTestContext {
         ranges[0].start.to_display_point(&snapshot)
     }
 
+    pub fn pixel_position(&mut self, marked_text: &str) -> Point<Pixels> {
+        let display_point = self.display_point(marked_text);
+        self.pixel_position_for(display_point)
+    }
+
+    pub fn pixel_position_for(&mut self, display_point: DisplayPoint) -> Point<Pixels> {
+        self.update_editor(|editor, cx| {
+            let newest_point = editor.selections.newest_display(cx).head();
+            let pixel_position = editor.pixel_position_of_newest_cursor.unwrap();
+            let line_height = editor
+                .style()
+                .unwrap()
+                .text
+                .line_height_in_pixels(cx.rem_size());
+            let snapshot = editor.snapshot(cx);
+            let details = editor.text_layout_details(cx);
+
+            let y = pixel_position.y
+                + line_height * (display_point.row() as f32 - newest_point.row() as f32);
+            let x = pixel_position.x + snapshot.x_for_display_point(display_point, &details)
+                - snapshot.x_for_display_point(newest_point, &details);
+            Point::new(x, y)
+        })
+    }
+
     // Returns anchors for the current buffer using `«` and `»`
     pub fn text_anchor_range(&mut self, marked_text: &str) -> Range<language::Anchor> {
         let ranges = self.ranges(marked_text);
@@ -343,7 +369,7 @@ impl EditorTestContext {
 }
 
 impl Deref for EditorTestContext {
-    type Target = gpui::TestAppContext;
+    type Target = gpui::VisualTestContext;
 
     fn deref(&self) -> &Self::Target {
         &self.cx

crates/gpui/src/app/test_context.rs 🔗

@@ -1,9 +1,10 @@
 use crate::{
     Action, AnyElement, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext,
     AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, Context, Entity, EventEmitter,
-    ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, Pixels, Platform,
-    Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, TextSystem, View,
-    ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions,
+    ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, Modifiers,
+    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
+    Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow,
+    TextSystem, View, ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions,
 };
 use anyhow::{anyhow, bail};
 use futures::{Stream, StreamExt};
@@ -236,6 +237,11 @@ impl TestAppContext {
         self.test_platform.has_pending_prompt()
     }
 
+    /// All the urls that have been opened with cx.open_url() during this test.
+    pub fn opened_url(&self) -> Option<String> {
+        self.test_platform.opened_url.borrow().clone()
+    }
+
     /// Simulates the user resizing the window to the new size.
     pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size<Pixels>) {
         self.test_window(window_handle).simulate_resize(size);
@@ -625,6 +631,36 @@ impl<'a> VisualTestContext {
         self.cx.simulate_input(self.window, input)
     }
 
+    /// Simulate a mouse move event to the given point
+    pub fn simulate_mouse_move(&mut self, position: Point<Pixels>, modifiers: Modifiers) {
+        self.simulate_event(MouseMoveEvent {
+            position,
+            modifiers,
+            pressed_button: None,
+        })
+    }
+
+    /// Simulate a primary mouse click at the given point
+    pub fn simulate_click(&mut self, position: Point<Pixels>, modifiers: Modifiers) {
+        self.simulate_event(MouseDownEvent {
+            position,
+            modifiers,
+            button: MouseButton::Left,
+            click_count: 1,
+        });
+        self.simulate_event(MouseUpEvent {
+            position,
+            modifiers,
+            button: MouseButton::Left,
+            click_count: 1,
+        });
+    }
+
+    /// Simulate a modifiers changed event
+    pub fn simulate_modifiers_change(&mut self, modifiers: Modifiers) {
+        self.simulate_event(ModifiersChangedEvent { modifiers })
+    }
+
     /// Simulates the user resizing the window to the new size.
     pub fn simulate_resize(&self, size: Size<Pixels>) {
         self.simulate_window_resize(self.window, size)

crates/gpui/src/platform/keystroke.rs 🔗

@@ -170,4 +170,34 @@ impl Modifiers {
     pub fn modified(&self) -> bool {
         self.control || self.alt || self.shift || self.command || self.function
     }
+
+    /// helper method for Modifiers with no modifiers
+    pub fn none() -> Modifiers {
+        Default::default()
+    }
+
+    /// helper method for Modifiers with just command
+    pub fn command() -> Modifiers {
+        Modifiers {
+            command: true,
+            ..Default::default()
+        }
+    }
+
+    /// helper method for Modifiers with just shift
+    pub fn shift() -> Modifiers {
+        Modifiers {
+            shift: true,
+            ..Default::default()
+        }
+    }
+
+    /// helper method for Modifiers with command + shift
+    pub fn command_shift() -> Modifiers {
+        Modifiers {
+            shift: true,
+            command: true,
+            ..Default::default()
+        }
+    }
 }

crates/gpui/src/platform/test/platform.rs 🔗

@@ -25,6 +25,7 @@ pub(crate) struct TestPlatform {
     active_cursor: Mutex<CursorStyle>,
     current_clipboard_item: Mutex<Option<ClipboardItem>>,
     pub(crate) prompts: RefCell<TestPrompts>,
+    pub opened_url: RefCell<Option<String>>,
     weak: Weak<Self>,
 }
 
@@ -45,6 +46,7 @@ impl TestPlatform {
             active_window: Default::default(),
             current_clipboard_item: Mutex::new(None),
             weak: weak.clone(),
+            opened_url: Default::default(),
         })
     }
 
@@ -188,8 +190,8 @@ impl Platform for TestPlatform {
 
     fn stop_display_link(&self, _display_id: DisplayId) {}
 
-    fn open_url(&self, _url: &str) {
-        unimplemented!()
+    fn open_url(&self, url: &str) {
+        *self.opened_url.borrow_mut() = Some(url.to_string())
     }
 
     fn on_open_urls(&self, _callback: Box<dyn FnMut(Vec<String>)>) {