diff --git a/Cargo.lock b/Cargo.lock index 2604753f71..14b5882ff9 100644 --- a/Cargo.lock +++ b/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" diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 3e6b4d709c..be8745e4c2 100644 --- a/crates/editor/Cargo.toml +++ b/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" } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 4ad43ef0cf..23507eab3e 100644 --- a/crates/editor/src/display_map.rs +++ b/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}; diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index c0d5198ddd..f1f52b4927 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/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; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 40f385be9b..9c84dd0fe9 100644 --- a/crates/editor/src/editor.rs +++ b/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, hover_state: HoverState, gutter_hovered: bool, - link_go_to_definition_state: LinkGoToDefinitionState, + hovered_link_state: Option, 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, + mut definitions: Vec, split: bool, cx: &mut ViewContext, ) { @@ -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::() - ) - }) - } - 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::() + ) + }), + 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::>(); (title, location_tasks) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e546cc272..46c85ddd10 100644 --- a/crates/editor/src/element.rs +++ b/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, + 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, + stacking_order: &StackingOrder, cx: &mut ViewContext, - ) -> 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::(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 { if self.previous_valid == self.exact_unclipped && self.next_valid == self.exact_unclipped { Some(self.previous_valid) diff --git a/crates/editor/src/link_go_to_definition.rs b/crates/editor/src/hover_links.rs similarity index 63% rename from crates/editor/src/link_go_to_definition.rs rename to crates/editor/src/hover_links.rs index c4da7fcd38..955aafde24 100644 --- a/crates/editor/src/link_go_to_definition.rs +++ b/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, +#[derive(Debug)] +pub struct HoveredLinkState { + pub last_trigger_point: TriggerPoint, + pub preferred_kind: LinkDefinitionKind, pub symbol_range: Option, - pub kind: Option, - pub definitions: Vec, + pub links: Vec, pub 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, } -#[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, - cmd_held: bool, - shift_held: bool, - cx: &mut ViewContext, -) { - 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)) - } - 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, +impl Editor { + pub(crate) fn update_hovered_link( + &mut self, + point_for_position: PointForPosition, + snapshot: &EditorSnapshot, + modifiers: Modifiers, + cx: &mut ViewContext, ) { - match (a, b) { - (TriggerPoint::Text(anchor_a), TriggerPoint::Text(anchor_b)) => { - if anchor_a.cmp(anchor_b, &snapshot.buffer_snapshot).is_eq() { - return; - } - } - (TriggerPoint::InlayHint(range_a, _, _), TriggerPoint::InlayHint(range_b, _, _)) => { - if range_a == range_b { - return; - } - } - _ => {} - } - } - - editor.link_go_to_definition_state.last_trigger_point = trigger_point.clone(); - - if pending_nonempty_selection { - hide_link_definition(editor, cx); - return; - } - - 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); + if !modifiers.command || self.has_pending_selection() { + self.hide_hovered_link(cx); 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); + } + None => { + update_inlay_link_and_hover_points( + &snapshot, + point_for_position, + self, + modifiers.command, + modifiers.shift, + cx, + ); + } + } } - hide_link_definition(editor, cx); + pub(crate) fn hide_hovered_link(&mut self, cx: &mut ViewContext) { + self.hovered_link_state.take(); + self.clear_highlights::(cx); + } + + pub(crate) fn handle_click_hovered_link( + &mut self, + point: PointForPosition, + modifiers: Modifiers, + cx: &mut ViewContext, + ) { + 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; + } + } + + // 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, ) { - 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::(cx); - this.link_go_to_definition_state.kind = Some(definition_kind); - this.link_go_to_definition_state.symbol_range = result + this.clear_highlights::(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::( - vec![text_range], - style, - cx, - ), + RangeInEditor::Text(text_range) => { + this.highlight_text::(vec![text_range], style, cx) + } RangeInEditor::Inlay(highlight) => this - .highlight_inlays::( - vec![highlight], - style, - cx, - ), + .highlight_inlays::(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) { - if editor.link_go_to_definition_state.symbol_range.is_some() - || !editor.link_go_to_definition_state.definitions.is_empty() +fn find_url( + buffer: &Model, + position: text::Anchor, + mut cx: AsyncWindowContext, +) -> Option<(Range, String)> { + const LIMIT: usize = 2048; + + let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else { + return None; + }; + + 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; + + 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; + } + + for ch in snapshot + .chars_at(offset) + .take(LIMIT - (offset - token_start)) { - editor.link_go_to_definition_state.symbol_range.take(); - editor.link_go_to_definition_state.definitions.clear(); - cx.notify(); + if ch.is_whitespace() { + found_end = true; + break; + } + token_end += ch.len_utf8(); + } + if !found_end { + return None; } - editor.link_go_to_definition_state.task = None; + let mut finder = LinkFinder::new(); + finder.kinds(&[LinkKind::Url]); + let input = snapshot + .text_for_range(token_start..token_end) + .collect::(); - editor.clear_highlights::(cx); -} - -pub fn go_to_fetched_definition( - editor: &mut Editor, - point: PointForPosition, - split: bool, - cx: &mut ViewContext, -) { - go_to_fetched_definition_of_kind(LinkDefinitionKind::Symbol, editor, point, split, cx); -} - -pub fn go_to_fetched_type_definition( - editor: &mut Editor, - point: PointForPosition, - split: bool, - cx: &mut ViewContext, -) { - go_to_fetched_definition_of_kind(LinkDefinitionKind::Type, editor, point, split, cx); -} - -fn go_to_fetched_definition_of_kind( - kind: LinkDefinitionKind, - editor: &mut Editor, - point: PointForPosition, - split: bool, - cx: &mut ViewContext, -) { - 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); - } - - editor.navigate_to_definitions(cached_definitions, split, cx); - } else { - editor.select( - SelectPhase::Begin { - position: point.next_valid, - add: false, - click_count: 1, - }, - cx, - ); - - 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 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::(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::(indoc! {" + cx.run_until_parked(); + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(indoc! {" struct A; let variable = A; "}); - // Cmd+shift click without existing definition requests and jumps - let hover_point = cx.display_point(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::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.simulate_modifiers_change(Modifiers::none()); + cx.assert_editor_text_highlights::(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::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(indoc! {" fn test() { do_work(); } fn do_work() { «test»(); } "}); - cx.cx.cx.deactivate_window(); - cx.assert_editor_text_highlights::(indoc! {" + cx.deactivate_window(); + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(indoc! {" + cx.assert_editor_text_highlights::(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::(move |_, _| async move { // Empty definition response to make sure we aren't hitting the lsp and using @@ -990,13 +902,13 @@ mod tests { "}); // Assert no link highlights after jump - cx.assert_editor_text_highlights::(indoc! {" + cx.assert_editor_text_highlights::(indoc! {" fn test() { do_work(); } fn do_work() { test(); } "}); // Cmd click without existing definition requests and jumps - let hover_point = cx.display_point(indoc! {" + let hover_point = cx.pixel_position(indoc! {" fn test() { do_wˇork(); } fn do_work() { test(); } "}); @@ -1015,9 +927,7 @@ mod tests { }, ]))) }); - cx.update_editor(|editor, cx| { - go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx); - }); + cx.simulate_click(hover_point, Modifiers::command()); requests.next().await; cx.background_executor.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -1027,7 +937,7 @@ mod tests { // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens // 2. Selection is completed, hovering - let hover_point = cx.display_point(indoc! {" + let hover_point = cx.pixel_position(indoc! {" fn test() { do_wˇork(); } fn do_work() { test(); } "}); @@ -1060,18 +970,10 @@ mod tests { s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character) }); }); - 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(); assert!(requests.try_next().is_err()); - cx.assert_editor_text_highlights::(indoc! {" + cx.assert_editor_text_highlights::(indoc! {" fn test() { do_work(); } fn do_work() { test(); } "}); @@ -1079,7 +981,7 @@ mod tests { } #[gpui::test] - async fn test_link_go_to_inlay(cx: &mut gpui::TestAppContext) { + async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { settings.defaults.inlay_hints = Some(InlayHintSettings { enabled: true, @@ -1167,39 +1069,25 @@ mod tests { .get(0) .cloned() .unwrap(); - let hint_hover_position = cx.update_editor(|editor, cx| { + let midpoint = cx.update_editor(|editor, cx| { let snapshot = editor.snapshot(cx); let previous_valid = inlay_range.start.to_display_point(&snapshot); let next_valid = inlay_range.end.to_display_point(&snapshot); assert_eq!(previous_valid.row(), next_valid.row()); assert!(previous_valid.column() < next_valid.column()); - let exact_unclipped = DisplayPoint::new( + DisplayPoint::new( previous_valid.row(), previous_valid.column() + (hint_label.len() / 2) as u32, - ); - PointForPosition { - previous_valid, - next_valid, - exact_unclipped, - column_overshoot_after_line_end: 0, - } + ) }); // Press cmd to trigger highlight - cx.update_editor(|editor, cx| { - update_inlay_link_and_hover_points( - &editor.snapshot(cx), - hint_hover_position, - editor, - true, - false, - cx, - ); - }); + let hover_point = cx.pixel_position_for(midpoint); + cx.simulate_mouse_move(hover_point, Modifiers::command()); cx.background_executor.run_until_parked(); cx.update_editor(|editor, cx| { let snapshot = editor.snapshot(cx); let actual_highlights = snapshot - .inlay_highlights::() + .inlay_highlights::() .into_iter() .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight)) .collect::>(); @@ -1213,57 +1101,21 @@ mod tests { assert_set_eq!(actual_highlights, vec![&expected_highlight]); }); - // Unpress cmd causes highlight to go away - cx.update_editor(|editor, cx| { - crate::element::EditorElement::modifiers_changed( - editor, - &ModifiersChangedEvent { - modifiers: Modifiers { - command: false, - ..Default::default() - }, - ..Default::default() - }, - cx, - ); - }); + cx.simulate_mouse_move(hover_point, Modifiers::none()); // Assert no link highlights cx.update_editor(|editor, cx| { let snapshot = editor.snapshot(cx); let actual_ranges = snapshot - .text_highlight_ranges::() + .text_highlight_ranges::() .map(|ranges| ranges.as_ref().clone().1) .unwrap_or_default(); assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}"); }); - // Cmd+click without existing definition requests and jumps - cx.update_editor(|editor, cx| { - crate::element::EditorElement::modifiers_changed( - editor, - &ModifiersChangedEvent { - modifiers: Modifiers { - command: true, - ..Default::default() - }, - ..Default::default() - }, - cx, - ); - update_inlay_link_and_hover_points( - &editor.snapshot(cx), - hint_hover_position, - editor, - true, - false, - cx, - ); - }); + cx.simulate_modifiers_change(Modifiers::command()); cx.background_executor.run_until_parked(); - cx.update_editor(|editor, cx| { - go_to_fetched_type_definition(editor, hint_hover_position, false, cx); - }); + cx.simulate_click(hover_point, Modifiers::command()); cx.background_executor.run_until_parked(); cx.assert_editor_state(indoc! {" struct «TestStructˇ»; @@ -1273,4 +1125,35 @@ mod tests { } "}); } + + #[gpui::test] + async fn test_urls(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ. + "}); + + let screen_coord = cx.pixel_position(indoc! {" + Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case. + "}); + + cx.simulate_mouse_move(screen_coord, Modifiers::command()); + cx.assert_editor_text_highlights::(indoc! {" + Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case. + "}); + + cx.simulate_click(screen_coord, Modifiers::command()); + assert_eq!( + cx.opened_url(), + Some("https://zed.dev/channel/had-(oops)".into()) + ); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 668d00f1aa..b4253e074b 100644 --- a/crates/editor/src/hover_popover.rs +++ b/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, }; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index ac55ed8546..e95d958db5 100644 --- a/crates/editor/src/items.rs +++ b/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) { - 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 { diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 3289471e81..9ad0839088 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/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 { + 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 { + 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 { 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 diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index bf213e2818..0f64a0690f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/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 { + 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) { 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, 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, 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) { self.simulate_window_resize(self.window, size) diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 2e1acfa630..9cdffd5e61 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/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() + } + } } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 5aadc4b760..4d954b3266 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -25,6 +25,7 @@ pub(crate) struct TestPlatform { active_cursor: Mutex, current_clipboard_item: Mutex>, pub(crate) prompts: RefCell, + pub opened_url: RefCell>, weak: Weak, } @@ -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)>) {