use crate::{ Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot, GlobalDiagnosticRenderer, HighlightKey, Hover, display_map::{InlayOffset, ToDisplayPoint, is_invisible}, editor_settings::EditorSettingsScrollbarProxy, hover_links::{InlayHighlight, RangeInEditor}, movement::TextLayoutDetails, scroll::ScrollAmount, }; use anyhow::Context as _; use gpui::{ AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TaskExt, TextStyleRefinement, Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; use std::{ borrow::Cow, cell::{Cell, RefCell}, }; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; use theme_settings::ThemeSettings; use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; use workspace::{OpenOptions, OpenVisible, Workspace}; pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.; pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.; pub const POPOVER_RIGHT_OFFSET: Pixels = px(8.0); pub const HOVER_POPOVER_GAP: Pixels = px(10.); /// Bindable action which uses the most recent selection head to trigger a hover pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Context) { let head = editor.selections.newest_anchor().head(); show_hover(editor, head, true, window, cx); } /// The internal hover action dispatches between `show_hover` or `hide_hover` /// depending on whether a point to hover over is provided. pub fn hover_at( editor: &mut Editor, anchor: Option, mouse_position: Option>, window: &mut Window, cx: &mut Context, ) { if EditorSettings::get_global(cx).hover_popover_enabled { if show_keyboard_hover(editor, window, cx) { return; } if let Some(anchor) = anchor { editor.hover_state.hiding_delay_task = None; editor.hover_state.closest_mouse_distance = None; show_hover(editor, anchor, false, window, cx); } else if !editor.hover_state.visible() { editor.hover_state.info_task = None; } else { let settings = EditorSettings::get_global(cx); if !settings.hover_popover_sticky { hide_hover(editor, cx); return; } let mut getting_closer = false; if let Some(mouse_position) = mouse_position { getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position); } // If we are moving away and a timer is already running, just let it count down. if !getting_closer && editor.hover_state.hiding_delay_task.is_some() { return; } // If we are moving closer, or if no timer is running at all, start/restart the timer. let delay = Duration::from_millis(settings.hover_popover_hiding_delay.0); let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(delay).await; this.update(cx, |editor, cx| { hide_hover(editor, cx); }) .ok(); }); editor.hover_state.hiding_delay_task = Some(task); } } } pub fn show_keyboard_hover( editor: &mut Editor, window: &mut Window, cx: &mut Context, ) -> bool { if let Some(anchor) = editor.hover_state.info_popovers.iter().find_map(|p| { if *p.keyboard_grace.borrow() { p.anchor } else { None } }) { show_hover(editor, anchor, false, window, cx); return true; } if let Some(anchor) = editor .hover_state .diagnostic_popover .as_ref() .and_then(|d| { if *d.keyboard_grace.borrow() { Some(d.anchor) } else { None } }) { show_hover(editor, anchor, false, window, cx); return true; } false } pub struct InlayHover { pub(crate) range: InlayHighlight, pub tooltip: HoverBlock, } pub fn find_hovered_hint_part( label_parts: Vec, hint_start: InlayOffset, hovered_offset: InlayOffset, ) -> Option<(InlayHintLabelPart, Range)> { if hovered_offset >= hint_start { let mut offset_in_hint = hovered_offset - hint_start; let mut part_start = hint_start; for part in label_parts { let part_len = part.value.len(); if offset_in_hint >= part_len { offset_in_hint -= part_len; part_start.0 += part_len; } else { let part_end = InlayOffset(part_start.0 + part_len); return Some((part, part_start..part_end)); } } } None } pub fn hover_at_inlay( editor: &mut Editor, inlay_hover: InlayHover, window: &mut Window, cx: &mut Context, ) { if EditorSettings::get_global(cx).hover_popover_enabled { if editor.pending_rename.is_some() { return; } let Some(project) = editor.project.clone() else { return; }; if editor .hover_state .info_popovers .iter() .any(|InfoPopover { symbol_range, .. }| { if let RangeInEditor::Inlay(range) = symbol_range && range == &inlay_hover.range { // Hover triggered from same location as last time. Don't show again. return true; } false }) { return; } let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; editor.hover_state.hiding_delay_task = None; editor.hover_state.closest_mouse_distance = None; let task = cx.spawn_in(window, async move |this, cx| { async move { cx.background_executor() .timer(Duration::from_millis(hover_popover_delay)) .await; this.update(cx, |this, _| { this.hover_state.diagnostic_popover = None; })?; let language_registry = project.read_with(cx, |p, _| p.languages().clone()); let blocks = vec![inlay_hover.tooltip]; let parsed_content = parse_blocks(&blocks, Some(&language_registry), None, cx); let scroll_handle = ScrollHandle::new(); let subscription = this .update(cx, |_, cx| { parsed_content.as_ref().map(|parsed_content| { cx.observe(parsed_content, |_, _, cx| cx.notify()) }) }) .ok() .flatten(); let hover_popover = InfoPopover { symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()), parsed_content, scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }; this.update(cx, |this, cx| { // TODO: no background highlights happen for inlays currently this.hover_state.info_popovers = vec![hover_popover]; cx.notify(); })?; anyhow::Ok(()) } .log_err() .await }); editor.hover_state.info_task = Some(task); } } /// Hides the type information popup. /// Triggered by the `Hover` action when the cursor is not over a symbol or when the /// selections changed. pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool { let info_popovers = editor.hover_state.info_popovers.drain(..); let diagnostics_popover = editor.hover_state.diagnostic_popover.take(); let did_hide = info_popovers.count() > 0 || diagnostics_popover.is_some(); editor.hover_state.info_task = None; editor.hover_state.hiding_delay_task = None; editor.hover_state.closest_mouse_distance = None; editor.clear_background_highlights(HighlightKey::HoverState, cx); if did_hide { cx.notify(); } did_hide } /// Queries the LSP and shows type info and documentation /// about the symbol the mouse is currently hovering over. /// Triggered by the `Hover` action when the cursor may be over a symbol. fn show_hover( editor: &mut Editor, anchor: Anchor, ignore_timeout: bool, window: &mut Window, cx: &mut Context, ) -> Option<()> { if editor.pending_rename.is_some() { return None; } let snapshot = editor.snapshot(window, cx); let (buffer_position, _) = editor .buffer .read(cx) .snapshot(cx) .anchor_to_buffer_anchor(anchor)?; let buffer = editor.buffer.read(cx).buffer(buffer_position.buffer_id)?; let language_registry = editor .project() .map(|project| project.read(cx).languages().clone()); let provider = editor.semantics_provider.clone()?; editor.hover_state.hiding_delay_task = None; editor.hover_state.closest_mouse_distance = None; if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) || editor.hover_state.diagnostic_popover.is_some() { // Hover triggered from same location as last time. Don't show again. return None; } else { hide_hover(editor, cx); } } let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let all_diagnostics_active = editor.all_diagnostics_active(); let active_group_id = editor.active_diagnostic_group_id(); let renderer = GlobalDiagnosticRenderer::global(cx); let task = cx.spawn_in(window, async move |this, cx| { async move { // If we need to delay, delay a set amount initially before making the lsp request let delay = if ignore_timeout { None } else { let lsp_request_early = hover_popover_delay / 2; cx.background_executor() .timer(Duration::from_millis( hover_popover_delay - lsp_request_early, )) .await; // Construct delay task to wait for later let total_delay = Some( cx.background_executor() .timer(Duration::from_millis(lsp_request_early)), ); total_delay }; let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?; if let Some(delay) = delay { delay.await; } let offset = anchor.to_offset(&snapshot.buffer_snapshot()); let local_diagnostic = if all_diagnostics_active { None } else { snapshot .buffer_snapshot() .diagnostics_with_buffer_ids_in_range::(offset..offset) .filter(|(_, diagnostic)| { Some(diagnostic.diagnostic.group_id) != active_group_id }) // Find the entry with the most specific range .min_by_key(|(_, entry)| entry.range.end - entry.range.start) }; let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic { let group = snapshot .buffer_snapshot() .diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id) .collect::>(); let point_range = local_diagnostic .range .start .to_point(&snapshot.buffer_snapshot()) ..local_diagnostic .range .end .to_point(&snapshot.buffer_snapshot()); let markdown = cx.update(|_, cx| { renderer .as_ref() .and_then(|renderer| { renderer.render_hover( group, point_range, buffer_id, language_registry.clone(), cx, ) }) .context("no rendered diagnostic") })??; let (background_color, border_color) = cx.update(|_, cx| { let status_colors = cx.theme().status(); match local_diagnostic.diagnostic.severity { DiagnosticSeverity::ERROR => { (status_colors.error_background, status_colors.error_border) } DiagnosticSeverity::WARNING => ( status_colors.warning_background, status_colors.warning_border, ), DiagnosticSeverity::INFORMATION => { (status_colors.info_background, status_colors.info_border) } DiagnosticSeverity::HINT => { (status_colors.hint_background, status_colors.hint_border) } _ => ( status_colors.ignored_background, status_colors.ignored_border, ), } })?; let subscription = this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?; let local_diagnostic = DiagnosticEntry { diagnostic: local_diagnostic.diagnostic.to_owned(), range: snapshot .buffer_snapshot() .anchor_before(local_diagnostic.range.start) ..snapshot .buffer_snapshot() .anchor_after(local_diagnostic.range.end), }; let scroll_handle = ScrollHandle::new(); Some(DiagnosticPopover { local_diagnostic, markdown, border_color, scroll_handle, background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } else { None }; this.update(cx, |this, _| { this.hover_state.diagnostic_popover = diagnostic_popover; })?; let invisible_char = if let Some(invisible) = snapshot .buffer_snapshot() .chars_at(anchor) .next() .filter(|&c| is_invisible(c)) { let after = snapshot.buffer_snapshot().anchor_after( anchor.to_offset(&snapshot.buffer_snapshot()) + invisible.len_utf8(), ); Some((invisible, anchor..after)) } else if let Some(invisible) = snapshot .buffer_snapshot() .reversed_chars_at(anchor) .next() .filter(|&c| is_invisible(c)) { let before = snapshot.buffer_snapshot().anchor_before( anchor.to_offset(&snapshot.buffer_snapshot()) - invisible.len_utf8(), ); Some((invisible, before..anchor)) } else { None }; let hovers_response = if let Some(hover_request) = hover_request { hover_request.await.unwrap_or_default() } else { Vec::new() }; let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?; let mut hover_highlights = Vec::with_capacity(hovers_response.len()); let mut info_popovers = Vec::with_capacity( hovers_response.len() + if invisible_char.is_some() { 1 } else { 0 }, ); if let Some((invisible, range)) = invisible_char { let blocks = vec![HoverBlock { text: format!("Unicode character U+{:02X}", invisible as u32), kind: HoverBlockKind::PlainText, }]; let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx); let scroll_handle = ScrollHandle::new(); let subscription = this .update(cx, |_, cx| { parsed_content.as_ref().map(|parsed_content| { cx.observe(parsed_content, |_, _, cx| cx.notify()) }) }) .ok() .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } let doc_link_task = this .update(cx, |editor, cx| { editor.document_links_at(buffer.clone(), buffer_position, cx) }) .ok() .flatten(); let doc_link_tooltips = match doc_link_task { Some(task) => task .await .into_iter() .filter_map(|(_, link)| { let multi_buffer_range = snapshot .buffer_snapshot() .buffer_anchor_range_to_anchor_range(link.range.clone())?; let tooltip = link.tooltip?; Some((multi_buffer_range, tooltip)) }) .collect::>(), None => Vec::new(), }; for hover_result in hovers_response { // Create symbol range of anchors for highlighting and filtering of future requests. let range = hover_result .range .and_then(|range| { let range = snapshot .buffer_snapshot() .buffer_anchor_range_to_anchor_range(range)?; Some(range) }) .or_else(|| { let snapshot = &snapshot.buffer_snapshot(); let range = snapshot.syntax_ancestor(anchor..anchor)?.1; Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end)) }) .unwrap_or_else(|| anchor..anchor); let blocks = hover_result.contents; let language = hover_result.language; let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), language, cx); let scroll_handle = ScrollHandle::new(); hover_highlights.push(range.clone()); let subscription = this .update(cx, |_, cx| { parsed_content.as_ref().map(|parsed_content| { cx.observe(parsed_content, |_, _, cx| cx.notify()) }) }) .ok() .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }); } for (multi_buffer_range, tooltip) in doc_link_tooltips { let blocks = vec![HoverBlock { text: tooltip.to_string(), kind: HoverBlockKind::Markdown, }]; let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx); let scroll_handle = ScrollHandle::new(); let subscription = this .update(cx, |_, cx| { parsed_content.as_ref().map(|parsed_content| { cx.observe(parsed_content, |_, _, cx| cx.notify()) }) }) .ok() .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(multi_buffer_range), parsed_content, scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }); } this.update_in(cx, |editor, window, cx| { if hover_highlights.is_empty() { editor.clear_background_highlights(HighlightKey::HoverState, cx); } else { // Highlight the selected symbol using a background highlight editor.highlight_background( HighlightKey::HoverState, &hover_highlights, |_, theme| theme.colors().element_hover, // todo update theme cx, ); } editor.hover_state.info_popovers = info_popovers; cx.notify(); window.refresh(); })?; anyhow::Ok(()) } .log_err() .await }); editor.hover_state.info_task = Some(task); None } fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool { editor .hover_state .info_popovers .iter() .any(|InfoPopover { symbol_range, .. }| { symbol_range .as_text_range() .map(|range| { let hover_range = range.to_offset(&snapshot.buffer_snapshot()); let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // LSP returns a hover result for the end index of ranges that should be hovered, so we need to // use an inclusive range here to check if we should dismiss the popover (hover_range.start..=hover_range.end).contains(&offset) }) .unwrap_or(false) }) } fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool { editor .hover_state .diagnostic_popover .as_ref() .map(|diagnostic| { let hover_range = diagnostic .local_diagnostic .range .to_offset(&snapshot.buffer_snapshot()); let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // Here we do basically the same as in `same_info_hover`, see comment there for an explanation (hover_range.start..=hover_range.end).contains(&offset) }) .unwrap_or(false) } fn parse_blocks( blocks: &[HoverBlock], language_registry: Option<&Arc>, language: Option>, cx: &mut AsyncWindowContext, ) -> Option> { let combined_text = blocks .iter() .map(|block| match &block.kind { project::HoverBlockKind::PlainText | project::HoverBlockKind::Markdown => { Cow::Borrowed(block.text.trim()) } project::HoverBlockKind::Code { language } => { Cow::Owned(format!("```{}\n{}\n```", language, block.text.trim())) } }) .join("\n\n"); cx.new_window_entity(|_window, cx| { Markdown::new( combined_text.into(), language_registry.cloned(), language.map(|language| language.name()), cx, ) }) .ok() } pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); let ui_font_features = settings.ui_font.features.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); let buffer_font_family = settings.buffer_font.family.clone(); let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let buffer_font_weight = settings.buffer_font.weight; let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); MarkdownStyle { base_text_style, code_block: StyleRefinement::default() .my(rems(1.)) .font_buffer(cx) .font_features(buffer_font_features.clone()) .font_weight(buffer_font_weight), inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().background), font_family: Some(buffer_font_family), font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, font_weight: Some(buffer_font_weight), ..Default::default() }, rule_color: cx.theme().colors().border, block_quote_border_color: Color::Muted.color(cx), block_quote: TextStyleRefinement { color: Some(Color::Muted.color(cx)), ..Default::default() }, link: TextStyleRefinement { color: Some(cx.theme().colors().editor_foreground), underline: Some(gpui::UnderlineStyle { thickness: px(1.), color: Some(cx.theme().colors().editor_foreground), wavy: false, }), ..Default::default() }, syntax: cx.theme().syntax().clone(), selection_background_color: cx.theme().colors().element_selection_background, heading: StyleRefinement::default() .font_weight(FontWeight::BOLD) .text_base() .mt(rems(1.)) .mb_0(), table_columns_min_size: true, ..Default::default() } } pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); let ui_font_features = settings.ui_font.features.clone(); let buffer_font_family = settings.buffer_font.family.clone(); let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); MarkdownStyle { base_text_style, code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx), inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), font_family: Some(buffer_font_family), font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, ..Default::default() }, rule_color: cx.theme().colors().border, block_quote_border_color: Color::Muted.color(cx), block_quote: TextStyleRefinement { color: Some(Color::Muted.color(cx)), ..Default::default() }, link: TextStyleRefinement { color: Some(cx.theme().colors().editor_foreground), underline: Some(gpui::UnderlineStyle { thickness: px(1.), color: Some(cx.theme().colors().editor_foreground), wavy: false, }), ..Default::default() }, syntax: cx.theme().syntax().clone(), selection_background_color: cx.theme().colors().element_selection_background, height_is_multiple_of_line_height: true, heading: StyleRefinement::default() .font_weight(FontWeight::BOLD) .text_base() .mb_0(), table_columns_min_size: true, ..Default::default() } } fn parse_file_link(link: &str) -> Option<(PathBuf, Option)> { let uri = Url::parse(link).ok().filter(|uri| uri.scheme() == "file")?; let fragment = uri.fragment().map(ToOwned::to_owned); let path = uri.to_file_path().unwrap_or_else(|_| { let encoded = uri.path(); urlencoding::decode(encoded) .map(Cow::into_owned) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(encoded)) }); Some((path, fragment)) } pub fn open_markdown_url( workspace: Option>, link: SharedString, window: &mut Window, cx: &mut App, ) { if let Some((path, fragment)) = parse_file_link(&link) && let Some(workspace) = workspace { workspace.update(cx, |workspace, cx| { let task = workspace.open_abs_path( path, OpenOptions { visible: Some(OpenVisible::None), ..Default::default() }, window, cx, ); cx.spawn_in(window, async move |_, cx| { let item = task.await?; // Ruby LSP uses URLs with #L1,1-4,4 // we'll just take the first number and assume it's a line number let Some(fragment) = fragment else { return anyhow::Ok(()); }; let mut accum = 0u32; for c in fragment.chars() { if c >= '0' && c <= '9' && accum < u32::MAX / 2 { accum *= 10; accum += c as u32 - '0' as u32; } else if accum > 0 { break; } } if accum == 0 { return Ok(()); } let Some(editor) = cx.update(|_, cx| item.act_as::(cx))? else { return Ok(()); }; editor.update_in(cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([ text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0) ]); }); }) }) .detach_and_log_err(cx); }); return; } if let Some(workspace) = workspace { workspace.update(cx, |workspace, cx| { workspace.open_url_or_file(&link, None, window, cx); }); } else { cx.open_url(&link); } } #[derive(Default)] pub struct HoverState { pub info_popovers: Vec, pub diagnostic_popover: Option, pub info_task: Option>>, pub closest_mouse_distance: Option, pub hiding_delay_task: Option>, } impl HoverState { pub fn visible(&self) -> bool { !self.info_popovers.is_empty() || self.diagnostic_popover.is_some() } pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point) -> bool { if !self.visible() { return false; } let mut popover_bounds = Vec::new(); for info_popover in &self.info_popovers { if let Some(bounds) = info_popover.last_bounds.get() { popover_bounds.push(bounds); } } if let Some(diagnostic_popover) = &self.diagnostic_popover { if let Some(bounds) = diagnostic_popover.last_bounds.get() { popover_bounds.push(bounds); } } if popover_bounds.is_empty() { return false; } let distance = popover_bounds .iter() .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds)) .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or(px(f32::MAX)); if let Some(closest_distance) = self.closest_mouse_distance { if distance > closest_distance + px(4.0) { return false; } } self.closest_mouse_distance = Some(distance.min(self.closest_mouse_distance.unwrap_or(distance))); true } fn distance_from_point_to_bounds( &self, point: gpui::Point, bounds: Bounds, ) -> Pixels { let center_x = bounds.origin.x + bounds.size.width / 2.; let center_y = bounds.origin.y + bounds.size.height / 2.; let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.) .max(px(0.0)) .into(); let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.) .max(px(0.0)) .into(); px((dx.powi(2) + dy.powi(2)).sqrt()) } pub(crate) fn render( &mut self, snapshot: &EditorSnapshot, visible_rows: Range, max_size: Size, text_layout_details: &TextLayoutDetails, window: &mut Window, cx: &mut Context, ) -> Option<(DisplayPoint, Vec)> { if !self.visible() { return None; } // If there is a diagnostic, position the popovers based on that. // Otherwise use the start of the hover range let anchor = self .diagnostic_popover .as_ref() .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start) .or_else(|| { self.info_popovers.iter().find_map(|info_popover| { match &info_popover.symbol_range { RangeInEditor::Text(range) => Some(&range.start), RangeInEditor::Inlay(_) => None, } }) }) .or_else(|| { self.info_popovers.iter().find_map(|info_popover| { match &info_popover.symbol_range { RangeInEditor::Text(_) => None, RangeInEditor::Inlay(range) => Some(&range.inlay_position), } }) })?; let mut point = anchor.to_display_point(&snapshot.display_snapshot); // Clamp the point within the visible rows in case the popup source spans multiple lines if visible_rows.end <= point.row() { point = crate::movement::up_by_rows( &snapshot.display_snapshot, point, 1 + (point.row() - visible_rows.end).0, text::SelectionGoal::None, true, text_layout_details, ) .0; } else if point.row() < visible_rows.start { point = crate::movement::down_by_rows( &snapshot.display_snapshot, point, (visible_rows.start - point.row()).0, text::SelectionGoal::None, true, text_layout_details, ) .0; } if !visible_rows.contains(&point.row()) { log::error!("Hover popover point out of bounds after moving"); return None; } let mut elements = Vec::new(); if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() { elements.push(diagnostic_popover.render(max_size, window, cx)); } for info_popover in &mut self.info_popovers { elements.push(info_popover.render(max_size, window, cx)); } Some((point, elements)) } pub fn focused(&self, window: &mut Window, cx: &mut Context) -> bool { let mut hover_popover_is_focused = false; for info_popover in &self.info_popovers { if let Some(markdown_view) = &info_popover.parsed_content && markdown_view.focus_handle(cx).is_focused(window) { hover_popover_is_focused = true; } } if let Some(diagnostic_popover) = &self.diagnostic_popover && diagnostic_popover .markdown .focus_handle(cx) .is_focused(window) { hover_popover_is_focused = true; } hover_popover_is_focused } } pub struct InfoPopover { pub symbol_range: RangeInEditor, pub parsed_content: Option>, pub scroll_handle: ScrollHandle, pub keyboard_grace: Rc>, pub anchor: Option, pub last_bounds: Rc>>>, _subscription: Option, } impl InfoPopover { pub(crate) fn render( &mut self, max_size: Size, window: &mut Window, cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); let this2 = this.clone(); let bounds_cell = self.last_bounds.clone(); div() .id("info_popover") .occlude() .elevation_2(cx) .child( canvas( { move |bounds, _window, _cx| { bounds_cell.set(Some(bounds)); } }, |_, _, _, _| {}, ) .absolute() .size_full(), ) // Prevent a mouse down/move on the popover from being propagated to the editor, // because that would dismiss the popover. .on_mouse_move({ move |_, _, cx: &mut App| { this.update(cx, |editor, _| { editor.hover_state.closest_mouse_distance = Some(px(0.0)); editor.hover_state.hiding_delay_task = None; }) .ok(); cx.stop_propagation() } }) .on_mouse_down(MouseButton::Left, move |_, _, cx| { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; cx.stop_propagation(); }) .when_some(self.parsed_content.clone(), |this, markdown| { this.child( div() .id("info-md-container") .overflow_y_scroll() .max_w(max_size.width) .max_h(max_size.height) .track_scroll(&self.scroll_handle) .child( MarkdownElement::new(markdown, hover_markdown_style(window, cx)) .scroll_handle(self.scroll_handle.clone()) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .on_url_click(move |link, window, cx| { open_markdown_url( this2 .read_with(cx, |editor, _| editor.workspace()) .ok() .flatten(), link, window, cx, ) }) .p_2(), ), ) .custom_scrollbars( Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle), window, cx, ) }) .into_any_element() } pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context) { let mut current = self.scroll_handle.offset(); current.y -= amount.pixels( window.line_height(), self.scroll_handle.bounds().size.height - px(16.), ) / 2.0; cx.notify(); self.scroll_handle.set_offset(current); } } pub struct DiagnosticPopover { pub(crate) local_diagnostic: DiagnosticEntry, markdown: Entity, border_color: Hsla, background_color: Hsla, pub keyboard_grace: Rc>, pub anchor: Anchor, pub last_bounds: Rc>>>, _subscription: Subscription, pub scroll_handle: ScrollHandle, } impl DiagnosticPopover { pub fn render( &self, max_size: Size, window: &mut Window, cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); let bounds_cell = self.last_bounds.clone(); div() .id("diagnostic") .occlude() .elevation_2_borderless(cx) .child( canvas( { move |bounds, _window, _cx| { bounds_cell.set(Some(bounds)); } }, |_, _, _, _| {}, ) .absolute() .size_full(), ) // Don't draw the background color if the theme // allows transparent surfaces. .when(theme_is_transparent(cx), |this| { this.bg(gpui::transparent_black()) }) // Prevent a mouse move on the popover from being propagated to the editor, // because that would dismiss the popover. .on_mouse_move({ let this = this.clone(); move |_, _, cx: &mut App| { this.update(cx, |editor, _| { editor.hover_state.closest_mouse_distance = Some(px(0.0)); editor.hover_state.hiding_delay_task = None; }) .ok(); cx.stop_propagation() } }) // Prevent a mouse down on the popover from being propagated to the editor, // because that would move the cursor. .on_mouse_down(MouseButton::Left, move |_, _, cx| { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; cx.stop_propagation(); }) .child( div() .relative() .py_1() .pl_2() .pr_8() .bg(self.background_color) .border_1() .border_color(self.border_color) .rounded_lg() .child( div() .id("diagnostic-content-container") .max_w(max_size.width) .max_h(max_size.height) .overflow_y_scroll() .track_scroll(&self.scroll_handle) .child( MarkdownElement::new( self.markdown.clone(), diagnostics_markdown_style(window, cx), ) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .on_url_click( move |link, window, cx| { if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) { this.update(cx, |this, cx| { renderer.as_ref().open_link(this, link, window, cx); }) .ok(); } }, ), ), ) .child(div().absolute().top_1().right_1().child({ let message = self.local_diagnostic.diagnostic.message.clone(); CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic") })) .custom_scrollbars( Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle), window, cx, ), ) .into_any_element() } } #[cfg(test)] mod tests { use super::*; use crate::{ PointForPosition, actions::ConfirmCompletion, editor_tests::{handle_completion_request, init_test}, inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, test::editor_lsp_test_context::EditorLspTestContext, }; use collections::BTreeSet; use futures::stream::StreamExt; use gpui::App; use indoc::indoc; use markdown::parser::MarkdownEvent; use project::InlayId; use settings::InlayHintSettingsContent; use settings::{DelayMs, SettingsStore}; use std::sync::atomic; use std::sync::atomic::AtomicUsize; use text::Bias; fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 { cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 }) } #[gpui::test] fn test_hover_markdown_soft_breaks_reflow_per_commonmark(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let cx = cx.add_empty_window(); // JSDoc / Rust doc comment / Go doc style: single newlines inside a // paragraph. Per CommonMark §6.7 these are soft breaks and must render // as spaces, producing one reflowing paragraph. See issue #60777. let text = concat!("This is a test\n", "for tooltip\n", "reflow"); let markdown = cx.new(|cx| Markdown::new(text.into(), None, None, cx)); cx.run_until_parked(); let rendered = MarkdownElement::rendered_text(markdown, cx, hover_markdown_style); // No hard line breaks should appear: the two soft breaks collapse to spaces. assert_eq!( rendered.matches('\n').count(), 0, "expected no hard line breaks, got {rendered:?}" ); // The whole paragraph reflows onto a single line. assert_eq!(rendered, "This is a test for tooltip reflow"); } #[gpui::test] fn test_hover_markdown_explicit_hard_breaks_preserved(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let cx = cx.add_empty_window(); // The spec-correct way for an LSP server to request line preservation // is to emit CommonMark hard breaks. PR #54165 also maps `
` tags to // hard breaks. Both must continue to render as forced line breaks under // the default hover style. let text = "first line \nsecond line\\\nthird line"; let markdown = cx.new(|cx| Markdown::new(text.into(), None, None, cx)); cx.run_until_parked(); let rendered = MarkdownElement::rendered_text(markdown, cx, hover_markdown_style); // Two explicit hard breaks (two trailing spaces, and a trailing `\`) // must render as two newline characters. assert_eq!( rendered.matches('\n').count(), 2, "expected two hard line breaks, got {rendered:?}" ); let lines: Vec<&str> = rendered.split('\n').collect(); assert_eq!(lines, ["first line", "second line", "third line"]); } impl InfoPopover { fn get_rendered_text(&self, cx: &gpui::App) -> String { let mut rendered_text = String::new(); if let Some(parsed_content) = self.parsed_content.clone() { let markdown = parsed_content.read(cx); let text = markdown.parsed_markdown().source().to_string(); let data = markdown.parsed_markdown().events(); let slice = data; for (range, event) in slice.iter() { match event { MarkdownEvent::SubstitutedText(parsed) => { rendered_text.push_str(parsed.as_str()) } MarkdownEvent::Text | MarkdownEvent::Code => { rendered_text.push_str(&text[range.clone()]) } _ => {} } } } rendered_text } } #[gpui::test] async fn test_mouse_hover_info_popover_with_autocomplete_popover( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), ":".to_string()]), resolve_provider: Some(true), ..Default::default() }), ..Default::default() }, cx, ) .await; let counter = Arc::new(AtomicUsize::new(0)); // Basic hover delays and then pops without moving the mouse cx.set_state(indoc! {" oneˇ two three fn test() { println!(); } "}); //prompt autocompletion menu cx.simulate_keystroke("."); handle_completion_request( indoc! {" one.|<> two three "}, vec!["first_completion", "second_completion"], true, counter.clone(), &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible .await; assert_eq!(counter.load(atomic::Ordering::Acquire), 1); // 1 completion request let hover_point = cx.display_point(indoc! {" one. two three fn test() { printˇln!(); } "}); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); // After delay, hover should be visible. let symbol_range = cx.lsp_range(indoc! {" one. two three fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; cx.editor(|editor, _window, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!(rendered_text, "some basic docs".to_string()) }); // check that the completion menu is still visible and that there still has only been 1 completion request cx.editor(|editor, _, _| assert!(editor.context_menu_visible())); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); //apply a completion and check it was successfully applied let _apply_additional_edits = cx.update_editor(|editor, window, cx| { editor.context_menu_next(&Default::default(), window, cx); editor .confirm_completion(&ConfirmCompletion::default(), window, cx) .unwrap() }); cx.assert_editor_state(indoc! {" one.second_completionˇ two three fn test() { println!(); } "}); // check that the completion menu is no longer visible and that there still has only been 1 completion request cx.editor(|editor, _, _| assert!(!editor.context_menu_visible())); assert_eq!(counter.load(atomic::Ordering::Acquire), 1); //verify the information popover is still visible and unchanged cx.editor(|editor, _, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!(rendered_text, "some basic docs".to_string()) }); // Mouse moved with no hover response dismisses let hover_point = cx.display_point(indoc! {" one.second_completionˇ two three fn teˇst() { println!(); } "}); let mut request = cx .lsp .set_request_handler::( |_, _| async move { Ok(None) }, ); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); request.next().await; // verify that the information popover is no longer visible cx.editor(|editor, _, _| { assert!(!editor.hover_state.visible()); }); } #[gpui::test] async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Basic hover delays and then pops without moving the mouse cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); // After delay, hover should be visible. let symbol_range = cx.lsp_range(indoc! {" fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; cx.editor(|editor, _, cx| { assert!(editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!(rendered_text, "some basic docs".to_string()) }); // Mouse moved with no hover response dismisses let hover_point = cx.display_point(indoc! {" fn teˇst() { println!(); } "}); let mut request = cx .lsp .set_request_handler::( |_, _| async move { Ok(None) }, ); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); request.next().await; cx.editor(|editor, _, _| { assert!(!editor.hover_state.visible()); }); } #[gpui::test] async fn test_mouse_hover_cancelled_before_delay(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx); hover_at(editor, None, None, window, cx); }); let request_count = Arc::new(AtomicUsize::new(0)); cx.set_request_handler::({ let request_count = request_count.clone(); move |_, _, _| { let request_count = request_count.clone(); async move { request_count.fetch_add(1, atomic::Ordering::Release); Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: None, })) } } }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); cx.background_executor.run_until_parked(); cx.run_until_parked(); assert_eq!(request_count.load(atomic::Ordering::Acquire), 0); cx.editor(|editor, _, _| { assert!(!editor.hover_state.visible()); }); } #[gpui::test] async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Hover with keyboard has no delay cx.set_state(indoc! {" fˇn test() { println!(); } "}); cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx)); let symbol_range = cx.lsp_range(indoc! {" «fn» test() { println!(); } "}); cx.editor(|editor, _window, _cx| { assert!(!editor.hover_state.visible()); assert_eq!( editor.hover_state.info_popovers.len(), 0, "Expected no hovers but got but got: {:?}", editor.hover_state.info_popovers.len() ); }); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some other basic docs".to_string(), }), range: Some(symbol_range), })) }); requests.next().await; cx.dispatch_action(Hover); cx.condition(|editor, _| editor.hover_state.visible()).await; cx.editor(|editor, _, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!(rendered_text, "some other basic docs".to_string()) }); } #[gpui::test] async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Hover with keyboard has no delay cx.set_state(indoc! {" fˇn test() { println!(); } "}); cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx)); let symbol_range = cx.lsp_range(indoc! {" «fn» test() { println!(); } "}); cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Array(vec![ lsp::MarkedString::String("regular text for hover to show".to_string()), lsp::MarkedString::String("".to_string()), lsp::MarkedString::LanguageString(lsp::LanguageString { language: "Rust".to_string(), value: "".to_string(), }), ]), range: Some(symbol_range), })) }) .next() .await; cx.dispatch_action(Hover); cx.condition(|editor, _| editor.hover_state.visible()).await; cx.editor(|editor, _, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!( rendered_text, "regular text for hover to show".to_string(), "No empty string hovers should be shown" ); }); } #[gpui::test] async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Hover with keyboard has no delay cx.set_state(indoc! {" fˇn test() { println!(); } "}); cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx)); let symbol_range = cx.lsp_range(indoc! {" «fn» test() { println!(); } "}); let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n"; let markdown_string = format!("\n```rust\n{code_str}```"); let closure_markdown_string = markdown_string.clone(); cx.set_request_handler::(move |_, _, _| { let future_markdown_string = closure_markdown_string.clone(); async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: future_markdown_string, }), range: Some(symbol_range), })) } }) .next() .await; cx.dispatch_action(Hover); cx.condition(|editor, _| editor.hover_state.visible()).await; cx.editor(|editor, _, cx| { assert_eq!( editor.hover_state.info_popovers.len(), 1, "Expected exactly one hover but got: {:?}", editor.hover_state.info_popovers.len() ); let rendered_text = editor .hover_state .info_popovers .first() .unwrap() .get_rendered_text(cx); assert_eq!( rendered_text, code_str, "Should not have extra line breaks at end of rendered hover" ); }); } #[gpui::test] // https://github.com/zed-industries/zed/issues/15498 async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn fuˇnc(abc def: i32) -> u32 { } "}); cx.lsp .set_request_handler::({ |_, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: indoc!( r#" ### function `errands_data_read` --- → `char *` Function to read a file into a string --- ```cpp static char *errands_data_read() ``` "# ) .to_string(), }), range: None, })) } }); cx.update_editor(|editor, window, cx| hover(editor, &Default::default(), window, cx)); cx.run_until_parked(); cx.update_editor(|editor, _, cx| { let popover = editor.hover_state.info_popovers.first().unwrap(); let content = popover.get_rendered_text(cx); assert!(content.contains("Function to read a file")); }); } #[gpui::test] async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { settings.defaults.inlay_hints = Some(InlayHintSettingsContent { show_value_hints: Some(true), enabled: Some(true), edit_debounce_ms: Some(0), scroll_debounce_ms: Some(0), show_type_hints: Some(true), show_parameter_hints: Some(true), show_other_hints: Some(true), show_background: Some(false), toggle_on_modifiers_press: None, }) }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Right( lsp::InlayHintServerCapabilities::Options(lsp::InlayHintOptions { resolve_provider: Some(true), ..Default::default() }), )), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" struct TestStruct; // ================== struct TestNewType(T); fn main() { let variableˇ = TestNewType(TestStruct); } "}); let hint_start_offset = cx.ranges(indoc! {" struct TestStruct; // ================== struct TestNewType(T); fn main() { let variableˇ = TestNewType(TestStruct); } "})[0] .start; let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); let new_type_target_range = cx.lsp_range(indoc! {" struct TestStruct; // ================== struct «TestNewType»(T); fn main() { let variable = TestNewType(TestStruct); } "}); let struct_target_range = cx.lsp_range(indoc! {" struct «TestStruct»; // ================== struct TestNewType(T); fn main() { let variable = TestNewType(TestStruct); } "}); let uri = cx.buffer_lsp_url.clone(); let new_type_label = "TestNewType"; let struct_label = "TestStruct"; let entire_hint_label = ": TestNewType"; let closure_uri = uri.clone(); cx.lsp .set_request_handler::(move |params, _| { let task_uri = closure_uri.clone(); async move { assert_eq!(params.text_document.uri, task_uri); Ok(Some(vec![lsp::InlayHint { position: hint_position, label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart { value: entire_hint_label.to_string(), ..Default::default() }]), kind: Some(lsp::InlayHintKind::TYPE), text_edits: None, tooltip: None, padding_left: Some(false), padding_right: Some(false), data: None, }])) } }) .next() .await; cx.background_executor.run_until_parked(); cx.update_editor(|editor, _, cx| { let expected_layers = vec![entire_hint_label.to_string()]; assert_eq!(expected_layers, cached_hint_labels(editor, cx)); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); }); let inlay_range = cx .ranges(indoc! {" struct TestStruct; // ================== struct TestNewType(T); fn main() { let variable« »= TestNewType(TestStruct); } "}) .first() .cloned() .unwrap(); let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot); let next_valid = MultiBufferOffset(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( previous_valid.row(), previous_valid.column() + (entire_hint_label.find(new_type_label).unwrap() + new_type_label.len() / 2) as u32, ); PointForPosition { previous_valid, next_valid, nearest_valid: previous_valid, exact_unclipped, column_overshoot_after_line_end: 0, } }); cx.update_editor(|editor, window, cx| { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, None, true, false, window, cx, ); }); let resolve_closure_uri = uri.clone(); cx.lsp .set_request_handler::( move |mut hint_to_resolve, _| { let mut resolved_hint_positions = BTreeSet::new(); let task_uri = resolve_closure_uri.clone(); async move { let inserted = resolved_hint_positions.insert(hint_to_resolve.position); assert!(inserted, "Hint {hint_to_resolve:?} was resolved twice"); // `: TestNewType` hint_to_resolve.label = lsp::InlayHintLabel::LabelParts(vec![ lsp::InlayHintLabelPart { value: ": ".to_string(), ..Default::default() }, lsp::InlayHintLabelPart { value: new_type_label.to_string(), location: Some(lsp::Location { uri: task_uri.clone(), range: new_type_target_range, }), tooltip: Some(lsp::InlayHintLabelPartTooltip::String(format!( "A tooltip for `{new_type_label}`" ))), ..Default::default() }, lsp::InlayHintLabelPart { value: "<".to_string(), ..Default::default() }, lsp::InlayHintLabelPart { value: struct_label.to_string(), location: Some(lsp::Location { uri: task_uri, range: struct_target_range, }), tooltip: Some(lsp::InlayHintLabelPartTooltip::MarkupContent( lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: format!("A tooltip for `{struct_label}`"), }, )), ..Default::default() }, lsp::InlayHintLabelPart { value: ">".to_string(), ..Default::default() }, ]); Ok(hint_to_resolve) } }, ) .next() .await; cx.background_executor.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, None, true, false, window, cx, ); }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); cx.background_executor.run_until_parked(); cx.update_editor(|editor, _, cx| { let hover_state = &editor.hover_state; assert!( hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1 ); let popover = hover_state.info_popovers.first().unwrap(); let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); assert_eq!( popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), inlay_position: buffer_snapshot .anchor_after(MultiBufferOffset(inlay_range.start)), range: ": ".len()..": ".len() + new_type_label.len(), }), "Popover range should match the new type label part" ); assert_eq!( popover.get_rendered_text(cx), format!("A tooltip for {new_type_label}"), ); }); let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot); let next_valid = MultiBufferOffset(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( previous_valid.row(), previous_valid.column() + (entire_hint_label.find(struct_label).unwrap() + struct_label.len() / 2) as u32, ); PointForPosition { previous_valid, next_valid, nearest_valid: previous_valid, exact_unclipped, column_overshoot_after_line_end: 0, } }); cx.update_editor(|editor, window, cx| { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, None, true, false, window, cx, ); }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); cx.background_executor.run_until_parked(); cx.update_editor(|editor, _, cx| { let hover_state = &editor.hover_state; assert!( hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1 ); let popover = hover_state.info_popovers.first().unwrap(); let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); assert_eq!( popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), inlay_position: buffer_snapshot .anchor_after(MultiBufferOffset(inlay_range.start)), range: ": ".len() + new_type_label.len() + "<".len() ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(), }), "Popover range should match the struct label part" ); assert_eq!( popover.get_rendered_text(cx), format!("A tooltip for {struct_label}"), "Rendered markdown element should remove backticks from text" ); }); } #[test] fn test_find_hovered_hint_part_with_multibyte_characters() { use crate::display_map::InlayOffset; use multi_buffer::MultiBufferOffset; use project::InlayHintLabelPart; // Test with multi-byte UTF-8 character "→" (3 bytes, 1 character) let label = "→ app/Livewire/UserProfile.php"; let label_parts = vec![InlayHintLabelPart { value: label.to_string(), tooltip: None, location: None, }]; let hint_start = InlayOffset(MultiBufferOffset(100)); // Verify the label has more bytes than characters (due to "→") assert_eq!(label.len(), 32); // bytes assert_eq!(label.chars().count(), 30); // characters // Test hovering at the last byte (should find the part) let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1)); let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset); assert!( result.is_some(), "Should find part when hovering at last byte" ); let (part, range) = result.unwrap(); assert_eq!(part.value, label); assert_eq!(range.start, hint_start); assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len()))); // Test hovering at the first byte of "→" (byte 0) let first_byte_offset = InlayOffset(MultiBufferOffset(100)); let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset); assert!( result.is_some(), "Should find part when hovering at first byte" ); // Test hovering in the middle of "→" (byte 1, still part of the arrow character) let mid_arrow_offset = InlayOffset(MultiBufferOffset(101)); let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset); assert!( result.is_some(), "Should find part when hovering in middle of multi-byte char" ); // Test with multiple parts containing multi-byte characters // Part ranges are [start, end) - start inclusive, end exclusive // "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8) let parts = vec![ InlayHintLabelPart { value: "→ ".to_string(), // 4 bytes (3 + 1) tooltip: None, location: None, }, InlayHintLabelPart { value: "path".to_string(), // 4 bytes tooltip: None, location: None, }, ]; // Hover at byte 3 (last byte of "→ ", the space character) let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3)); let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte); assert!(result.is_some(), "Should find first part at its last byte"); let (part, range) = result.unwrap(); assert_eq!(part.value, "→ "); assert_eq!( range, InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104)) ); // Hover at byte 4 (first byte of "path", at the boundary) let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4)); let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset); assert!(result.is_some(), "Should find second part at boundary"); let (part, _) = result.unwrap(); assert_eq!(part.value, "path"); // Hover at byte 7 (last byte of "path") let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7)); let result = find_hovered_hint_part(parts, hint_start, path_end_offset); assert!(result.is_some(), "Should find second part at last byte"); let (part, range) = result.unwrap(); assert_eq!(part.value, "path"); assert_eq!( range, InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108)) ); } #[gpui::test] async fn test_hover_popover_hiding_delay(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let custom_delay_ms = 500u64; cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.hover_popover_sticky = Some(true); settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); // Trigger hover on a symbol let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); let symbol_range = cx.lsp_range(indoc! {" fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; // Hover should be visible cx.editor(|editor, _, _| { assert!(editor.hover_state.visible()); }); // Move mouse away (hover_at with None anchor triggers the hiding delay) cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx)); // Popover should still be visible before the custom hiding delay expires cx.background_executor .advance_clock(Duration::from_millis(custom_delay_ms - 100)); cx.editor(|editor, _, _| { assert!( editor.hover_state.visible(), "Popover should remain visible before the hiding delay expires" ); }); // After the full custom delay, the popover should be hidden cx.background_executor .advance_clock(Duration::from_millis(200)); cx.editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Popover should be hidden after the hiding delay expires" ); }); } #[gpui::test] async fn test_hover_popover_sticky_disabled(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.hover_popover_sticky = Some(false); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); // Trigger hover on a symbol let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); let symbol_range = cx.lsp_range(indoc! {" fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; // Hover should be visible cx.editor(|editor, _, _| { assert!(editor.hover_state.visible()); }); // Move mouse away — with sticky disabled, hide immediately cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx)); // Popover should be hidden immediately without any delay cx.editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Popover should be hidden immediately when sticky is disabled" ); }); } #[gpui::test] async fn test_hover_popover_hiding_delay_restarts_when_mouse_gets_closer( cx: &mut gpui::TestAppContext, ) { init_test(cx, |_| {}); let custom_delay_ms = 600u64; cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.hover_popover_sticky = Some(true); settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); let symbol_range = cx.lsp_range(indoc! {" fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; cx.editor(|editor, _, _| { assert!(editor.hover_state.visible()); }); cx.update_editor(|editor, _, _| { let popover = editor.hover_state.info_popovers.first().unwrap(); popover.last_bounds.set(Some(Bounds { origin: gpui::Point { x: px(100.0), y: px(100.0), }, size: Size { width: px(100.0), height: px(60.0), }, })); }); let far_point = gpui::Point { x: px(260.0), y: px(130.0), }; cx.update_editor(|editor, window, cx| hover_at(editor, None, Some(far_point), window, cx)); cx.background_executor .advance_clock(Duration::from_millis(400)); cx.background_executor.run_until_parked(); let closer_point = gpui::Point { x: px(220.0), y: px(130.0), }; cx.update_editor(|editor, window, cx| { hover_at(editor, None, Some(closer_point), window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(250)); cx.background_executor.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.hover_state.visible(), "Popover should remain visible because moving closer restarts the hiding timer" ); }); cx.background_executor .advance_clock(Duration::from_millis(350)); cx.background_executor.run_until_parked(); cx.editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Popover should hide after the restarted hiding timer expires" ); }); } #[gpui::test] async fn test_hover_popover_cancel_hide_on_rehover(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let custom_delay_ms = 500u64; cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.hover_popover_sticky = Some(true); settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); let symbol_range = cx.lsp_range(indoc! {" fn test() { «println!»(); } "}); let mut requests = cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some basic docs".to_string(), }), range: Some(symbol_range), })) }); cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); requests.next().await; cx.editor(|editor, _, _| { assert!(editor.hover_state.visible()); }); // Move mouse away — starts the 500ms hide timer cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx)); cx.background_executor .advance_clock(Duration::from_millis(300)); cx.background_executor.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.hover_state.visible(), "Popover should still be visible before hiding delay expires" ); }); // Move back to the symbol — should cancel the hiding timer cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); // Advance past the original deadline — popover should still be visible // because re-hovering cleared the hiding_delay_task cx.background_executor .advance_clock(Duration::from_millis(300)); cx.background_executor.run_until_parked(); cx.editor(|editor, _, _| { assert!( editor.hover_state.visible(), "Popover should remain visible after re-hovering the symbol" ); assert!( editor.hover_state.hiding_delay_task.is_none(), "Hiding delay task should have been cleared by re-hover" ); }); // Move away again — starts a fresh 500ms timer cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx)); cx.background_executor .advance_clock(Duration::from_millis(custom_delay_ms + 100)); cx.background_executor.run_until_parked(); cx.editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Popover should hide after the new hiding timer expires" ); }); } #[gpui::test] async fn test_hover_popover_enabled_false_ignores_sticky(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { settings.update_user_settings(cx, |settings| { settings.editor.hover_popover_enabled = Some(false); settings.editor.hover_popover_sticky = Some(true); settings.editor.hover_popover_hiding_delay = Some(DelayMs(500)); }); }); }); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; cx.set_state(indoc! {" fn ˇtest() { println!(); } "}); let hover_point = cx.display_point(indoc! {" fn test() { printˇln!(); } "}); // Trigger hover_at — should be gated by hover_popover_enabled=false cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), None, window, cx) }); // No need to advance clock or wait for LSP — the gate should prevent any work cx.editor(|editor, _, _| { assert!( !editor.hover_state.visible(), "Popover should not appear when hover_popover_enabled is false" ); assert!( editor.hover_state.info_task.is_none(), "No hover info task should be scheduled when hover is disabled" ); }); } #[test] fn test_parse_file_links() { assert_eq!( parse_file_link("file:///path/to/file"), Some((PathBuf::from("/path/to/file"), None)) ); assert_eq!( parse_file_link("file:///path/to/file%20with%20spaces"), Some((PathBuf::from("/path/to/file with spaces"), None)) ); assert_eq!( parse_file_link("file:///path/to/file#123"), Some((PathBuf::from("/path/to/file"), Some("123".to_string()))) ); assert_eq!(parse_file_link("http://example.com/"), None,); } }