Skip to repository content2782 lines · 104.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:33:40.210Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
hover_popover.rs
1use crate::{
2 Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
3 GlobalDiagnosticRenderer, HighlightKey, Hover,
4 display_map::{InlayOffset, ToDisplayPoint, is_invisible},
5 editor_settings::EditorSettingsScrollbarProxy,
6 hover_links::{InlayHighlight, RangeInEditor},
7 movement::TextLayoutDetails,
8 scroll::ScrollAmount,
9};
10use anyhow::Context as _;
11use gpui::{
12 AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla,
13 InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
14 StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TaskExt,
15 TextStyleRefinement, Window, canvas, div, px,
16};
17use itertools::Itertools;
18use language::{DiagnosticEntry, Language, LanguageRegistry};
19use lsp::DiagnosticSeverity;
20use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
21use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
22use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
23use settings::Settings;
24use std::{
25 borrow::Cow,
26 cell::{Cell, RefCell},
27};
28use std::{ops::Range, sync::Arc, time::Duration};
29use std::{path::PathBuf, rc::Rc};
30use theme_settings::ThemeSettings;
31use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
32use url::Url;
33use util::TryFutureExt;
34use workspace::{OpenOptions, OpenVisible, Workspace};
35
36pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
37pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.;
38pub const POPOVER_RIGHT_OFFSET: Pixels = px(8.0);
39pub const HOVER_POPOVER_GAP: Pixels = px(10.);
40
41/// Bindable action which uses the most recent selection head to trigger a hover
42pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Context<Editor>) {
43 let head = editor.selections.newest_anchor().head();
44 show_hover(editor, head, true, window, cx);
45}
46
47/// The internal hover action dispatches between `show_hover` or `hide_hover`
48/// depending on whether a point to hover over is provided.
49pub fn hover_at(
50 editor: &mut Editor,
51 anchor: Option<Anchor>,
52 mouse_position: Option<gpui::Point<Pixels>>,
53 window: &mut Window,
54 cx: &mut Context<Editor>,
55) {
56 if EditorSettings::get_global(cx).hover_popover_enabled {
57 if show_keyboard_hover(editor, window, cx) {
58 return;
59 }
60
61 if let Some(anchor) = anchor {
62 editor.hover_state.hiding_delay_task = None;
63 editor.hover_state.closest_mouse_distance = None;
64 show_hover(editor, anchor, false, window, cx);
65 } else if !editor.hover_state.visible() {
66 editor.hover_state.info_task = None;
67 } else {
68 let settings = EditorSettings::get_global(cx);
69 if !settings.hover_popover_sticky {
70 hide_hover(editor, cx);
71 return;
72 }
73
74 let mut getting_closer = false;
75 if let Some(mouse_position) = mouse_position {
76 getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position);
77 }
78
79 // If we are moving away and a timer is already running, just let it count down.
80 if !getting_closer && editor.hover_state.hiding_delay_task.is_some() {
81 return;
82 }
83
84 // If we are moving closer, or if no timer is running at all, start/restart the timer.
85 let delay = Duration::from_millis(settings.hover_popover_hiding_delay.0);
86 let task = cx.spawn(async move |this, cx| {
87 cx.background_executor().timer(delay).await;
88 this.update(cx, |editor, cx| {
89 hide_hover(editor, cx);
90 })
91 .ok();
92 });
93 editor.hover_state.hiding_delay_task = Some(task);
94 }
95 }
96}
97
98pub fn show_keyboard_hover(
99 editor: &mut Editor,
100 window: &mut Window,
101 cx: &mut Context<Editor>,
102) -> bool {
103 if let Some(anchor) = editor.hover_state.info_popovers.iter().find_map(|p| {
104 if *p.keyboard_grace.borrow() {
105 p.anchor
106 } else {
107 None
108 }
109 }) {
110 show_hover(editor, anchor, false, window, cx);
111 return true;
112 }
113
114 if let Some(anchor) = editor
115 .hover_state
116 .diagnostic_popover
117 .as_ref()
118 .and_then(|d| {
119 if *d.keyboard_grace.borrow() {
120 Some(d.anchor)
121 } else {
122 None
123 }
124 })
125 {
126 show_hover(editor, anchor, false, window, cx);
127 return true;
128 }
129
130 false
131}
132
133pub struct InlayHover {
134 pub(crate) range: InlayHighlight,
135 pub tooltip: HoverBlock,
136}
137
138pub fn find_hovered_hint_part(
139 label_parts: Vec<InlayHintLabelPart>,
140 hint_start: InlayOffset,
141 hovered_offset: InlayOffset,
142) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
143 if hovered_offset >= hint_start {
144 let mut offset_in_hint = hovered_offset - hint_start;
145 let mut part_start = hint_start;
146 for part in label_parts {
147 let part_len = part.value.len();
148 if offset_in_hint >= part_len {
149 offset_in_hint -= part_len;
150 part_start.0 += part_len;
151 } else {
152 let part_end = InlayOffset(part_start.0 + part_len);
153 return Some((part, part_start..part_end));
154 }
155 }
156 }
157 None
158}
159
160pub fn hover_at_inlay(
161 editor: &mut Editor,
162 inlay_hover: InlayHover,
163 window: &mut Window,
164 cx: &mut Context<Editor>,
165) {
166 if EditorSettings::get_global(cx).hover_popover_enabled {
167 if editor.pending_rename.is_some() {
168 return;
169 }
170
171 let Some(project) = editor.project.clone() else {
172 return;
173 };
174
175 if editor
176 .hover_state
177 .info_popovers
178 .iter()
179 .any(|InfoPopover { symbol_range, .. }| {
180 if let RangeInEditor::Inlay(range) = symbol_range
181 && range == &inlay_hover.range
182 {
183 // Hover triggered from same location as last time. Don't show again.
184 return true;
185 }
186 false
187 })
188 {
189 return;
190 }
191
192 let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
193
194 editor.hover_state.hiding_delay_task = None;
195 editor.hover_state.closest_mouse_distance = None;
196
197 let task = cx.spawn_in(window, async move |this, cx| {
198 async move {
199 cx.background_executor()
200 .timer(Duration::from_millis(hover_popover_delay))
201 .await;
202 this.update(cx, |this, _| {
203 this.hover_state.diagnostic_popover = None;
204 })?;
205
206 let language_registry = project.read_with(cx, |p, _| p.languages().clone());
207 let blocks = vec![inlay_hover.tooltip];
208 let parsed_content = parse_blocks(&blocks, Some(&language_registry), None, cx);
209
210 let scroll_handle = ScrollHandle::new();
211
212 let subscription = this
213 .update(cx, |_, cx| {
214 parsed_content.as_ref().map(|parsed_content| {
215 cx.observe(parsed_content, |_, _, cx| cx.notify())
216 })
217 })
218 .ok()
219 .flatten();
220
221 let hover_popover = InfoPopover {
222 symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
223 parsed_content,
224 scroll_handle,
225 keyboard_grace: Rc::new(RefCell::new(false)),
226 anchor: None,
227 last_bounds: Rc::new(Cell::new(None)),
228 _subscription: subscription,
229 };
230
231 this.update(cx, |this, cx| {
232 // TODO: no background highlights happen for inlays currently
233 this.hover_state.info_popovers = vec![hover_popover];
234 cx.notify();
235 })?;
236
237 anyhow::Ok(())
238 }
239 .log_err()
240 .await
241 });
242
243 editor.hover_state.info_task = Some(task);
244 }
245}
246
247/// Hides the type information popup.
248/// Triggered by the `Hover` action when the cursor is not over a symbol or when the
249/// selections changed.
250pub fn hide_hover(editor: &mut Editor, cx: &mut Context<Editor>) -> bool {
251 let info_popovers = editor.hover_state.info_popovers.drain(..);
252 let diagnostics_popover = editor.hover_state.diagnostic_popover.take();
253 let did_hide = info_popovers.count() > 0 || diagnostics_popover.is_some();
254
255 editor.hover_state.info_task = None;
256 editor.hover_state.hiding_delay_task = None;
257 editor.hover_state.closest_mouse_distance = None;
258
259 editor.clear_background_highlights(HighlightKey::HoverState, cx);
260
261 if did_hide {
262 cx.notify();
263 }
264
265 did_hide
266}
267
268/// Queries the LSP and shows type info and documentation
269/// about the symbol the mouse is currently hovering over.
270/// Triggered by the `Hover` action when the cursor may be over a symbol.
271fn show_hover(
272 editor: &mut Editor,
273 anchor: Anchor,
274 ignore_timeout: bool,
275 window: &mut Window,
276 cx: &mut Context<Editor>,
277) -> Option<()> {
278 if editor.pending_rename.is_some() {
279 return None;
280 }
281
282 let snapshot = editor.snapshot(window, cx);
283
284 let (buffer_position, _) = editor
285 .buffer
286 .read(cx)
287 .snapshot(cx)
288 .anchor_to_buffer_anchor(anchor)?;
289 let buffer = editor.buffer.read(cx).buffer(buffer_position.buffer_id)?;
290
291 let language_registry = editor
292 .project()
293 .map(|project| project.read(cx).languages().clone());
294 let provider = editor.semantics_provider.clone()?;
295
296 editor.hover_state.hiding_delay_task = None;
297 editor.hover_state.closest_mouse_distance = None;
298
299 if !ignore_timeout {
300 if same_info_hover(editor, &snapshot, anchor)
301 || same_diagnostic_hover(editor, &snapshot, anchor)
302 || editor.hover_state.diagnostic_popover.is_some()
303 {
304 // Hover triggered from same location as last time. Don't show again.
305 return None;
306 } else {
307 hide_hover(editor, cx);
308 }
309 }
310
311 let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
312 let all_diagnostics_active = editor.all_diagnostics_active();
313 let active_group_id = editor.active_diagnostic_group_id();
314
315 let renderer = GlobalDiagnosticRenderer::global(cx);
316 let task = cx.spawn_in(window, async move |this, cx| {
317 async move {
318 // If we need to delay, delay a set amount initially before making the lsp request
319 let delay = if ignore_timeout {
320 None
321 } else {
322 let lsp_request_early = hover_popover_delay / 2;
323 cx.background_executor()
324 .timer(Duration::from_millis(
325 hover_popover_delay - lsp_request_early,
326 ))
327 .await;
328
329 // Construct delay task to wait for later
330 let total_delay = Some(
331 cx.background_executor()
332 .timer(Duration::from_millis(lsp_request_early)),
333 );
334 total_delay
335 };
336
337 let hover_request = cx.update(|_, cx| provider.hover(&buffer, buffer_position, cx))?;
338
339 if let Some(delay) = delay {
340 delay.await;
341 }
342 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
343 let local_diagnostic = if all_diagnostics_active {
344 None
345 } else {
346 snapshot
347 .buffer_snapshot()
348 .diagnostics_with_buffer_ids_in_range::<MultiBufferOffset>(offset..offset)
349 .filter(|(_, diagnostic)| {
350 Some(diagnostic.diagnostic.group_id) != active_group_id
351 })
352 // Find the entry with the most specific range
353 .min_by_key(|(_, entry)| entry.range.end - entry.range.start)
354 };
355
356 let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic {
357 let group = snapshot
358 .buffer_snapshot()
359 .diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id)
360 .collect::<Vec<_>>();
361 let point_range = local_diagnostic
362 .range
363 .start
364 .to_point(&snapshot.buffer_snapshot())
365 ..local_diagnostic
366 .range
367 .end
368 .to_point(&snapshot.buffer_snapshot());
369 let markdown = cx.update(|_, cx| {
370 renderer
371 .as_ref()
372 .and_then(|renderer| {
373 renderer.render_hover(
374 group,
375 point_range,
376 buffer_id,
377 language_registry.clone(),
378 cx,
379 )
380 })
381 .context("no rendered diagnostic")
382 })??;
383
384 let (background_color, border_color) = cx.update(|_, cx| {
385 let status_colors = cx.theme().status();
386 match local_diagnostic.diagnostic.severity {
387 DiagnosticSeverity::ERROR => {
388 (status_colors.error_background, status_colors.error_border)
389 }
390 DiagnosticSeverity::WARNING => (
391 status_colors.warning_background,
392 status_colors.warning_border,
393 ),
394 DiagnosticSeverity::INFORMATION => {
395 (status_colors.info_background, status_colors.info_border)
396 }
397 DiagnosticSeverity::HINT => {
398 (status_colors.hint_background, status_colors.hint_border)
399 }
400 _ => (
401 status_colors.ignored_background,
402 status_colors.ignored_border,
403 ),
404 }
405 })?;
406
407 let subscription =
408 this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?;
409
410 let local_diagnostic = DiagnosticEntry {
411 diagnostic: local_diagnostic.diagnostic.to_owned(),
412 range: snapshot
413 .buffer_snapshot()
414 .anchor_before(local_diagnostic.range.start)
415 ..snapshot
416 .buffer_snapshot()
417 .anchor_after(local_diagnostic.range.end),
418 };
419
420 let scroll_handle = ScrollHandle::new();
421
422 Some(DiagnosticPopover {
423 local_diagnostic,
424 markdown,
425 border_color,
426 scroll_handle,
427 background_color,
428 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
429 anchor,
430 last_bounds: Rc::new(Cell::new(None)),
431 _subscription: subscription,
432 })
433 } else {
434 None
435 };
436
437 this.update(cx, |this, _| {
438 this.hover_state.diagnostic_popover = diagnostic_popover;
439 })?;
440
441 let invisible_char = if let Some(invisible) = snapshot
442 .buffer_snapshot()
443 .chars_at(anchor)
444 .next()
445 .filter(|&c| is_invisible(c))
446 {
447 let after = snapshot.buffer_snapshot().anchor_after(
448 anchor.to_offset(&snapshot.buffer_snapshot()) + invisible.len_utf8(),
449 );
450 Some((invisible, anchor..after))
451 } else if let Some(invisible) = snapshot
452 .buffer_snapshot()
453 .reversed_chars_at(anchor)
454 .next()
455 .filter(|&c| is_invisible(c))
456 {
457 let before = snapshot.buffer_snapshot().anchor_before(
458 anchor.to_offset(&snapshot.buffer_snapshot()) - invisible.len_utf8(),
459 );
460
461 Some((invisible, before..anchor))
462 } else {
463 None
464 };
465
466 let hovers_response = if let Some(hover_request) = hover_request {
467 hover_request.await.unwrap_or_default()
468 } else {
469 Vec::new()
470 };
471 let snapshot = this.update_in(cx, |this, window, cx| this.snapshot(window, cx))?;
472 let mut hover_highlights = Vec::with_capacity(hovers_response.len());
473 let mut info_popovers = Vec::with_capacity(
474 hovers_response.len() + if invisible_char.is_some() { 1 } else { 0 },
475 );
476
477 if let Some((invisible, range)) = invisible_char {
478 let blocks = vec![HoverBlock {
479 text: format!("Unicode character U+{:02X}", invisible as u32),
480 kind: HoverBlockKind::PlainText,
481 }];
482 let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
483 let scroll_handle = ScrollHandle::new();
484 let subscription = this
485 .update(cx, |_, cx| {
486 parsed_content.as_ref().map(|parsed_content| {
487 cx.observe(parsed_content, |_, _, cx| cx.notify())
488 })
489 })
490 .ok()
491 .flatten();
492 info_popovers.push(InfoPopover {
493 symbol_range: RangeInEditor::Text(range),
494 parsed_content,
495 scroll_handle,
496 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
497 anchor: Some(anchor),
498 last_bounds: Rc::new(Cell::new(None)),
499 _subscription: subscription,
500 })
501 }
502
503 let doc_link_task = this
504 .update(cx, |editor, cx| {
505 editor.document_links_at(buffer.clone(), buffer_position, cx)
506 })
507 .ok()
508 .flatten();
509 let doc_link_tooltips = match doc_link_task {
510 Some(task) => task
511 .await
512 .into_iter()
513 .filter_map(|(_, link)| {
514 let multi_buffer_range = snapshot
515 .buffer_snapshot()
516 .buffer_anchor_range_to_anchor_range(link.range.clone())?;
517 let tooltip = link.tooltip?;
518 Some((multi_buffer_range, tooltip))
519 })
520 .collect::<Vec<_>>(),
521 None => Vec::new(),
522 };
523
524 for hover_result in hovers_response {
525 // Create symbol range of anchors for highlighting and filtering of future requests.
526 let range = hover_result
527 .range
528 .and_then(|range| {
529 let range = snapshot
530 .buffer_snapshot()
531 .buffer_anchor_range_to_anchor_range(range)?;
532 Some(range)
533 })
534 .or_else(|| {
535 let snapshot = &snapshot.buffer_snapshot();
536 let range = snapshot.syntax_ancestor(anchor..anchor)?.1;
537 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
538 })
539 .unwrap_or_else(|| anchor..anchor);
540
541 let blocks = hover_result.contents;
542 let language = hover_result.language;
543 let parsed_content =
544 parse_blocks(&blocks, language_registry.as_ref(), language, cx);
545 let scroll_handle = ScrollHandle::new();
546 hover_highlights.push(range.clone());
547 let subscription = this
548 .update(cx, |_, cx| {
549 parsed_content.as_ref().map(|parsed_content| {
550 cx.observe(parsed_content, |_, _, cx| cx.notify())
551 })
552 })
553 .ok()
554 .flatten();
555 info_popovers.push(InfoPopover {
556 symbol_range: RangeInEditor::Text(range),
557 parsed_content,
558 scroll_handle,
559 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
560 anchor: Some(anchor),
561 last_bounds: Rc::new(Cell::new(None)),
562 _subscription: subscription,
563 });
564 }
565
566 for (multi_buffer_range, tooltip) in doc_link_tooltips {
567 let blocks = vec![HoverBlock {
568 text: tooltip.to_string(),
569 kind: HoverBlockKind::Markdown,
570 }];
571 let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
572 let scroll_handle = ScrollHandle::new();
573 let subscription = this
574 .update(cx, |_, cx| {
575 parsed_content.as_ref().map(|parsed_content| {
576 cx.observe(parsed_content, |_, _, cx| cx.notify())
577 })
578 })
579 .ok()
580 .flatten();
581 info_popovers.push(InfoPopover {
582 symbol_range: RangeInEditor::Text(multi_buffer_range),
583 parsed_content,
584 scroll_handle,
585 keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
586 anchor: Some(anchor),
587 last_bounds: Rc::new(Cell::new(None)),
588 _subscription: subscription,
589 });
590 }
591
592 this.update_in(cx, |editor, window, cx| {
593 if hover_highlights.is_empty() {
594 editor.clear_background_highlights(HighlightKey::HoverState, cx);
595 } else {
596 // Highlight the selected symbol using a background highlight
597 editor.highlight_background(
598 HighlightKey::HoverState,
599 &hover_highlights,
600 |_, theme| theme.colors().element_hover, // todo update theme
601 cx,
602 );
603 }
604
605 editor.hover_state.info_popovers = info_popovers;
606 cx.notify();
607 window.refresh();
608 })?;
609
610 anyhow::Ok(())
611 }
612 .log_err()
613 .await
614 });
615
616 editor.hover_state.info_task = Some(task);
617 None
618}
619
620fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
621 editor
622 .hover_state
623 .info_popovers
624 .iter()
625 .any(|InfoPopover { symbol_range, .. }| {
626 symbol_range
627 .as_text_range()
628 .map(|range| {
629 let hover_range = range.to_offset(&snapshot.buffer_snapshot());
630 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
631 // LSP returns a hover result for the end index of ranges that should be hovered, so we need to
632 // use an inclusive range here to check if we should dismiss the popover
633 (hover_range.start..=hover_range.end).contains(&offset)
634 })
635 .unwrap_or(false)
636 })
637}
638
639fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) -> bool {
640 editor
641 .hover_state
642 .diagnostic_popover
643 .as_ref()
644 .map(|diagnostic| {
645 let hover_range = diagnostic
646 .local_diagnostic
647 .range
648 .to_offset(&snapshot.buffer_snapshot());
649 let offset = anchor.to_offset(&snapshot.buffer_snapshot());
650
651 // Here we do basically the same as in `same_info_hover`, see comment there for an explanation
652 (hover_range.start..=hover_range.end).contains(&offset)
653 })
654 .unwrap_or(false)
655}
656
657fn parse_blocks(
658 blocks: &[HoverBlock],
659 language_registry: Option<&Arc<LanguageRegistry>>,
660 language: Option<Arc<Language>>,
661 cx: &mut AsyncWindowContext,
662) -> Option<Entity<Markdown>> {
663 let combined_text = blocks
664 .iter()
665 .map(|block| match &block.kind {
666 project::HoverBlockKind::PlainText | project::HoverBlockKind::Markdown => {
667 Cow::Borrowed(block.text.trim())
668 }
669 project::HoverBlockKind::Code { language } => {
670 Cow::Owned(format!("```{}\n{}\n```", language, block.text.trim()))
671 }
672 })
673 .join("\n\n");
674
675 cx.new_window_entity(|_window, cx| {
676 Markdown::new(
677 combined_text.into(),
678 language_registry.cloned(),
679 language.map(|language| language.name()),
680 cx,
681 )
682 })
683 .ok()
684}
685
686pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
687 let settings = ThemeSettings::get_global(cx);
688 let ui_font_family = settings.ui_font.family.clone();
689 let ui_font_features = settings.ui_font.features.clone();
690 let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
691 let buffer_font_family = settings.buffer_font.family.clone();
692 let buffer_font_features = settings.buffer_font.features.clone();
693 let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
694 let buffer_font_weight = settings.buffer_font.weight;
695
696 let mut base_text_style = window.text_style();
697 base_text_style.refine(&TextStyleRefinement {
698 font_family: Some(ui_font_family),
699 font_features: Some(ui_font_features),
700 font_fallbacks: ui_font_fallbacks,
701 color: Some(cx.theme().colors().editor_foreground),
702 ..Default::default()
703 });
704 MarkdownStyle {
705 base_text_style,
706 code_block: StyleRefinement::default()
707 .my(rems(1.))
708 .font_buffer(cx)
709 .font_features(buffer_font_features.clone())
710 .font_weight(buffer_font_weight),
711 inline_code: TextStyleRefinement {
712 background_color: Some(cx.theme().colors().background),
713 font_family: Some(buffer_font_family),
714 font_features: Some(buffer_font_features),
715 font_fallbacks: buffer_font_fallbacks,
716 font_weight: Some(buffer_font_weight),
717 ..Default::default()
718 },
719 rule_color: cx.theme().colors().border,
720 block_quote_border_color: Color::Muted.color(cx),
721 block_quote: TextStyleRefinement {
722 color: Some(Color::Muted.color(cx)),
723 ..Default::default()
724 },
725 link: TextStyleRefinement {
726 color: Some(cx.theme().colors().editor_foreground),
727 underline: Some(gpui::UnderlineStyle {
728 thickness: px(1.),
729 color: Some(cx.theme().colors().editor_foreground),
730 wavy: false,
731 }),
732 ..Default::default()
733 },
734 syntax: cx.theme().syntax().clone(),
735 selection_background_color: cx.theme().colors().element_selection_background,
736 heading: StyleRefinement::default()
737 .font_weight(FontWeight::BOLD)
738 .text_base()
739 .mt(rems(1.))
740 .mb_0(),
741 table_columns_min_size: true,
742 soft_break_as_hard_break: true,
743 ..Default::default()
744 }
745}
746
747pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
748 let settings = ThemeSettings::get_global(cx);
749 let ui_font_family = settings.ui_font.family.clone();
750 let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
751 let ui_font_features = settings.ui_font.features.clone();
752 let buffer_font_family = settings.buffer_font.family.clone();
753 let buffer_font_features = settings.buffer_font.features.clone();
754 let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
755
756 let mut base_text_style = window.text_style();
757 base_text_style.refine(&TextStyleRefinement {
758 font_family: Some(ui_font_family),
759 font_features: Some(ui_font_features),
760 font_fallbacks: ui_font_fallbacks,
761 color: Some(cx.theme().colors().editor_foreground),
762 ..Default::default()
763 });
764 MarkdownStyle {
765 base_text_style,
766 code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx),
767 inline_code: TextStyleRefinement {
768 background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
769 font_family: Some(buffer_font_family),
770 font_features: Some(buffer_font_features),
771 font_fallbacks: buffer_font_fallbacks,
772 ..Default::default()
773 },
774 rule_color: cx.theme().colors().border,
775 block_quote_border_color: Color::Muted.color(cx),
776 block_quote: TextStyleRefinement {
777 color: Some(Color::Muted.color(cx)),
778 ..Default::default()
779 },
780 link: TextStyleRefinement {
781 color: Some(cx.theme().colors().editor_foreground),
782 underline: Some(gpui::UnderlineStyle {
783 thickness: px(1.),
784 color: Some(cx.theme().colors().editor_foreground),
785 wavy: false,
786 }),
787 ..Default::default()
788 },
789 syntax: cx.theme().syntax().clone(),
790 selection_background_color: cx.theme().colors().element_selection_background,
791 height_is_multiple_of_line_height: true,
792 heading: StyleRefinement::default()
793 .font_weight(FontWeight::BOLD)
794 .text_base()
795 .mb_0(),
796 table_columns_min_size: true,
797 ..Default::default()
798 }
799}
800
801fn parse_file_link(link: &str) -> Option<(PathBuf, Option<String>)> {
802 let uri = Url::parse(link).ok().filter(|uri| uri.scheme() == "file")?;
803 let fragment = uri.fragment().map(ToOwned::to_owned);
804 let path = uri.to_file_path().unwrap_or_else(|_| {
805 let encoded = uri.path();
806
807 urlencoding::decode(encoded)
808 .map(Cow::into_owned)
809 .map(PathBuf::from)
810 .unwrap_or_else(|_| PathBuf::from(encoded))
811 });
812
813 Some((path, fragment))
814}
815
816pub fn open_markdown_url(
817 workspace: Option<Entity<Workspace>>,
818 link: SharedString,
819 window: &mut Window,
820 cx: &mut App,
821) {
822 if let Some((path, fragment)) = parse_file_link(&link)
823 && let Some(workspace) = workspace
824 {
825 workspace.update(cx, |workspace, cx| {
826 let task = workspace.open_abs_path(
827 path,
828 OpenOptions {
829 visible: Some(OpenVisible::None),
830 ..Default::default()
831 },
832 window,
833 cx,
834 );
835
836 cx.spawn_in(window, async move |_, cx| {
837 let item = task.await?;
838 // Ruby LSP uses URLs with #L1,1-4,4
839 // we'll just take the first number and assume it's a line number
840 let Some(fragment) = fragment else {
841 return anyhow::Ok(());
842 };
843 let mut accum = 0u32;
844 for c in fragment.chars() {
845 if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
846 accum *= 10;
847 accum += c as u32 - '0' as u32;
848 } else if accum > 0 {
849 break;
850 }
851 }
852 if accum == 0 {
853 return Ok(());
854 }
855 let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
856 return Ok(());
857 };
858 editor.update_in(cx, |editor, window, cx| {
859 editor.change_selections(Default::default(), window, cx, |selections| {
860 selections.select_ranges([
861 text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0)
862 ]);
863 });
864 })
865 })
866 .detach_and_log_err(cx);
867 });
868 return;
869 }
870
871 if let Some(workspace) = workspace {
872 workspace.update(cx, |workspace, cx| {
873 workspace.open_url_or_file(&link, None, window, cx);
874 });
875 } else {
876 cx.open_url(&link);
877 }
878}
879
880#[derive(Default)]
881pub struct HoverState {
882 pub info_popovers: Vec<InfoPopover>,
883 pub diagnostic_popover: Option<DiagnosticPopover>,
884 pub info_task: Option<Task<Option<()>>>,
885 pub closest_mouse_distance: Option<Pixels>,
886 pub hiding_delay_task: Option<Task<()>>,
887}
888
889impl HoverState {
890 pub fn visible(&self) -> bool {
891 !self.info_popovers.is_empty() || self.diagnostic_popover.is_some()
892 }
893
894 pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point<Pixels>) -> bool {
895 if !self.visible() {
896 return false;
897 }
898
899 let mut popover_bounds = Vec::new();
900 for info_popover in &self.info_popovers {
901 if let Some(bounds) = info_popover.last_bounds.get() {
902 popover_bounds.push(bounds);
903 }
904 }
905 if let Some(diagnostic_popover) = &self.diagnostic_popover {
906 if let Some(bounds) = diagnostic_popover.last_bounds.get() {
907 popover_bounds.push(bounds);
908 }
909 }
910
911 if popover_bounds.is_empty() {
912 return false;
913 }
914
915 let distance = popover_bounds
916 .iter()
917 .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds))
918 .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
919 .unwrap_or(px(f32::MAX));
920
921 if let Some(closest_distance) = self.closest_mouse_distance {
922 if distance > closest_distance + px(4.0) {
923 return false;
924 }
925 }
926
927 self.closest_mouse_distance =
928 Some(distance.min(self.closest_mouse_distance.unwrap_or(distance)));
929 true
930 }
931
932 fn distance_from_point_to_bounds(
933 &self,
934 point: gpui::Point<Pixels>,
935 bounds: Bounds<Pixels>,
936 ) -> Pixels {
937 let center_x = bounds.origin.x + bounds.size.width / 2.;
938 let center_y = bounds.origin.y + bounds.size.height / 2.;
939 let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.)
940 .max(px(0.0))
941 .into();
942 let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.)
943 .max(px(0.0))
944 .into();
945 px((dx.powi(2) + dy.powi(2)).sqrt())
946 }
947
948 pub(crate) fn render(
949 &mut self,
950 snapshot: &EditorSnapshot,
951 visible_rows: Range<DisplayRow>,
952 max_size: Size<Pixels>,
953 text_layout_details: &TextLayoutDetails,
954 window: &mut Window,
955 cx: &mut Context<Editor>,
956 ) -> Option<(DisplayPoint, Vec<AnyElement>)> {
957 if !self.visible() {
958 return None;
959 }
960 // If there is a diagnostic, position the popovers based on that.
961 // Otherwise use the start of the hover range
962 let anchor = self
963 .diagnostic_popover
964 .as_ref()
965 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
966 .or_else(|| {
967 self.info_popovers.iter().find_map(|info_popover| {
968 match &info_popover.symbol_range {
969 RangeInEditor::Text(range) => Some(&range.start),
970 RangeInEditor::Inlay(_) => None,
971 }
972 })
973 })
974 .or_else(|| {
975 self.info_popovers.iter().find_map(|info_popover| {
976 match &info_popover.symbol_range {
977 RangeInEditor::Text(_) => None,
978 RangeInEditor::Inlay(range) => Some(&range.inlay_position),
979 }
980 })
981 })?;
982 let mut point = anchor.to_display_point(&snapshot.display_snapshot);
983 // Clamp the point within the visible rows in case the popup source spans multiple lines
984 if visible_rows.end <= point.row() {
985 point = crate::movement::up_by_rows(
986 &snapshot.display_snapshot,
987 point,
988 1 + (point.row() - visible_rows.end).0,
989 text::SelectionGoal::None,
990 true,
991 text_layout_details,
992 )
993 .0;
994 } else if point.row() < visible_rows.start {
995 point = crate::movement::down_by_rows(
996 &snapshot.display_snapshot,
997 point,
998 (visible_rows.start - point.row()).0,
999 text::SelectionGoal::None,
1000 true,
1001 text_layout_details,
1002 )
1003 .0;
1004 }
1005
1006 if !visible_rows.contains(&point.row()) {
1007 log::error!("Hover popover point out of bounds after moving");
1008 return None;
1009 }
1010
1011 let mut elements = Vec::new();
1012
1013 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
1014 elements.push(diagnostic_popover.render(max_size, window, cx));
1015 }
1016 for info_popover in &mut self.info_popovers {
1017 elements.push(info_popover.render(max_size, window, cx));
1018 }
1019
1020 Some((point, elements))
1021 }
1022
1023 pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
1024 let mut hover_popover_is_focused = false;
1025 for info_popover in &self.info_popovers {
1026 if let Some(markdown_view) = &info_popover.parsed_content
1027 && markdown_view.focus_handle(cx).is_focused(window)
1028 {
1029 hover_popover_is_focused = true;
1030 }
1031 }
1032 if let Some(diagnostic_popover) = &self.diagnostic_popover
1033 && diagnostic_popover
1034 .markdown
1035 .focus_handle(cx)
1036 .is_focused(window)
1037 {
1038 hover_popover_is_focused = true;
1039 }
1040 hover_popover_is_focused
1041 }
1042}
1043
1044pub struct InfoPopover {
1045 pub symbol_range: RangeInEditor,
1046 pub parsed_content: Option<Entity<Markdown>>,
1047 pub scroll_handle: ScrollHandle,
1048 pub keyboard_grace: Rc<RefCell<bool>>,
1049 pub anchor: Option<Anchor>,
1050 pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
1051 _subscription: Option<Subscription>,
1052}
1053
1054impl InfoPopover {
1055 pub(crate) fn render(
1056 &mut self,
1057 max_size: Size<Pixels>,
1058 window: &mut Window,
1059 cx: &mut Context<Editor>,
1060 ) -> AnyElement {
1061 let keyboard_grace = Rc::clone(&self.keyboard_grace);
1062 let this = cx.entity().downgrade();
1063 let this2 = this.clone();
1064 let bounds_cell = self.last_bounds.clone();
1065 div()
1066 .id("info_popover")
1067 .occlude()
1068 .elevation_2(cx)
1069 .child(
1070 canvas(
1071 {
1072 move |bounds, _window, _cx| {
1073 bounds_cell.set(Some(bounds));
1074 }
1075 },
1076 |_, _, _, _| {},
1077 )
1078 .absolute()
1079 .size_full(),
1080 )
1081 // Prevent a mouse down/move on the popover from being propagated to the editor,
1082 // because that would dismiss the popover.
1083 .on_mouse_move({
1084 move |_, _, cx: &mut App| {
1085 this.update(cx, |editor, _| {
1086 editor.hover_state.closest_mouse_distance = Some(px(0.0));
1087 editor.hover_state.hiding_delay_task = None;
1088 })
1089 .ok();
1090 cx.stop_propagation()
1091 }
1092 })
1093 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1094 let mut keyboard_grace = keyboard_grace.borrow_mut();
1095 *keyboard_grace = false;
1096 cx.stop_propagation();
1097 })
1098 .when_some(self.parsed_content.clone(), |this, markdown| {
1099 this.child(
1100 div()
1101 .id("info-md-container")
1102 .overflow_y_scroll()
1103 .max_w(max_size.width)
1104 .max_h(max_size.height)
1105 .track_scroll(&self.scroll_handle)
1106 .child(
1107 MarkdownElement::new(markdown, hover_markdown_style(window, cx))
1108 .scroll_handle(self.scroll_handle.clone())
1109 .code_block_renderer(markdown::CodeBlockRenderer::Default {
1110 copy_button_visibility: CopyButtonVisibility::Hidden,
1111 wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
1112 border: false,
1113 })
1114 .on_url_click(move |link, window, cx| {
1115 open_markdown_url(
1116 this2
1117 .read_with(cx, |editor, _| editor.workspace())
1118 .ok()
1119 .flatten(),
1120 link,
1121 window,
1122 cx,
1123 )
1124 })
1125 .p_2(),
1126 ),
1127 )
1128 .custom_scrollbars(
1129 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1130 .tracked_scroll_handle(&self.scroll_handle),
1131 window,
1132 cx,
1133 )
1134 })
1135 .into_any_element()
1136 }
1137
1138 pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context<Editor>) {
1139 let mut current = self.scroll_handle.offset();
1140 current.y -= amount.pixels(
1141 window.line_height(),
1142 self.scroll_handle.bounds().size.height - px(16.),
1143 ) / 2.0;
1144 cx.notify();
1145 self.scroll_handle.set_offset(current);
1146 }
1147}
1148
1149pub struct DiagnosticPopover {
1150 pub(crate) local_diagnostic: DiagnosticEntry<Anchor>,
1151 markdown: Entity<Markdown>,
1152 border_color: Hsla,
1153 background_color: Hsla,
1154 pub keyboard_grace: Rc<RefCell<bool>>,
1155 pub anchor: Anchor,
1156 pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
1157 _subscription: Subscription,
1158 pub scroll_handle: ScrollHandle,
1159}
1160
1161impl DiagnosticPopover {
1162 pub fn render(
1163 &self,
1164 max_size: Size<Pixels>,
1165 window: &mut Window,
1166 cx: &mut Context<Editor>,
1167 ) -> AnyElement {
1168 let keyboard_grace = Rc::clone(&self.keyboard_grace);
1169 let this = cx.entity().downgrade();
1170 let bounds_cell = self.last_bounds.clone();
1171 div()
1172 .id("diagnostic")
1173 .occlude()
1174 .elevation_2_borderless(cx)
1175 .child(
1176 canvas(
1177 {
1178 move |bounds, _window, _cx| {
1179 bounds_cell.set(Some(bounds));
1180 }
1181 },
1182 |_, _, _, _| {},
1183 )
1184 .absolute()
1185 .size_full(),
1186 )
1187 // Don't draw the background color if the theme
1188 // allows transparent surfaces.
1189 .when(theme_is_transparent(cx), |this| {
1190 this.bg(gpui::transparent_black())
1191 })
1192 // Prevent a mouse move on the popover from being propagated to the editor,
1193 // because that would dismiss the popover.
1194 .on_mouse_move({
1195 let this = this.clone();
1196 move |_, _, cx: &mut App| {
1197 this.update(cx, |editor, _| {
1198 editor.hover_state.closest_mouse_distance = Some(px(0.0));
1199 editor.hover_state.hiding_delay_task = None;
1200 })
1201 .ok();
1202 cx.stop_propagation()
1203 }
1204 })
1205 // Prevent a mouse down on the popover from being propagated to the editor,
1206 // because that would move the cursor.
1207 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1208 let mut keyboard_grace = keyboard_grace.borrow_mut();
1209 *keyboard_grace = false;
1210 cx.stop_propagation();
1211 })
1212 .child(
1213 div()
1214 .relative()
1215 .py_1()
1216 .pl_2()
1217 .pr_8()
1218 .bg(self.background_color)
1219 .border_1()
1220 .border_color(self.border_color)
1221 .rounded_lg()
1222 .child(
1223 div()
1224 .id("diagnostic-content-container")
1225 .max_w(max_size.width)
1226 .max_h(max_size.height)
1227 .overflow_y_scroll()
1228 .track_scroll(&self.scroll_handle)
1229 .child(
1230 MarkdownElement::new(
1231 self.markdown.clone(),
1232 diagnostics_markdown_style(window, cx),
1233 )
1234 .code_block_renderer(markdown::CodeBlockRenderer::Default {
1235 copy_button_visibility: CopyButtonVisibility::Hidden,
1236 wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
1237 border: false,
1238 })
1239 .on_url_click(
1240 move |link, window, cx| {
1241 if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
1242 {
1243 this.update(cx, |this, cx| {
1244 renderer.as_ref().open_link(this, link, window, cx);
1245 })
1246 .ok();
1247 }
1248 },
1249 ),
1250 ),
1251 )
1252 .child(div().absolute().top_1().right_1().child({
1253 let message = self.local_diagnostic.diagnostic.message.clone();
1254 CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic")
1255 }))
1256 .custom_scrollbars(
1257 Scrollbars::for_settings::<EditorSettingsScrollbarProxy>()
1258 .tracked_scroll_handle(&self.scroll_handle),
1259 window,
1260 cx,
1261 ),
1262 )
1263 .into_any_element()
1264 }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use crate::{
1271 PointForPosition,
1272 actions::ConfirmCompletion,
1273 editor_tests::{handle_completion_request, init_test},
1274 inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
1275 test::editor_lsp_test_context::EditorLspTestContext,
1276 };
1277 use collections::BTreeSet;
1278 use futures::stream::StreamExt;
1279 use gpui::App;
1280 use indoc::indoc;
1281 use markdown::parser::MarkdownEvent;
1282 use project::InlayId;
1283 use settings::InlayHintSettingsContent;
1284 use settings::{DelayMs, SettingsStore};
1285 use std::sync::atomic;
1286 use std::sync::atomic::AtomicUsize;
1287 use text::Bias;
1288
1289 fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 {
1290 cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 })
1291 }
1292
1293 #[gpui::test]
1294 fn test_hover_markdown_preserves_soft_breaks(cx: &mut gpui::TestAppContext) {
1295 init_test(cx, |_| {});
1296
1297 let cx = cx.add_empty_window();
1298 let text = concat!(
1299 "class super(object)\n",
1300 "| super(type) -> unbound super object\n",
1301 "| super(type, obj) -> bound super object"
1302 );
1303 let markdown = cx.new(|cx| Markdown::new(text.into(), None, None, cx));
1304 cx.run_until_parked();
1305
1306 let rendered = MarkdownElement::rendered_text(markdown, cx, hover_markdown_style);
1307
1308 // The two soft breaks must render as real newline characters rather
1309 // than being collapsed into spaces.
1310 assert_eq!(
1311 rendered.matches('\n').count(),
1312 2,
1313 "expected two hard line breaks, got {rendered:?}"
1314 );
1315 let lines: Vec<&str> = rendered.split('\n').collect();
1316 assert_eq!(
1317 lines,
1318 [
1319 "class super(object)",
1320 "| super(type) -> unbound super object",
1321 "| super(type, obj) -> bound super object",
1322 ]
1323 );
1324 // The two spaces after each `|` continuation marker are preserved verbatim.
1325 assert!(lines[1].starts_with("| super"));
1326 assert!(lines[2].starts_with("| super"));
1327 // No tabs are introduced anywhere in the rendered output.
1328 assert!(!rendered.contains('\t'));
1329 // And the full rendering matches the source exactly.
1330 assert_eq!(rendered, text);
1331 }
1332
1333 impl InfoPopover {
1334 fn get_rendered_text(&self, cx: &gpui::App) -> String {
1335 let mut rendered_text = String::new();
1336 if let Some(parsed_content) = self.parsed_content.clone() {
1337 let markdown = parsed_content.read(cx);
1338 let text = markdown.parsed_markdown().source().to_string();
1339 let data = markdown.parsed_markdown().events();
1340 let slice = data;
1341
1342 for (range, event) in slice.iter() {
1343 match event {
1344 MarkdownEvent::SubstitutedText(parsed) => {
1345 rendered_text.push_str(parsed.as_str())
1346 }
1347 MarkdownEvent::Text | MarkdownEvent::Code => {
1348 rendered_text.push_str(&text[range.clone()])
1349 }
1350 _ => {}
1351 }
1352 }
1353 }
1354 rendered_text
1355 }
1356 }
1357
1358 #[gpui::test]
1359 async fn test_mouse_hover_info_popover_with_autocomplete_popover(
1360 cx: &mut gpui::TestAppContext,
1361 ) {
1362 init_test(cx, |_| {});
1363
1364 let mut cx = EditorLspTestContext::new_rust(
1365 lsp::ServerCapabilities {
1366 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1367 completion_provider: Some(lsp::CompletionOptions {
1368 trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
1369 resolve_provider: Some(true),
1370 ..Default::default()
1371 }),
1372 ..Default::default()
1373 },
1374 cx,
1375 )
1376 .await;
1377 let counter = Arc::new(AtomicUsize::new(0));
1378 // Basic hover delays and then pops without moving the mouse
1379 cx.set_state(indoc! {"
1380 oneˇ
1381 two
1382 three
1383 fn test() { println!(); }
1384 "});
1385
1386 //prompt autocompletion menu
1387 cx.simulate_keystroke(".");
1388 handle_completion_request(
1389 indoc! {"
1390 one.|<>
1391 two
1392 three
1393 "},
1394 vec!["first_completion", "second_completion"],
1395 true,
1396 counter.clone(),
1397 &mut cx,
1398 )
1399 .await;
1400 cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible
1401 .await;
1402 assert_eq!(counter.load(atomic::Ordering::Acquire), 1); // 1 completion request
1403
1404 let hover_point = cx.display_point(indoc! {"
1405 one.
1406 two
1407 three
1408 fn test() { printˇln!(); }
1409 "});
1410 cx.update_editor(|editor, window, cx| {
1411 let snapshot = editor.snapshot(window, cx);
1412 let anchor = snapshot
1413 .buffer_snapshot()
1414 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1415 hover_at(editor, Some(anchor), None, window, cx)
1416 });
1417 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1418
1419 // After delay, hover should be visible.
1420 let symbol_range = cx.lsp_range(indoc! {"
1421 one.
1422 two
1423 three
1424 fn test() { «println!»(); }
1425 "});
1426 let mut requests =
1427 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1428 Ok(Some(lsp::Hover {
1429 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1430 kind: lsp::MarkupKind::Markdown,
1431 value: "some basic docs".to_string(),
1432 }),
1433 range: Some(symbol_range),
1434 }))
1435 });
1436 cx.background_executor
1437 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1438 requests.next().await;
1439
1440 cx.editor(|editor, _window, cx| {
1441 assert!(editor.hover_state.visible());
1442 assert_eq!(
1443 editor.hover_state.info_popovers.len(),
1444 1,
1445 "Expected exactly one hover but got: {:?}",
1446 editor.hover_state.info_popovers.len()
1447 );
1448 let rendered_text = editor
1449 .hover_state
1450 .info_popovers
1451 .first()
1452 .unwrap()
1453 .get_rendered_text(cx);
1454 assert_eq!(rendered_text, "some basic docs".to_string())
1455 });
1456
1457 // check that the completion menu is still visible and that there still has only been 1 completion request
1458 cx.editor(|editor, _, _| assert!(editor.context_menu_visible()));
1459 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1460
1461 //apply a completion and check it was successfully applied
1462 let _apply_additional_edits = cx.update_editor(|editor, window, cx| {
1463 editor.context_menu_next(&Default::default(), window, cx);
1464 editor
1465 .confirm_completion(&ConfirmCompletion::default(), window, cx)
1466 .unwrap()
1467 });
1468 cx.assert_editor_state(indoc! {"
1469 one.second_completionˇ
1470 two
1471 three
1472 fn test() { println!(); }
1473 "});
1474
1475 // check that the completion menu is no longer visible and that there still has only been 1 completion request
1476 cx.editor(|editor, _, _| assert!(!editor.context_menu_visible()));
1477 assert_eq!(counter.load(atomic::Ordering::Acquire), 1);
1478
1479 //verify the information popover is still visible and unchanged
1480 cx.editor(|editor, _, cx| {
1481 assert!(editor.hover_state.visible());
1482 assert_eq!(
1483 editor.hover_state.info_popovers.len(),
1484 1,
1485 "Expected exactly one hover but got: {:?}",
1486 editor.hover_state.info_popovers.len()
1487 );
1488 let rendered_text = editor
1489 .hover_state
1490 .info_popovers
1491 .first()
1492 .unwrap()
1493 .get_rendered_text(cx);
1494
1495 assert_eq!(rendered_text, "some basic docs".to_string())
1496 });
1497
1498 // Mouse moved with no hover response dismisses
1499 let hover_point = cx.display_point(indoc! {"
1500 one.second_completionˇ
1501 two
1502 three
1503 fn teˇst() { println!(); }
1504 "});
1505 let mut request = cx
1506 .lsp
1507 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1508 |_, _| async move { Ok(None) },
1509 );
1510 cx.update_editor(|editor, window, cx| {
1511 let snapshot = editor.snapshot(window, cx);
1512 let anchor = snapshot
1513 .buffer_snapshot()
1514 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1515 hover_at(editor, Some(anchor), None, window, cx)
1516 });
1517 cx.background_executor
1518 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1519 request.next().await;
1520
1521 // verify that the information popover is no longer visible
1522 cx.editor(|editor, _, _| {
1523 assert!(!editor.hover_state.visible());
1524 });
1525 }
1526
1527 #[gpui::test]
1528 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
1529 init_test(cx, |_| {});
1530
1531 let mut cx = EditorLspTestContext::new_rust(
1532 lsp::ServerCapabilities {
1533 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1534 ..Default::default()
1535 },
1536 cx,
1537 )
1538 .await;
1539
1540 // Basic hover delays and then pops without moving the mouse
1541 cx.set_state(indoc! {"
1542 fn ˇtest() { println!(); }
1543 "});
1544 let hover_point = cx.display_point(indoc! {"
1545 fn test() { printˇln!(); }
1546 "});
1547
1548 cx.update_editor(|editor, window, cx| {
1549 let snapshot = editor.snapshot(window, cx);
1550 let anchor = snapshot
1551 .buffer_snapshot()
1552 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1553 hover_at(editor, Some(anchor), None, window, cx)
1554 });
1555 assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
1556
1557 // After delay, hover should be visible.
1558 let symbol_range = cx.lsp_range(indoc! {"
1559 fn test() { «println!»(); }
1560 "});
1561 let mut requests =
1562 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1563 Ok(Some(lsp::Hover {
1564 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1565 kind: lsp::MarkupKind::Markdown,
1566 value: "some basic docs".to_string(),
1567 }),
1568 range: Some(symbol_range),
1569 }))
1570 });
1571 cx.background_executor
1572 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1573 requests.next().await;
1574
1575 cx.editor(|editor, _, cx| {
1576 assert!(editor.hover_state.visible());
1577 assert_eq!(
1578 editor.hover_state.info_popovers.len(),
1579 1,
1580 "Expected exactly one hover but got: {:?}",
1581 editor.hover_state.info_popovers.len()
1582 );
1583 let rendered_text = editor
1584 .hover_state
1585 .info_popovers
1586 .first()
1587 .unwrap()
1588 .get_rendered_text(cx);
1589
1590 assert_eq!(rendered_text, "some basic docs".to_string())
1591 });
1592
1593 // Mouse moved with no hover response dismisses
1594 let hover_point = cx.display_point(indoc! {"
1595 fn teˇst() { println!(); }
1596 "});
1597 let mut request = cx
1598 .lsp
1599 .set_request_handler::<lsp::request::HoverRequest, _, _>(
1600 |_, _| async move { Ok(None) },
1601 );
1602 cx.update_editor(|editor, window, cx| {
1603 let snapshot = editor.snapshot(window, cx);
1604 let anchor = snapshot
1605 .buffer_snapshot()
1606 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1607 hover_at(editor, Some(anchor), None, window, cx)
1608 });
1609 cx.background_executor
1610 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1611 request.next().await;
1612 cx.editor(|editor, _, _| {
1613 assert!(!editor.hover_state.visible());
1614 });
1615 }
1616
1617 #[gpui::test]
1618 async fn test_mouse_hover_cancelled_before_delay(cx: &mut gpui::TestAppContext) {
1619 init_test(cx, |_| {});
1620
1621 let mut cx = EditorLspTestContext::new_rust(
1622 lsp::ServerCapabilities {
1623 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1624 ..Default::default()
1625 },
1626 cx,
1627 )
1628 .await;
1629
1630 cx.set_state(indoc! {"
1631 fn ˇtest() { println!(); }
1632 "});
1633 let hover_point = cx.display_point(indoc! {"
1634 fn test() { printˇln!(); }
1635 "});
1636
1637 cx.update_editor(|editor, window, cx| {
1638 let snapshot = editor.snapshot(window, cx);
1639 let anchor = snapshot
1640 .buffer_snapshot()
1641 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
1642 hover_at(editor, Some(anchor), None, window, cx);
1643 hover_at(editor, None, None, window, cx);
1644 });
1645
1646 let request_count = Arc::new(AtomicUsize::new(0));
1647 cx.set_request_handler::<lsp::request::HoverRequest, _, _>({
1648 let request_count = request_count.clone();
1649 move |_, _, _| {
1650 let request_count = request_count.clone();
1651 async move {
1652 request_count.fetch_add(1, atomic::Ordering::Release);
1653 Ok(Some(lsp::Hover {
1654 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1655 kind: lsp::MarkupKind::Markdown,
1656 value: "some basic docs".to_string(),
1657 }),
1658 range: None,
1659 }))
1660 }
1661 }
1662 });
1663
1664 cx.background_executor
1665 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
1666 cx.background_executor.run_until_parked();
1667 cx.run_until_parked();
1668
1669 assert_eq!(request_count.load(atomic::Ordering::Acquire), 0);
1670 cx.editor(|editor, _, _| {
1671 assert!(!editor.hover_state.visible());
1672 });
1673 }
1674
1675 #[gpui::test]
1676 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
1677 init_test(cx, |_| {});
1678
1679 let mut cx = EditorLspTestContext::new_rust(
1680 lsp::ServerCapabilities {
1681 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1682 ..Default::default()
1683 },
1684 cx,
1685 )
1686 .await;
1687
1688 // Hover with keyboard has no delay
1689 cx.set_state(indoc! {"
1690 fˇn test() { println!(); }
1691 "});
1692 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1693 let symbol_range = cx.lsp_range(indoc! {"
1694 «fn» test() { println!(); }
1695 "});
1696
1697 cx.editor(|editor, _window, _cx| {
1698 assert!(!editor.hover_state.visible());
1699
1700 assert_eq!(
1701 editor.hover_state.info_popovers.len(),
1702 0,
1703 "Expected no hovers but got but got: {:?}",
1704 editor.hover_state.info_popovers.len()
1705 );
1706 });
1707
1708 let mut requests =
1709 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1710 Ok(Some(lsp::Hover {
1711 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1712 kind: lsp::MarkupKind::Markdown,
1713 value: "some other basic docs".to_string(),
1714 }),
1715 range: Some(symbol_range),
1716 }))
1717 });
1718
1719 requests.next().await;
1720 cx.dispatch_action(Hover);
1721
1722 cx.condition(|editor, _| editor.hover_state.visible()).await;
1723 cx.editor(|editor, _, cx| {
1724 assert_eq!(
1725 editor.hover_state.info_popovers.len(),
1726 1,
1727 "Expected exactly one hover but got: {:?}",
1728 editor.hover_state.info_popovers.len()
1729 );
1730
1731 let rendered_text = editor
1732 .hover_state
1733 .info_popovers
1734 .first()
1735 .unwrap()
1736 .get_rendered_text(cx);
1737
1738 assert_eq!(rendered_text, "some other basic docs".to_string())
1739 });
1740 }
1741
1742 #[gpui::test]
1743 async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) {
1744 init_test(cx, |_| {});
1745
1746 let mut cx = EditorLspTestContext::new_rust(
1747 lsp::ServerCapabilities {
1748 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1749 ..Default::default()
1750 },
1751 cx,
1752 )
1753 .await;
1754
1755 // Hover with keyboard has no delay
1756 cx.set_state(indoc! {"
1757 fˇn test() { println!(); }
1758 "});
1759 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1760 let symbol_range = cx.lsp_range(indoc! {"
1761 «fn» test() { println!(); }
1762 "});
1763 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1764 Ok(Some(lsp::Hover {
1765 contents: lsp::HoverContents::Array(vec![
1766 lsp::MarkedString::String("regular text for hover to show".to_string()),
1767 lsp::MarkedString::String("".to_string()),
1768 lsp::MarkedString::LanguageString(lsp::LanguageString {
1769 language: "Rust".to_string(),
1770 value: "".to_string(),
1771 }),
1772 ]),
1773 range: Some(symbol_range),
1774 }))
1775 })
1776 .next()
1777 .await;
1778 cx.dispatch_action(Hover);
1779
1780 cx.condition(|editor, _| editor.hover_state.visible()).await;
1781 cx.editor(|editor, _, cx| {
1782 assert_eq!(
1783 editor.hover_state.info_popovers.len(),
1784 1,
1785 "Expected exactly one hover but got: {:?}",
1786 editor.hover_state.info_popovers.len()
1787 );
1788 let rendered_text = editor
1789 .hover_state
1790 .info_popovers
1791 .first()
1792 .unwrap()
1793 .get_rendered_text(cx);
1794
1795 assert_eq!(
1796 rendered_text,
1797 "regular text for hover to show".to_string(),
1798 "No empty string hovers should be shown"
1799 );
1800 });
1801 }
1802
1803 #[gpui::test]
1804 async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) {
1805 init_test(cx, |_| {});
1806
1807 let mut cx = EditorLspTestContext::new_rust(
1808 lsp::ServerCapabilities {
1809 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1810 ..Default::default()
1811 },
1812 cx,
1813 )
1814 .await;
1815
1816 // Hover with keyboard has no delay
1817 cx.set_state(indoc! {"
1818 fˇn test() { println!(); }
1819 "});
1820 cx.update_editor(|editor, window, cx| hover(editor, &Hover, window, cx));
1821 let symbol_range = cx.lsp_range(indoc! {"
1822 «fn» test() { println!(); }
1823 "});
1824
1825 let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n";
1826 let markdown_string = format!("\n```rust\n{code_str}```");
1827
1828 let closure_markdown_string = markdown_string.clone();
1829 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| {
1830 let future_markdown_string = closure_markdown_string.clone();
1831 async move {
1832 Ok(Some(lsp::Hover {
1833 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1834 kind: lsp::MarkupKind::Markdown,
1835 value: future_markdown_string,
1836 }),
1837 range: Some(symbol_range),
1838 }))
1839 }
1840 })
1841 .next()
1842 .await;
1843
1844 cx.dispatch_action(Hover);
1845
1846 cx.condition(|editor, _| editor.hover_state.visible()).await;
1847 cx.editor(|editor, _, cx| {
1848 assert_eq!(
1849 editor.hover_state.info_popovers.len(),
1850 1,
1851 "Expected exactly one hover but got: {:?}",
1852 editor.hover_state.info_popovers.len()
1853 );
1854 let rendered_text = editor
1855 .hover_state
1856 .info_popovers
1857 .first()
1858 .unwrap()
1859 .get_rendered_text(cx);
1860
1861 assert_eq!(
1862 rendered_text, code_str,
1863 "Should not have extra line breaks at end of rendered hover"
1864 );
1865 });
1866 }
1867
1868 #[gpui::test]
1869 // https://github.com/zed-industries/zed/issues/15498
1870 async fn test_info_hover_with_hrs(cx: &mut gpui::TestAppContext) {
1871 init_test(cx, |_| {});
1872
1873 let mut cx = EditorLspTestContext::new_rust(
1874 lsp::ServerCapabilities {
1875 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1876 ..Default::default()
1877 },
1878 cx,
1879 )
1880 .await;
1881
1882 cx.set_state(indoc! {"
1883 fn fuˇnc(abc def: i32) -> u32 {
1884 }
1885 "});
1886
1887 cx.lsp
1888 .set_request_handler::<lsp::request::HoverRequest, _, _>({
1889 |_, _| async move {
1890 Ok(Some(lsp::Hover {
1891 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1892 kind: lsp::MarkupKind::Markdown,
1893 value: indoc!(
1894 r#"
1895 ### function `errands_data_read`
1896
1897 ---
1898 → `char *`
1899 Function to read a file into a string
1900
1901 ---
1902 ```cpp
1903 static char *errands_data_read()
1904 ```
1905 "#
1906 )
1907 .to_string(),
1908 }),
1909 range: None,
1910 }))
1911 }
1912 });
1913 cx.update_editor(|editor, window, cx| hover(editor, &Default::default(), window, cx));
1914 cx.run_until_parked();
1915
1916 cx.update_editor(|editor, _, cx| {
1917 let popover = editor.hover_state.info_popovers.first().unwrap();
1918 let content = popover.get_rendered_text(cx);
1919
1920 assert!(content.contains("Function to read a file"));
1921 });
1922 }
1923
1924 #[gpui::test]
1925 async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) {
1926 init_test(cx, |settings| {
1927 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1928 show_value_hints: Some(true),
1929 enabled: Some(true),
1930 edit_debounce_ms: Some(0),
1931 scroll_debounce_ms: Some(0),
1932 show_type_hints: Some(true),
1933 show_parameter_hints: Some(true),
1934 show_other_hints: Some(true),
1935 show_background: Some(false),
1936 toggle_on_modifiers_press: None,
1937 })
1938 });
1939
1940 let mut cx = EditorLspTestContext::new_rust(
1941 lsp::ServerCapabilities {
1942 inlay_hint_provider: Some(lsp::OneOf::Right(
1943 lsp::InlayHintServerCapabilities::Options(lsp::InlayHintOptions {
1944 resolve_provider: Some(true),
1945 ..Default::default()
1946 }),
1947 )),
1948 ..Default::default()
1949 },
1950 cx,
1951 )
1952 .await;
1953
1954 cx.set_state(indoc! {"
1955 struct TestStruct;
1956
1957 // ==================
1958
1959 struct TestNewType<T>(T);
1960
1961 fn main() {
1962 let variableˇ = TestNewType(TestStruct);
1963 }
1964 "});
1965
1966 let hint_start_offset = cx.ranges(indoc! {"
1967 struct TestStruct;
1968
1969 // ==================
1970
1971 struct TestNewType<T>(T);
1972
1973 fn main() {
1974 let variableˇ = TestNewType(TestStruct);
1975 }
1976 "})[0]
1977 .start;
1978 let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1979 let new_type_target_range = cx.lsp_range(indoc! {"
1980 struct TestStruct;
1981
1982 // ==================
1983
1984 struct «TestNewType»<T>(T);
1985
1986 fn main() {
1987 let variable = TestNewType(TestStruct);
1988 }
1989 "});
1990 let struct_target_range = cx.lsp_range(indoc! {"
1991 struct «TestStruct»;
1992
1993 // ==================
1994
1995 struct TestNewType<T>(T);
1996
1997 fn main() {
1998 let variable = TestNewType(TestStruct);
1999 }
2000 "});
2001
2002 let uri = cx.buffer_lsp_url.clone();
2003 let new_type_label = "TestNewType";
2004 let struct_label = "TestStruct";
2005 let entire_hint_label = ": TestNewType<TestStruct>";
2006 let closure_uri = uri.clone();
2007 cx.lsp
2008 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
2009 let task_uri = closure_uri.clone();
2010 async move {
2011 assert_eq!(params.text_document.uri, task_uri);
2012 Ok(Some(vec![lsp::InlayHint {
2013 position: hint_position,
2014 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
2015 value: entire_hint_label.to_string(),
2016 ..Default::default()
2017 }]),
2018 kind: Some(lsp::InlayHintKind::TYPE),
2019 text_edits: None,
2020 tooltip: None,
2021 padding_left: Some(false),
2022 padding_right: Some(false),
2023 data: None,
2024 }]))
2025 }
2026 })
2027 .next()
2028 .await;
2029 cx.background_executor.run_until_parked();
2030 cx.update_editor(|editor, _, cx| {
2031 let expected_layers = vec![entire_hint_label.to_string()];
2032 assert_eq!(expected_layers, cached_hint_labels(editor, cx));
2033 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
2034 });
2035
2036 let inlay_range = cx
2037 .ranges(indoc! {"
2038 struct TestStruct;
2039
2040 // ==================
2041
2042 struct TestNewType<T>(T);
2043
2044 fn main() {
2045 let variable« »= TestNewType(TestStruct);
2046 }
2047 "})
2048 .first()
2049 .cloned()
2050 .unwrap();
2051 let new_type_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
2052 let snapshot = editor.snapshot(window, cx);
2053 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
2054 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
2055 assert_eq!(previous_valid.row(), next_valid.row());
2056 assert!(previous_valid.column() < next_valid.column());
2057 let exact_unclipped = DisplayPoint::new(
2058 previous_valid.row(),
2059 previous_valid.column()
2060 + (entire_hint_label.find(new_type_label).unwrap() + new_type_label.len() / 2)
2061 as u32,
2062 );
2063 PointForPosition {
2064 previous_valid,
2065 next_valid,
2066 nearest_valid: previous_valid,
2067 exact_unclipped,
2068 column_overshoot_after_line_end: 0,
2069 }
2070 });
2071 cx.update_editor(|editor, window, cx| {
2072 editor.update_inlay_link_and_hover_points(
2073 &editor.snapshot(window, cx),
2074 new_type_hint_part_hover_position,
2075 None,
2076 true,
2077 false,
2078 window,
2079 cx,
2080 );
2081 });
2082
2083 let resolve_closure_uri = uri.clone();
2084 cx.lsp
2085 .set_request_handler::<lsp::request::InlayHintResolveRequest, _, _>(
2086 move |mut hint_to_resolve, _| {
2087 let mut resolved_hint_positions = BTreeSet::new();
2088 let task_uri = resolve_closure_uri.clone();
2089 async move {
2090 let inserted = resolved_hint_positions.insert(hint_to_resolve.position);
2091 assert!(inserted, "Hint {hint_to_resolve:?} was resolved twice");
2092
2093 // `: TestNewType<TestStruct>`
2094 hint_to_resolve.label = lsp::InlayHintLabel::LabelParts(vec![
2095 lsp::InlayHintLabelPart {
2096 value: ": ".to_string(),
2097 ..Default::default()
2098 },
2099 lsp::InlayHintLabelPart {
2100 value: new_type_label.to_string(),
2101 location: Some(lsp::Location {
2102 uri: task_uri.clone(),
2103 range: new_type_target_range,
2104 }),
2105 tooltip: Some(lsp::InlayHintLabelPartTooltip::String(format!(
2106 "A tooltip for `{new_type_label}`"
2107 ))),
2108 ..Default::default()
2109 },
2110 lsp::InlayHintLabelPart {
2111 value: "<".to_string(),
2112 ..Default::default()
2113 },
2114 lsp::InlayHintLabelPart {
2115 value: struct_label.to_string(),
2116 location: Some(lsp::Location {
2117 uri: task_uri,
2118 range: struct_target_range,
2119 }),
2120 tooltip: Some(lsp::InlayHintLabelPartTooltip::MarkupContent(
2121 lsp::MarkupContent {
2122 kind: lsp::MarkupKind::Markdown,
2123 value: format!("A tooltip for `{struct_label}`"),
2124 },
2125 )),
2126 ..Default::default()
2127 },
2128 lsp::InlayHintLabelPart {
2129 value: ">".to_string(),
2130 ..Default::default()
2131 },
2132 ]);
2133
2134 Ok(hint_to_resolve)
2135 }
2136 },
2137 )
2138 .next()
2139 .await;
2140 cx.background_executor.run_until_parked();
2141
2142 cx.update_editor(|editor, window, cx| {
2143 editor.update_inlay_link_and_hover_points(
2144 &editor.snapshot(window, cx),
2145 new_type_hint_part_hover_position,
2146 None,
2147 true,
2148 false,
2149 window,
2150 cx,
2151 );
2152 });
2153 cx.background_executor
2154 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2155 cx.background_executor.run_until_parked();
2156 cx.update_editor(|editor, _, cx| {
2157 let hover_state = &editor.hover_state;
2158 assert!(
2159 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
2160 );
2161 let popover = hover_state.info_popovers.first().unwrap();
2162 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
2163 assert_eq!(
2164 popover.symbol_range,
2165 RangeInEditor::Inlay(InlayHighlight {
2166 inlay: InlayId::Hint(0),
2167 inlay_position: buffer_snapshot
2168 .anchor_after(MultiBufferOffset(inlay_range.start)),
2169 range: ": ".len()..": ".len() + new_type_label.len(),
2170 }),
2171 "Popover range should match the new type label part"
2172 );
2173 assert_eq!(
2174 popover.get_rendered_text(cx),
2175 format!("A tooltip for {new_type_label}"),
2176 );
2177 });
2178
2179 let struct_hint_part_hover_position = cx.update_editor(|editor, window, cx| {
2180 let snapshot = editor.snapshot(window, cx);
2181 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
2182 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
2183 assert_eq!(previous_valid.row(), next_valid.row());
2184 assert!(previous_valid.column() < next_valid.column());
2185 let exact_unclipped = DisplayPoint::new(
2186 previous_valid.row(),
2187 previous_valid.column()
2188 + (entire_hint_label.find(struct_label).unwrap() + struct_label.len() / 2)
2189 as u32,
2190 );
2191 PointForPosition {
2192 previous_valid,
2193 next_valid,
2194 nearest_valid: previous_valid,
2195 exact_unclipped,
2196 column_overshoot_after_line_end: 0,
2197 }
2198 });
2199 cx.update_editor(|editor, window, cx| {
2200 editor.update_inlay_link_and_hover_points(
2201 &editor.snapshot(window, cx),
2202 struct_hint_part_hover_position,
2203 None,
2204 true,
2205 false,
2206 window,
2207 cx,
2208 );
2209 });
2210 cx.background_executor
2211 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2212 cx.background_executor.run_until_parked();
2213 cx.update_editor(|editor, _, cx| {
2214 let hover_state = &editor.hover_state;
2215 assert!(
2216 hover_state.diagnostic_popover.is_none() && hover_state.info_popovers.len() == 1
2217 );
2218 let popover = hover_state.info_popovers.first().unwrap();
2219 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
2220 assert_eq!(
2221 popover.symbol_range,
2222 RangeInEditor::Inlay(InlayHighlight {
2223 inlay: InlayId::Hint(0),
2224 inlay_position: buffer_snapshot
2225 .anchor_after(MultiBufferOffset(inlay_range.start)),
2226 range: ": ".len() + new_type_label.len() + "<".len()
2227 ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(),
2228 }),
2229 "Popover range should match the struct label part"
2230 );
2231 assert_eq!(
2232 popover.get_rendered_text(cx),
2233 format!("A tooltip for {struct_label}"),
2234 "Rendered markdown element should remove backticks from text"
2235 );
2236 });
2237 }
2238
2239 #[test]
2240 fn test_find_hovered_hint_part_with_multibyte_characters() {
2241 use crate::display_map::InlayOffset;
2242 use multi_buffer::MultiBufferOffset;
2243 use project::InlayHintLabelPart;
2244
2245 // Test with multi-byte UTF-8 character "→" (3 bytes, 1 character)
2246 let label = "→ app/Livewire/UserProfile.php";
2247 let label_parts = vec![InlayHintLabelPart {
2248 value: label.to_string(),
2249 tooltip: None,
2250 location: None,
2251 }];
2252
2253 let hint_start = InlayOffset(MultiBufferOffset(100));
2254
2255 // Verify the label has more bytes than characters (due to "→")
2256 assert_eq!(label.len(), 32); // bytes
2257 assert_eq!(label.chars().count(), 30); // characters
2258
2259 // Test hovering at the last byte (should find the part)
2260 let last_byte_offset = InlayOffset(MultiBufferOffset(100 + label.len() - 1));
2261 let result = find_hovered_hint_part(label_parts.clone(), hint_start, last_byte_offset);
2262 assert!(
2263 result.is_some(),
2264 "Should find part when hovering at last byte"
2265 );
2266 let (part, range) = result.unwrap();
2267 assert_eq!(part.value, label);
2268 assert_eq!(range.start, hint_start);
2269 assert_eq!(range.end, InlayOffset(MultiBufferOffset(100 + label.len())));
2270
2271 // Test hovering at the first byte of "→" (byte 0)
2272 let first_byte_offset = InlayOffset(MultiBufferOffset(100));
2273 let result = find_hovered_hint_part(label_parts.clone(), hint_start, first_byte_offset);
2274 assert!(
2275 result.is_some(),
2276 "Should find part when hovering at first byte"
2277 );
2278
2279 // Test hovering in the middle of "→" (byte 1, still part of the arrow character)
2280 let mid_arrow_offset = InlayOffset(MultiBufferOffset(101));
2281 let result = find_hovered_hint_part(label_parts, hint_start, mid_arrow_offset);
2282 assert!(
2283 result.is_some(),
2284 "Should find part when hovering in middle of multi-byte char"
2285 );
2286
2287 // Test with multiple parts containing multi-byte characters
2288 // Part ranges are [start, end) - start inclusive, end exclusive
2289 // "→ " occupies bytes [0, 4), "path" occupies bytes [4, 8)
2290 let parts = vec![
2291 InlayHintLabelPart {
2292 value: "→ ".to_string(), // 4 bytes (3 + 1)
2293 tooltip: None,
2294 location: None,
2295 },
2296 InlayHintLabelPart {
2297 value: "path".to_string(), // 4 bytes
2298 tooltip: None,
2299 location: None,
2300 },
2301 ];
2302
2303 // Hover at byte 3 (last byte of "→ ", the space character)
2304 let arrow_last_byte = InlayOffset(MultiBufferOffset(100 + 3));
2305 let result = find_hovered_hint_part(parts.clone(), hint_start, arrow_last_byte);
2306 assert!(result.is_some(), "Should find first part at its last byte");
2307 let (part, range) = result.unwrap();
2308 assert_eq!(part.value, "→ ");
2309 assert_eq!(
2310 range,
2311 InlayOffset(MultiBufferOffset(100))..InlayOffset(MultiBufferOffset(104))
2312 );
2313
2314 // Hover at byte 4 (first byte of "path", at the boundary)
2315 let path_start_offset = InlayOffset(MultiBufferOffset(100 + 4));
2316 let result = find_hovered_hint_part(parts.clone(), hint_start, path_start_offset);
2317 assert!(result.is_some(), "Should find second part at boundary");
2318 let (part, _) = result.unwrap();
2319 assert_eq!(part.value, "path");
2320
2321 // Hover at byte 7 (last byte of "path")
2322 let path_end_offset = InlayOffset(MultiBufferOffset(100 + 7));
2323 let result = find_hovered_hint_part(parts, hint_start, path_end_offset);
2324 assert!(result.is_some(), "Should find second part at last byte");
2325 let (part, range) = result.unwrap();
2326 assert_eq!(part.value, "path");
2327 assert_eq!(
2328 range,
2329 InlayOffset(MultiBufferOffset(104))..InlayOffset(MultiBufferOffset(108))
2330 );
2331 }
2332
2333 #[gpui::test]
2334 async fn test_hover_popover_hiding_delay(cx: &mut gpui::TestAppContext) {
2335 init_test(cx, |_| {});
2336
2337 let custom_delay_ms = 500u64;
2338 cx.update(|cx| {
2339 cx.update_global::<SettingsStore, _>(|settings, cx| {
2340 settings.update_user_settings(cx, |settings| {
2341 settings.editor.hover_popover_sticky = Some(true);
2342 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2343 });
2344 });
2345 });
2346
2347 let mut cx = EditorLspTestContext::new_rust(
2348 lsp::ServerCapabilities {
2349 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2350 ..Default::default()
2351 },
2352 cx,
2353 )
2354 .await;
2355
2356 cx.set_state(indoc! {"
2357 fn ˇtest() { println!(); }
2358 "});
2359
2360 // Trigger hover on a symbol
2361 let hover_point = cx.display_point(indoc! {"
2362 fn test() { printˇln!(); }
2363 "});
2364 let symbol_range = cx.lsp_range(indoc! {"
2365 fn test() { «println!»(); }
2366 "});
2367 let mut requests =
2368 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2369 Ok(Some(lsp::Hover {
2370 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2371 kind: lsp::MarkupKind::Markdown,
2372 value: "some basic docs".to_string(),
2373 }),
2374 range: Some(symbol_range),
2375 }))
2376 });
2377 cx.update_editor(|editor, window, cx| {
2378 let snapshot = editor.snapshot(window, cx);
2379 let anchor = snapshot
2380 .buffer_snapshot()
2381 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2382 hover_at(editor, Some(anchor), None, window, cx)
2383 });
2384 cx.background_executor
2385 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2386 requests.next().await;
2387
2388 // Hover should be visible
2389 cx.editor(|editor, _, _| {
2390 assert!(editor.hover_state.visible());
2391 });
2392
2393 // Move mouse away (hover_at with None anchor triggers the hiding delay)
2394 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2395
2396 // Popover should still be visible before the custom hiding delay expires
2397 cx.background_executor
2398 .advance_clock(Duration::from_millis(custom_delay_ms - 100));
2399 cx.editor(|editor, _, _| {
2400 assert!(
2401 editor.hover_state.visible(),
2402 "Popover should remain visible before the hiding delay expires"
2403 );
2404 });
2405
2406 // After the full custom delay, the popover should be hidden
2407 cx.background_executor
2408 .advance_clock(Duration::from_millis(200));
2409 cx.editor(|editor, _, _| {
2410 assert!(
2411 !editor.hover_state.visible(),
2412 "Popover should be hidden after the hiding delay expires"
2413 );
2414 });
2415 }
2416
2417 #[gpui::test]
2418 async fn test_hover_popover_sticky_disabled(cx: &mut gpui::TestAppContext) {
2419 init_test(cx, |_| {});
2420
2421 cx.update(|cx| {
2422 cx.update_global::<SettingsStore, _>(|settings, cx| {
2423 settings.update_user_settings(cx, |settings| {
2424 settings.editor.hover_popover_sticky = Some(false);
2425 });
2426 });
2427 });
2428
2429 let mut cx = EditorLspTestContext::new_rust(
2430 lsp::ServerCapabilities {
2431 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2432 ..Default::default()
2433 },
2434 cx,
2435 )
2436 .await;
2437
2438 cx.set_state(indoc! {"
2439 fn ˇtest() { println!(); }
2440 "});
2441
2442 // Trigger hover on a symbol
2443 let hover_point = cx.display_point(indoc! {"
2444 fn test() { printˇln!(); }
2445 "});
2446 let symbol_range = cx.lsp_range(indoc! {"
2447 fn test() { «println!»(); }
2448 "});
2449 let mut requests =
2450 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2451 Ok(Some(lsp::Hover {
2452 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2453 kind: lsp::MarkupKind::Markdown,
2454 value: "some basic docs".to_string(),
2455 }),
2456 range: Some(symbol_range),
2457 }))
2458 });
2459 cx.update_editor(|editor, window, cx| {
2460 let snapshot = editor.snapshot(window, cx);
2461 let anchor = snapshot
2462 .buffer_snapshot()
2463 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2464 hover_at(editor, Some(anchor), None, window, cx)
2465 });
2466 cx.background_executor
2467 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2468 requests.next().await;
2469
2470 // Hover should be visible
2471 cx.editor(|editor, _, _| {
2472 assert!(editor.hover_state.visible());
2473 });
2474
2475 // Move mouse away — with sticky disabled, hide immediately
2476 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2477
2478 // Popover should be hidden immediately without any delay
2479 cx.editor(|editor, _, _| {
2480 assert!(
2481 !editor.hover_state.visible(),
2482 "Popover should be hidden immediately when sticky is disabled"
2483 );
2484 });
2485 }
2486
2487 #[gpui::test]
2488 async fn test_hover_popover_hiding_delay_restarts_when_mouse_gets_closer(
2489 cx: &mut gpui::TestAppContext,
2490 ) {
2491 init_test(cx, |_| {});
2492
2493 let custom_delay_ms = 600u64;
2494 cx.update(|cx| {
2495 cx.update_global::<SettingsStore, _>(|settings, cx| {
2496 settings.update_user_settings(cx, |settings| {
2497 settings.editor.hover_popover_sticky = Some(true);
2498 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2499 });
2500 });
2501 });
2502
2503 let mut cx = EditorLspTestContext::new_rust(
2504 lsp::ServerCapabilities {
2505 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2506 ..Default::default()
2507 },
2508 cx,
2509 )
2510 .await;
2511
2512 cx.set_state(indoc! {"
2513 fn ˇtest() { println!(); }
2514 "});
2515
2516 let hover_point = cx.display_point(indoc! {"
2517 fn test() { printˇln!(); }
2518 "});
2519 let symbol_range = cx.lsp_range(indoc! {"
2520 fn test() { «println!»(); }
2521 "});
2522 let mut requests =
2523 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2524 Ok(Some(lsp::Hover {
2525 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2526 kind: lsp::MarkupKind::Markdown,
2527 value: "some basic docs".to_string(),
2528 }),
2529 range: Some(symbol_range),
2530 }))
2531 });
2532 cx.update_editor(|editor, window, cx| {
2533 let snapshot = editor.snapshot(window, cx);
2534 let anchor = snapshot
2535 .buffer_snapshot()
2536 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2537 hover_at(editor, Some(anchor), None, window, cx)
2538 });
2539 cx.background_executor
2540 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2541 requests.next().await;
2542
2543 cx.editor(|editor, _, _| {
2544 assert!(editor.hover_state.visible());
2545 });
2546
2547 cx.update_editor(|editor, _, _| {
2548 let popover = editor.hover_state.info_popovers.first().unwrap();
2549 popover.last_bounds.set(Some(Bounds {
2550 origin: gpui::Point {
2551 x: px(100.0),
2552 y: px(100.0),
2553 },
2554 size: Size {
2555 width: px(100.0),
2556 height: px(60.0),
2557 },
2558 }));
2559 });
2560
2561 let far_point = gpui::Point {
2562 x: px(260.0),
2563 y: px(130.0),
2564 };
2565 cx.update_editor(|editor, window, cx| hover_at(editor, None, Some(far_point), window, cx));
2566
2567 cx.background_executor
2568 .advance_clock(Duration::from_millis(400));
2569 cx.background_executor.run_until_parked();
2570
2571 let closer_point = gpui::Point {
2572 x: px(220.0),
2573 y: px(130.0),
2574 };
2575 cx.update_editor(|editor, window, cx| {
2576 hover_at(editor, None, Some(closer_point), window, cx)
2577 });
2578
2579 cx.background_executor
2580 .advance_clock(Duration::from_millis(250));
2581 cx.background_executor.run_until_parked();
2582
2583 cx.editor(|editor, _, _| {
2584 assert!(
2585 editor.hover_state.visible(),
2586 "Popover should remain visible because moving closer restarts the hiding timer"
2587 );
2588 });
2589
2590 cx.background_executor
2591 .advance_clock(Duration::from_millis(350));
2592 cx.background_executor.run_until_parked();
2593
2594 cx.editor(|editor, _, _| {
2595 assert!(
2596 !editor.hover_state.visible(),
2597 "Popover should hide after the restarted hiding timer expires"
2598 );
2599 });
2600 }
2601
2602 #[gpui::test]
2603 async fn test_hover_popover_cancel_hide_on_rehover(cx: &mut gpui::TestAppContext) {
2604 init_test(cx, |_| {});
2605
2606 let custom_delay_ms = 500u64;
2607 cx.update(|cx| {
2608 cx.update_global::<SettingsStore, _>(|settings, cx| {
2609 settings.update_user_settings(cx, |settings| {
2610 settings.editor.hover_popover_sticky = Some(true);
2611 settings.editor.hover_popover_hiding_delay = Some(DelayMs(custom_delay_ms));
2612 });
2613 });
2614 });
2615
2616 let mut cx = EditorLspTestContext::new_rust(
2617 lsp::ServerCapabilities {
2618 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2619 ..Default::default()
2620 },
2621 cx,
2622 )
2623 .await;
2624
2625 cx.set_state(indoc! {"
2626 fn ˇtest() { println!(); }
2627 "});
2628
2629 let hover_point = cx.display_point(indoc! {"
2630 fn test() { printˇln!(); }
2631 "});
2632 let symbol_range = cx.lsp_range(indoc! {"
2633 fn test() { «println!»(); }
2634 "});
2635 let mut requests =
2636 cx.set_request_handler::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
2637 Ok(Some(lsp::Hover {
2638 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
2639 kind: lsp::MarkupKind::Markdown,
2640 value: "some basic docs".to_string(),
2641 }),
2642 range: Some(symbol_range),
2643 }))
2644 });
2645 cx.update_editor(|editor, window, cx| {
2646 let snapshot = editor.snapshot(window, cx);
2647 let anchor = snapshot
2648 .buffer_snapshot()
2649 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2650 hover_at(editor, Some(anchor), None, window, cx)
2651 });
2652 cx.background_executor
2653 .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
2654 requests.next().await;
2655
2656 cx.editor(|editor, _, _| {
2657 assert!(editor.hover_state.visible());
2658 });
2659
2660 // Move mouse away — starts the 500ms hide timer
2661 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2662
2663 cx.background_executor
2664 .advance_clock(Duration::from_millis(300));
2665 cx.background_executor.run_until_parked();
2666 cx.editor(|editor, _, _| {
2667 assert!(
2668 editor.hover_state.visible(),
2669 "Popover should still be visible before hiding delay expires"
2670 );
2671 });
2672
2673 // Move back to the symbol — should cancel the hiding timer
2674 cx.update_editor(|editor, window, cx| {
2675 let snapshot = editor.snapshot(window, cx);
2676 let anchor = snapshot
2677 .buffer_snapshot()
2678 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2679 hover_at(editor, Some(anchor), None, window, cx)
2680 });
2681
2682 // Advance past the original deadline — popover should still be visible
2683 // because re-hovering cleared the hiding_delay_task
2684 cx.background_executor
2685 .advance_clock(Duration::from_millis(300));
2686 cx.background_executor.run_until_parked();
2687 cx.editor(|editor, _, _| {
2688 assert!(
2689 editor.hover_state.visible(),
2690 "Popover should remain visible after re-hovering the symbol"
2691 );
2692 assert!(
2693 editor.hover_state.hiding_delay_task.is_none(),
2694 "Hiding delay task should have been cleared by re-hover"
2695 );
2696 });
2697
2698 // Move away again — starts a fresh 500ms timer
2699 cx.update_editor(|editor, window, cx| hover_at(editor, None, None, window, cx));
2700
2701 cx.background_executor
2702 .advance_clock(Duration::from_millis(custom_delay_ms + 100));
2703 cx.background_executor.run_until_parked();
2704 cx.editor(|editor, _, _| {
2705 assert!(
2706 !editor.hover_state.visible(),
2707 "Popover should hide after the new hiding timer expires"
2708 );
2709 });
2710 }
2711
2712 #[gpui::test]
2713 async fn test_hover_popover_enabled_false_ignores_sticky(cx: &mut gpui::TestAppContext) {
2714 init_test(cx, |_| {});
2715
2716 cx.update(|cx| {
2717 cx.update_global::<SettingsStore, _>(|settings, cx| {
2718 settings.update_user_settings(cx, |settings| {
2719 settings.editor.hover_popover_enabled = Some(false);
2720 settings.editor.hover_popover_sticky = Some(true);
2721 settings.editor.hover_popover_hiding_delay = Some(DelayMs(500));
2722 });
2723 });
2724 });
2725
2726 let mut cx = EditorLspTestContext::new_rust(
2727 lsp::ServerCapabilities {
2728 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2729 ..Default::default()
2730 },
2731 cx,
2732 )
2733 .await;
2734
2735 cx.set_state(indoc! {"
2736 fn ˇtest() { println!(); }
2737 "});
2738
2739 let hover_point = cx.display_point(indoc! {"
2740 fn test() { printˇln!(); }
2741 "});
2742
2743 // Trigger hover_at — should be gated by hover_popover_enabled=false
2744 cx.update_editor(|editor, window, cx| {
2745 let snapshot = editor.snapshot(window, cx);
2746 let anchor = snapshot
2747 .buffer_snapshot()
2748 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
2749 hover_at(editor, Some(anchor), None, window, cx)
2750 });
2751
2752 // No need to advance clock or wait for LSP — the gate should prevent any work
2753 cx.editor(|editor, _, _| {
2754 assert!(
2755 !editor.hover_state.visible(),
2756 "Popover should not appear when hover_popover_enabled is false"
2757 );
2758 assert!(
2759 editor.hover_state.info_task.is_none(),
2760 "No hover info task should be scheduled when hover is disabled"
2761 );
2762 });
2763 }
2764
2765 #[test]
2766 fn test_parse_file_links() {
2767 assert_eq!(
2768 parse_file_link("file:///path/to/file"),
2769 Some((PathBuf::from("/path/to/file"), None))
2770 );
2771 assert_eq!(
2772 parse_file_link("file:///path/to/file%20with%20spaces"),
2773 Some((PathBuf::from("/path/to/file with spaces"), None))
2774 );
2775 assert_eq!(
2776 parse_file_link("file:///path/to/file#123"),
2777 Some((PathBuf::from("/path/to/file"), Some("123".to_string())))
2778 );
2779 assert_eq!(parse_file_link("http://example.com/"), None,);
2780 }
2781}
2782