1use crate::{
2 display_map::{InlayOffset, ToDisplayPoint},
3 link_go_to_definition::{DocumentRange, InlayRange},
4 Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSettings, EditorSnapshot, EditorStyle,
5 ExcerptId, RangeToAnchorExt,
6};
7use futures::FutureExt;
8use gpui::{
9 actions,
10 elements::{Flex, MouseEventHandler, Padding, ParentElement, Text},
11 fonts::{HighlightStyle, Underline, Weight},
12 platform::{CursorStyle, MouseButton},
13 AnyElement, AppContext, CursorRegion, Element, ModelHandle, MouseRegion, Task, ViewContext,
14};
15use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
16use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project};
17use std::{ops::Range, sync::Arc, time::Duration};
18use util::TryFutureExt;
19
20pub const HOVER_DELAY_MILLIS: u64 = 350;
21pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
22
23pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
24pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.;
25pub const HOVER_POPOVER_GAP: f32 = 10.;
26
27actions!(editor, [Hover]);
28
29pub fn init(cx: &mut AppContext) {
30 cx.add_action(hover);
31}
32
33/// Bindable action which uses the most recent selection head to trigger a hover
34pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
35 let head = editor.selections.newest_display(cx).head();
36 show_hover(editor, head, true, cx);
37}
38
39/// The internal hover action dispatches between `show_hover` or `hide_hover`
40/// depending on whether a point to hover over is provided.
41pub fn hover_at(editor: &mut Editor, point: Option<DisplayPoint>, cx: &mut ViewContext<Editor>) {
42 if settings::get::<EditorSettings>(cx).hover_popover_enabled {
43 if let Some(point) = point {
44 show_hover(editor, point, false, cx);
45 } else {
46 hide_hover(editor, cx);
47 }
48 }
49}
50
51pub struct InlayHover {
52 pub excerpt: ExcerptId,
53 pub triggered_from: InlayOffset,
54 pub range: InlayRange,
55 pub tooltip: HoverBlock,
56}
57
58pub fn find_hovered_hint_part(
59 label_parts: Vec<InlayHintLabelPart>,
60 hint_range: Range<InlayOffset>,
61 hovered_offset: InlayOffset,
62) -> Option<(InlayHintLabelPart, Range<InlayOffset>)> {
63 if hovered_offset >= hint_range.start && hovered_offset <= hint_range.end {
64 let mut hovered_character = (hovered_offset - hint_range.start).0;
65 let mut part_start = hint_range.start;
66 for part in label_parts {
67 let part_len = part.value.chars().count();
68 if hovered_character >= part_len {
69 hovered_character -= part_len;
70 part_start.0 += part_len;
71 } else {
72 return Some((part, part_start..InlayOffset(part_start.0 + part_len)));
73 }
74 }
75 }
76 None
77}
78
79pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut ViewContext<Editor>) {
80 if settings::get::<EditorSettings>(cx).hover_popover_enabled {
81 if editor.pending_rename.is_some() {
82 return;
83 }
84
85 let Some(project) = editor.project.clone() else {
86 return;
87 };
88
89 if let Some(InfoPopover { symbol_range, .. }) = &editor.hover_state.info_popover {
90 if let DocumentRange::Inlay(range) = symbol_range {
91 if (range.highlight_start..=range.highlight_end)
92 .contains(&inlay_hover.triggered_from)
93 {
94 // Hover triggered from same location as last time. Don't show again.
95 return;
96 }
97 }
98 hide_hover(editor, cx);
99 }
100
101 let snapshot = editor.snapshot(cx);
102 // Don't request again if the location is the same as the previous request
103 if let Some(triggered_from) = editor.hover_state.triggered_from {
104 if inlay_hover.triggered_from
105 == snapshot
106 .display_snapshot
107 .anchor_to_inlay_offset(triggered_from)
108 {
109 return;
110 }
111 }
112
113 let task = cx.spawn(|this, mut cx| {
114 async move {
115 cx.background()
116 .timer(Duration::from_millis(HOVER_DELAY_MILLIS))
117 .await;
118 this.update(&mut cx, |this, _| {
119 this.hover_state.diagnostic_popover = None;
120 })?;
121
122 let hover_popover = InfoPopover {
123 project: project.clone(),
124 symbol_range: DocumentRange::Inlay(inlay_hover.range),
125 blocks: vec![inlay_hover.tooltip],
126 language: None,
127 rendered_content: None,
128 };
129
130 this.update(&mut cx, |this, cx| {
131 // Highlight the selected symbol using a background highlight
132 this.highlight_inlay_background::<HoverState>(
133 vec![inlay_hover.range],
134 |theme| theme.editor.hover_popover.highlight,
135 cx,
136 );
137 this.hover_state.info_popover = Some(hover_popover);
138 cx.notify();
139 })?;
140
141 anyhow::Ok(())
142 }
143 .log_err()
144 });
145
146 editor.hover_state.info_task = Some(task);
147 }
148}
149
150/// Hides the type information popup.
151/// Triggered by the `Hover` action when the cursor is not over a symbol or when the
152/// selections changed.
153pub fn hide_hover(editor: &mut Editor, cx: &mut ViewContext<Editor>) -> bool {
154 let did_hide = editor.hover_state.info_popover.take().is_some()
155 | editor.hover_state.diagnostic_popover.take().is_some();
156
157 editor.hover_state.info_task = None;
158 editor.hover_state.triggered_from = None;
159
160 editor.clear_background_highlights::<HoverState>(cx);
161
162 if did_hide {
163 cx.notify();
164 }
165
166 did_hide
167}
168
169/// Queries the LSP and shows type info and documentation
170/// about the symbol the mouse is currently hovering over.
171/// Triggered by the `Hover` action when the cursor may be over a symbol.
172fn show_hover(
173 editor: &mut Editor,
174 point: DisplayPoint,
175 ignore_timeout: bool,
176 cx: &mut ViewContext<Editor>,
177) {
178 if editor.pending_rename.is_some() {
179 return;
180 }
181
182 let snapshot = editor.snapshot(cx);
183 let multibuffer_offset = point.to_offset(&snapshot.display_snapshot, Bias::Left);
184
185 let (buffer, buffer_position) = if let Some(output) = editor
186 .buffer
187 .read(cx)
188 .text_anchor_for_position(multibuffer_offset, cx)
189 {
190 output
191 } else {
192 return;
193 };
194
195 let excerpt_id = if let Some((excerpt_id, _, _)) = editor
196 .buffer()
197 .read(cx)
198 .excerpt_containing(multibuffer_offset, cx)
199 {
200 excerpt_id
201 } else {
202 return;
203 };
204
205 let project = if let Some(project) = editor.project.clone() {
206 project
207 } else {
208 return;
209 };
210
211 if !ignore_timeout {
212 if let Some(InfoPopover { symbol_range, .. }) = &editor.hover_state.info_popover {
213 if symbol_range
214 .as_text_range()
215 .map(|range| {
216 range
217 .to_offset(&snapshot.buffer_snapshot)
218 .contains(&multibuffer_offset)
219 })
220 .unwrap_or(false)
221 {
222 // Hover triggered from same location as last time. Don't show again.
223 return;
224 } else {
225 hide_hover(editor, cx);
226 }
227 }
228 }
229
230 // Get input anchor
231 let anchor = snapshot
232 .buffer_snapshot
233 .anchor_at(multibuffer_offset, Bias::Left);
234
235 // Don't request again if the location is the same as the previous request
236 if let Some(triggered_from) = &editor.hover_state.triggered_from {
237 if triggered_from
238 .cmp(&anchor, &snapshot.buffer_snapshot)
239 .is_eq()
240 {
241 return;
242 }
243 }
244
245 let task = cx.spawn(|this, mut cx| {
246 async move {
247 // If we need to delay, delay a set amount initially before making the lsp request
248 let delay = if !ignore_timeout {
249 // Construct delay task to wait for later
250 let total_delay = Some(
251 cx.background()
252 .timer(Duration::from_millis(HOVER_DELAY_MILLIS)),
253 );
254
255 cx.background()
256 .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS))
257 .await;
258 total_delay
259 } else {
260 None
261 };
262
263 // query the LSP for hover info
264 let hover_request = cx.update(|cx| {
265 project.update(cx, |project, cx| {
266 project.hover(&buffer, buffer_position, cx)
267 })
268 });
269
270 if let Some(delay) = delay {
271 delay.await;
272 }
273
274 // If there's a diagnostic, assign it on the hover state and notify
275 let local_diagnostic = snapshot
276 .buffer_snapshot
277 .diagnostics_in_range::<_, usize>(multibuffer_offset..multibuffer_offset, false)
278 // Find the entry with the most specific range
279 .min_by_key(|entry| entry.range.end - entry.range.start)
280 .map(|entry| DiagnosticEntry {
281 diagnostic: entry.diagnostic,
282 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
283 });
284
285 // Pull the primary diagnostic out so we can jump to it if the popover is clicked
286 let primary_diagnostic = local_diagnostic.as_ref().and_then(|local_diagnostic| {
287 snapshot
288 .buffer_snapshot
289 .diagnostic_group::<usize>(local_diagnostic.diagnostic.group_id)
290 .find(|diagnostic| diagnostic.diagnostic.is_primary)
291 .map(|entry| DiagnosticEntry {
292 diagnostic: entry.diagnostic,
293 range: entry.range.to_anchors(&snapshot.buffer_snapshot),
294 })
295 });
296
297 this.update(&mut cx, |this, _| {
298 this.hover_state.diagnostic_popover =
299 local_diagnostic.map(|local_diagnostic| DiagnosticPopover {
300 local_diagnostic,
301 primary_diagnostic,
302 });
303 })?;
304
305 // Construct new hover popover from hover request
306 let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
307 if hover_result.is_empty() {
308 return None;
309 }
310
311 // Create symbol range of anchors for highlighting and filtering
312 // of future requests.
313 let range = if let Some(range) = hover_result.range {
314 let start = snapshot
315 .buffer_snapshot
316 .anchor_in_excerpt(excerpt_id.clone(), range.start);
317 let end = snapshot
318 .buffer_snapshot
319 .anchor_in_excerpt(excerpt_id.clone(), range.end);
320
321 start..end
322 } else {
323 anchor..anchor
324 };
325
326 Some(InfoPopover {
327 project: project.clone(),
328 symbol_range: DocumentRange::Text(range),
329 blocks: hover_result.contents,
330 language: hover_result.language,
331 rendered_content: None,
332 })
333 });
334
335 this.update(&mut cx, |this, cx| {
336 if let Some(symbol_range) = hover_popover
337 .as_ref()
338 .and_then(|hover_popover| hover_popover.symbol_range.as_text_range())
339 {
340 // Highlight the selected symbol using a background highlight
341 this.highlight_background::<HoverState>(
342 vec![symbol_range],
343 |theme| theme.editor.hover_popover.highlight,
344 cx,
345 );
346 } else {
347 this.clear_background_highlights::<HoverState>(cx);
348 }
349
350 this.hover_state.info_popover = hover_popover;
351 cx.notify();
352 })?;
353
354 Ok::<_, anyhow::Error>(())
355 }
356 .log_err()
357 });
358
359 editor.hover_state.info_task = Some(task);
360}
361
362fn render_blocks(
363 theme_id: usize,
364 blocks: &[HoverBlock],
365 language_registry: &Arc<LanguageRegistry>,
366 language: Option<&Arc<Language>>,
367 style: &EditorStyle,
368) -> RenderedInfo {
369 let mut text = String::new();
370 let mut highlights = Vec::new();
371 let mut region_ranges = Vec::new();
372 let mut regions = Vec::new();
373
374 for block in blocks {
375 match &block.kind {
376 HoverBlockKind::PlainText => {
377 new_paragraph(&mut text, &mut Vec::new());
378 text.push_str(&block.text);
379 }
380 HoverBlockKind::Markdown => {
381 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
382
383 let mut bold_depth = 0;
384 let mut italic_depth = 0;
385 let mut link_url = None;
386 let mut current_language = None;
387 let mut list_stack = Vec::new();
388
389 for event in Parser::new_ext(&block.text, Options::all()) {
390 let prev_len = text.len();
391 match event {
392 Event::Text(t) => {
393 if let Some(language) = ¤t_language {
394 render_code(
395 &mut text,
396 &mut highlights,
397 t.as_ref(),
398 language,
399 style,
400 );
401 } else {
402 text.push_str(t.as_ref());
403
404 let mut style = HighlightStyle::default();
405 if bold_depth > 0 {
406 style.weight = Some(Weight::BOLD);
407 }
408 if italic_depth > 0 {
409 style.italic = Some(true);
410 }
411 if let Some(link_url) = link_url.clone() {
412 region_ranges.push(prev_len..text.len());
413 regions.push(RenderedRegion {
414 link_url: Some(link_url),
415 code: false,
416 });
417 style.underline = Some(Underline {
418 thickness: 1.0.into(),
419 ..Default::default()
420 });
421 }
422
423 if style != HighlightStyle::default() {
424 let mut new_highlight = true;
425 if let Some((last_range, last_style)) = highlights.last_mut() {
426 if last_range.end == prev_len && last_style == &style {
427 last_range.end = text.len();
428 new_highlight = false;
429 }
430 }
431 if new_highlight {
432 highlights.push((prev_len..text.len(), style));
433 }
434 }
435 }
436 }
437 Event::Code(t) => {
438 text.push_str(t.as_ref());
439 region_ranges.push(prev_len..text.len());
440 if link_url.is_some() {
441 highlights.push((
442 prev_len..text.len(),
443 HighlightStyle {
444 underline: Some(Underline {
445 thickness: 1.0.into(),
446 ..Default::default()
447 }),
448 ..Default::default()
449 },
450 ));
451 }
452 regions.push(RenderedRegion {
453 code: true,
454 link_url: link_url.clone(),
455 });
456 }
457 Event::Start(tag) => match tag {
458 Tag::Paragraph => new_paragraph(&mut text, &mut list_stack),
459 Tag::Heading(_, _, _) => {
460 new_paragraph(&mut text, &mut list_stack);
461 bold_depth += 1;
462 }
463 Tag::CodeBlock(kind) => {
464 new_paragraph(&mut text, &mut list_stack);
465 current_language = if let CodeBlockKind::Fenced(language) = kind {
466 language_registry
467 .language_for_name(language.as_ref())
468 .now_or_never()
469 .and_then(Result::ok)
470 } else {
471 language.cloned()
472 }
473 }
474 Tag::Emphasis => italic_depth += 1,
475 Tag::Strong => bold_depth += 1,
476 Tag::Link(_, url, _) => link_url = Some(url.to_string()),
477 Tag::List(number) => {
478 list_stack.push((number, false));
479 }
480 Tag::Item => {
481 let len = list_stack.len();
482 if let Some((list_number, has_content)) = list_stack.last_mut() {
483 *has_content = false;
484 if !text.is_empty() && !text.ends_with('\n') {
485 text.push('\n');
486 }
487 for _ in 0..len - 1 {
488 text.push_str(" ");
489 }
490 if let Some(number) = list_number {
491 text.push_str(&format!("{}. ", number));
492 *number += 1;
493 *has_content = false;
494 } else {
495 text.push_str("- ");
496 }
497 }
498 }
499 _ => {}
500 },
501 Event::End(tag) => match tag {
502 Tag::Heading(_, _, _) => bold_depth -= 1,
503 Tag::CodeBlock(_) => current_language = None,
504 Tag::Emphasis => italic_depth -= 1,
505 Tag::Strong => bold_depth -= 1,
506 Tag::Link(_, _, _) => link_url = None,
507 Tag::List(_) => drop(list_stack.pop()),
508 _ => {}
509 },
510 Event::HardBreak => text.push('\n'),
511 Event::SoftBreak => text.push(' '),
512 _ => {}
513 }
514 }
515 }
516 HoverBlockKind::Code { language } => {
517 if let Some(language) = language_registry
518 .language_for_name(language)
519 .now_or_never()
520 .and_then(Result::ok)
521 {
522 render_code(&mut text, &mut highlights, &block.text, &language, style);
523 } else {
524 text.push_str(&block.text);
525 }
526 }
527 }
528 }
529
530 RenderedInfo {
531 theme_id,
532 text: text.trim().to_string(),
533 highlights,
534 region_ranges,
535 regions,
536 }
537}
538
539fn render_code(
540 text: &mut String,
541 highlights: &mut Vec<(Range<usize>, HighlightStyle)>,
542 content: &str,
543 language: &Arc<Language>,
544 style: &EditorStyle,
545) {
546 let prev_len = text.len();
547 text.push_str(content);
548 for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
549 if let Some(style) = highlight_id.style(&style.syntax) {
550 highlights.push((prev_len + range.start..prev_len + range.end, style));
551 }
552 }
553}
554
555fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
556 let mut is_subsequent_paragraph_of_list = false;
557 if let Some((_, has_content)) = list_stack.last_mut() {
558 if *has_content {
559 is_subsequent_paragraph_of_list = true;
560 } else {
561 *has_content = true;
562 return;
563 }
564 }
565
566 if !text.is_empty() {
567 if !text.ends_with('\n') {
568 text.push('\n');
569 }
570 text.push('\n');
571 }
572 for _ in 0..list_stack.len().saturating_sub(1) {
573 text.push_str(" ");
574 }
575 if is_subsequent_paragraph_of_list {
576 text.push_str(" ");
577 }
578}
579
580#[derive(Default)]
581pub struct HoverState {
582 pub info_popover: Option<InfoPopover>,
583 pub diagnostic_popover: Option<DiagnosticPopover>,
584 pub triggered_from: Option<Anchor>,
585 pub info_task: Option<Task<Option<()>>>,
586}
587
588impl HoverState {
589 pub fn visible(&self) -> bool {
590 self.info_popover.is_some() || self.diagnostic_popover.is_some()
591 }
592
593 pub fn render(
594 &mut self,
595 snapshot: &EditorSnapshot,
596 style: &EditorStyle,
597 visible_rows: Range<u32>,
598 cx: &mut ViewContext<Editor>,
599 ) -> Option<(DisplayPoint, Vec<AnyElement<Editor>>)> {
600 // If there is a diagnostic, position the popovers based on that.
601 // Otherwise use the start of the hover range
602 let anchor = self
603 .diagnostic_popover
604 .as_ref()
605 .map(|diagnostic_popover| &diagnostic_popover.local_diagnostic.range.start)
606 .or_else(|| {
607 self.info_popover
608 .as_ref()
609 .map(|info_popover| match &info_popover.symbol_range {
610 DocumentRange::Text(range) => &range.start,
611 DocumentRange::Inlay(range) => &range.inlay_position,
612 })
613 })?;
614 let point = anchor.to_display_point(&snapshot.display_snapshot);
615
616 // Don't render if the relevant point isn't on screen
617 if !self.visible() || !visible_rows.contains(&point.row()) {
618 return None;
619 }
620
621 let mut elements = Vec::new();
622
623 if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
624 elements.push(diagnostic_popover.render(style, cx));
625 }
626 if let Some(info_popover) = self.info_popover.as_mut() {
627 elements.push(info_popover.render(style, cx));
628 }
629
630 Some((point, elements))
631 }
632}
633
634#[derive(Debug, Clone)]
635pub struct InfoPopover {
636 pub project: ModelHandle<Project>,
637 symbol_range: DocumentRange,
638 pub blocks: Vec<HoverBlock>,
639 language: Option<Arc<Language>>,
640 rendered_content: Option<RenderedInfo>,
641}
642
643#[derive(Debug, Clone)]
644struct RenderedInfo {
645 theme_id: usize,
646 text: String,
647 highlights: Vec<(Range<usize>, HighlightStyle)>,
648 region_ranges: Vec<Range<usize>>,
649 regions: Vec<RenderedRegion>,
650}
651
652#[derive(Debug, Clone)]
653struct RenderedRegion {
654 code: bool,
655 link_url: Option<String>,
656}
657
658impl InfoPopover {
659 pub fn render(
660 &mut self,
661 style: &EditorStyle,
662 cx: &mut ViewContext<Editor>,
663 ) -> AnyElement<Editor> {
664 if let Some(rendered) = &self.rendered_content {
665 if rendered.theme_id != style.theme_id {
666 self.rendered_content = None;
667 }
668 }
669
670 let rendered_content = self.rendered_content.get_or_insert_with(|| {
671 render_blocks(
672 style.theme_id,
673 &self.blocks,
674 self.project.read(cx).languages(),
675 self.language.as_ref(),
676 style,
677 )
678 });
679
680 MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
681 let mut region_id = 0;
682 let view_id = cx.view_id();
683
684 let code_span_background_color = style.document_highlight_read_background;
685 let regions = rendered_content.regions.clone();
686 Flex::column()
687 .scrollable::<HoverBlock>(1, None, cx)
688 .with_child(
689 Text::new(rendered_content.text.clone(), style.text.clone())
690 .with_highlights(rendered_content.highlights.clone())
691 .with_custom_runs(
692 rendered_content.region_ranges.clone(),
693 move |ix, bounds, scene, _| {
694 region_id += 1;
695 let region = regions[ix].clone();
696 if let Some(url) = region.link_url {
697 scene.push_cursor_region(CursorRegion {
698 bounds,
699 style: CursorStyle::PointingHand,
700 });
701 scene.push_mouse_region(
702 MouseRegion::new::<Self>(view_id, region_id, bounds)
703 .on_click::<Editor, _>(
704 MouseButton::Left,
705 move |_, _, cx| cx.platform().open_url(&url),
706 ),
707 );
708 }
709 if region.code {
710 scene.push_quad(gpui::Quad {
711 bounds,
712 background: Some(code_span_background_color),
713 border: Default::default(),
714 corner_radii: (2.0).into(),
715 });
716 }
717 },
718 )
719 .with_soft_wrap(true),
720 )
721 .contained()
722 .with_style(style.hover_popover.container)
723 })
724 .on_move(|_, _, _| {}) // Consume move events so they don't reach regions underneath.
725 .with_cursor_style(CursorStyle::Arrow)
726 .with_padding(Padding {
727 bottom: HOVER_POPOVER_GAP,
728 top: HOVER_POPOVER_GAP,
729 ..Default::default()
730 })
731 .into_any()
732 }
733}
734
735#[derive(Debug, Clone)]
736pub struct DiagnosticPopover {
737 local_diagnostic: DiagnosticEntry<Anchor>,
738 primary_diagnostic: Option<DiagnosticEntry<Anchor>>,
739}
740
741impl DiagnosticPopover {
742 pub fn render(&self, style: &EditorStyle, cx: &mut ViewContext<Editor>) -> AnyElement<Editor> {
743 enum PrimaryDiagnostic {}
744
745 let mut text_style = style.hover_popover.prose.clone();
746 text_style.font_size = style.text.font_size;
747 let diagnostic_source_style = style.hover_popover.diagnostic_source_highlight.clone();
748
749 let text = match &self.local_diagnostic.diagnostic.source {
750 Some(source) => Text::new(
751 format!("{source}: {}", self.local_diagnostic.diagnostic.message),
752 text_style,
753 )
754 .with_highlights(vec![(0..source.len(), diagnostic_source_style)]),
755
756 None => Text::new(self.local_diagnostic.diagnostic.message.clone(), text_style),
757 };
758
759 let container_style = match self.local_diagnostic.diagnostic.severity {
760 DiagnosticSeverity::HINT => style.hover_popover.info_container,
761 DiagnosticSeverity::INFORMATION => style.hover_popover.info_container,
762 DiagnosticSeverity::WARNING => style.hover_popover.warning_container,
763 DiagnosticSeverity::ERROR => style.hover_popover.error_container,
764 _ => style.hover_popover.container,
765 };
766
767 let tooltip_style = theme::current(cx).tooltip.clone();
768
769 MouseEventHandler::new::<DiagnosticPopover, _>(0, cx, |_, _| {
770 text.with_soft_wrap(true)
771 .contained()
772 .with_style(container_style)
773 })
774 .with_padding(Padding {
775 top: HOVER_POPOVER_GAP,
776 bottom: HOVER_POPOVER_GAP,
777 ..Default::default()
778 })
779 .on_move(|_, _, _| {}) // Consume move events so they don't reach regions underneath.
780 .on_click(MouseButton::Left, |_, this, cx| {
781 this.go_to_diagnostic(&Default::default(), cx)
782 })
783 .with_cursor_style(CursorStyle::PointingHand)
784 .with_tooltip::<PrimaryDiagnostic>(
785 0,
786 "Go To Diagnostic".to_string(),
787 Some(Box::new(crate::GoToDiagnostic)),
788 tooltip_style,
789 cx,
790 )
791 .into_any()
792 }
793
794 pub fn activation_info(&self) -> (usize, Anchor) {
795 let entry = self
796 .primary_diagnostic
797 .as_ref()
798 .unwrap_or(&self.local_diagnostic);
799
800 (entry.diagnostic.group_id, entry.range.start.clone())
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
808 use gpui::fonts::Weight;
809 use indoc::indoc;
810 use language::{Diagnostic, DiagnosticSet};
811 use lsp::LanguageServerId;
812 use project::{HoverBlock, HoverBlockKind};
813 use smol::stream::StreamExt;
814 use unindent::Unindent;
815 use util::test::marked_text_ranges;
816
817 #[gpui::test]
818 async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
819 init_test(cx, |_| {});
820
821 let mut cx = EditorLspTestContext::new_rust(
822 lsp::ServerCapabilities {
823 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
824 ..Default::default()
825 },
826 cx,
827 )
828 .await;
829
830 // Basic hover delays and then pops without moving the mouse
831 cx.set_state(indoc! {"
832 fn ˇtest() { println!(); }
833 "});
834 let hover_point = cx.display_point(indoc! {"
835 fn test() { printˇln!(); }
836 "});
837
838 cx.update_editor(|editor, cx| hover_at(editor, Some(hover_point), cx));
839 assert!(!cx.editor(|editor, _| editor.hover_state.visible()));
840
841 // After delay, hover should be visible.
842 let symbol_range = cx.lsp_range(indoc! {"
843 fn test() { «println!»(); }
844 "});
845 let mut requests =
846 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
847 Ok(Some(lsp::Hover {
848 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
849 kind: lsp::MarkupKind::Markdown,
850 value: "some basic docs".to_string(),
851 }),
852 range: Some(symbol_range),
853 }))
854 });
855 cx.foreground()
856 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
857 requests.next().await;
858
859 cx.editor(|editor, _| {
860 assert!(editor.hover_state.visible());
861 assert_eq!(
862 editor.hover_state.info_popover.clone().unwrap().blocks,
863 vec![HoverBlock {
864 text: "some basic docs".to_string(),
865 kind: HoverBlockKind::Markdown,
866 },]
867 )
868 });
869
870 // Mouse moved with no hover response dismisses
871 let hover_point = cx.display_point(indoc! {"
872 fn teˇst() { println!(); }
873 "});
874 let mut request = cx
875 .lsp
876 .handle_request::<lsp::request::HoverRequest, _, _>(|_, _| async move { Ok(None) });
877 cx.update_editor(|editor, cx| hover_at(editor, Some(hover_point), cx));
878 cx.foreground()
879 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
880 request.next().await;
881 cx.editor(|editor, _| {
882 assert!(!editor.hover_state.visible());
883 });
884 }
885
886 #[gpui::test]
887 async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
888 init_test(cx, |_| {});
889
890 let mut cx = EditorLspTestContext::new_rust(
891 lsp::ServerCapabilities {
892 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
893 ..Default::default()
894 },
895 cx,
896 )
897 .await;
898
899 // Hover with keyboard has no delay
900 cx.set_state(indoc! {"
901 fˇn test() { println!(); }
902 "});
903 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
904 let symbol_range = cx.lsp_range(indoc! {"
905 «fn» test() { println!(); }
906 "});
907 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
908 Ok(Some(lsp::Hover {
909 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
910 kind: lsp::MarkupKind::Markdown,
911 value: "some other basic docs".to_string(),
912 }),
913 range: Some(symbol_range),
914 }))
915 })
916 .next()
917 .await;
918
919 cx.condition(|editor, _| editor.hover_state.visible()).await;
920 cx.editor(|editor, _| {
921 assert_eq!(
922 editor.hover_state.info_popover.clone().unwrap().blocks,
923 vec![HoverBlock {
924 text: "some other basic docs".to_string(),
925 kind: HoverBlockKind::Markdown,
926 }]
927 )
928 });
929 }
930
931 #[gpui::test]
932 async fn test_empty_hovers_filtered(cx: &mut gpui::TestAppContext) {
933 init_test(cx, |_| {});
934
935 let mut cx = EditorLspTestContext::new_rust(
936 lsp::ServerCapabilities {
937 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
938 ..Default::default()
939 },
940 cx,
941 )
942 .await;
943
944 // Hover with keyboard has no delay
945 cx.set_state(indoc! {"
946 fˇn test() { println!(); }
947 "});
948 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
949 let symbol_range = cx.lsp_range(indoc! {"
950 «fn» test() { println!(); }
951 "});
952 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
953 Ok(Some(lsp::Hover {
954 contents: lsp::HoverContents::Array(vec![
955 lsp::MarkedString::String("regular text for hover to show".to_string()),
956 lsp::MarkedString::String("".to_string()),
957 lsp::MarkedString::LanguageString(lsp::LanguageString {
958 language: "Rust".to_string(),
959 value: "".to_string(),
960 }),
961 ]),
962 range: Some(symbol_range),
963 }))
964 })
965 .next()
966 .await;
967
968 cx.condition(|editor, _| editor.hover_state.visible()).await;
969 cx.editor(|editor, _| {
970 assert_eq!(
971 editor.hover_state.info_popover.clone().unwrap().blocks,
972 vec![HoverBlock {
973 text: "regular text for hover to show".to_string(),
974 kind: HoverBlockKind::Markdown,
975 }],
976 "No empty string hovers should be shown"
977 );
978 });
979 }
980
981 #[gpui::test]
982 async fn test_line_ends_trimmed(cx: &mut gpui::TestAppContext) {
983 init_test(cx, |_| {});
984
985 let mut cx = EditorLspTestContext::new_rust(
986 lsp::ServerCapabilities {
987 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
988 ..Default::default()
989 },
990 cx,
991 )
992 .await;
993
994 // Hover with keyboard has no delay
995 cx.set_state(indoc! {"
996 fˇn test() { println!(); }
997 "});
998 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
999 let symbol_range = cx.lsp_range(indoc! {"
1000 «fn» test() { println!(); }
1001 "});
1002
1003 let code_str = "\nlet hovered_point: Vector2F // size = 8, align = 0x4\n";
1004 let markdown_string = format!("\n```rust\n{code_str}```");
1005
1006 let closure_markdown_string = markdown_string.clone();
1007 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| {
1008 let future_markdown_string = closure_markdown_string.clone();
1009 async move {
1010 Ok(Some(lsp::Hover {
1011 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1012 kind: lsp::MarkupKind::Markdown,
1013 value: future_markdown_string,
1014 }),
1015 range: Some(symbol_range),
1016 }))
1017 }
1018 })
1019 .next()
1020 .await;
1021
1022 cx.condition(|editor, _| editor.hover_state.visible()).await;
1023 cx.editor(|editor, cx| {
1024 let blocks = editor.hover_state.info_popover.clone().unwrap().blocks;
1025 assert_eq!(
1026 blocks,
1027 vec![HoverBlock {
1028 text: markdown_string,
1029 kind: HoverBlockKind::Markdown,
1030 }],
1031 );
1032
1033 let style = editor.style(cx);
1034 let rendered = render_blocks(0, &blocks, &Default::default(), None, &style);
1035 assert_eq!(
1036 rendered.text,
1037 code_str.trim(),
1038 "Should not have extra line breaks at end of rendered hover"
1039 );
1040 });
1041 }
1042
1043 #[gpui::test]
1044 async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
1045 init_test(cx, |_| {});
1046
1047 let mut cx = EditorLspTestContext::new_rust(
1048 lsp::ServerCapabilities {
1049 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1050 ..Default::default()
1051 },
1052 cx,
1053 )
1054 .await;
1055
1056 // Hover with just diagnostic, pops DiagnosticPopover immediately and then
1057 // info popover once request completes
1058 cx.set_state(indoc! {"
1059 fn teˇst() { println!(); }
1060 "});
1061
1062 // Send diagnostic to client
1063 let range = cx.text_anchor_range(indoc! {"
1064 fn «test»() { println!(); }
1065 "});
1066 cx.update_buffer(|buffer, cx| {
1067 let snapshot = buffer.text_snapshot();
1068 let set = DiagnosticSet::from_sorted_entries(
1069 vec![DiagnosticEntry {
1070 range,
1071 diagnostic: Diagnostic {
1072 message: "A test diagnostic message.".to_string(),
1073 ..Default::default()
1074 },
1075 }],
1076 &snapshot,
1077 );
1078 buffer.update_diagnostics(LanguageServerId(0), set, cx);
1079 });
1080
1081 // Hover pops diagnostic immediately
1082 cx.update_editor(|editor, cx| hover(editor, &Hover, cx));
1083 cx.foreground().run_until_parked();
1084
1085 cx.editor(|Editor { hover_state, .. }, _| {
1086 assert!(hover_state.diagnostic_popover.is_some() && hover_state.info_popover.is_none())
1087 });
1088
1089 // Info Popover shows after request responded to
1090 let range = cx.lsp_range(indoc! {"
1091 fn «test»() { println!(); }
1092 "});
1093 cx.handle_request::<lsp::request::HoverRequest, _, _>(move |_, _, _| async move {
1094 Ok(Some(lsp::Hover {
1095 contents: lsp::HoverContents::Markup(lsp::MarkupContent {
1096 kind: lsp::MarkupKind::Markdown,
1097 value: "some new docs".to_string(),
1098 }),
1099 range: Some(range),
1100 }))
1101 });
1102 cx.foreground()
1103 .advance_clock(Duration::from_millis(HOVER_DELAY_MILLIS + 100));
1104
1105 cx.foreground().run_until_parked();
1106 cx.editor(|Editor { hover_state, .. }, _| {
1107 hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some()
1108 });
1109 }
1110
1111 #[gpui::test]
1112 fn test_render_blocks(cx: &mut gpui::TestAppContext) {
1113 init_test(cx, |_| {});
1114
1115 cx.add_window(|cx| {
1116 let editor = Editor::single_line(None, cx);
1117 let style = editor.style(cx);
1118
1119 struct Row {
1120 blocks: Vec<HoverBlock>,
1121 expected_marked_text: String,
1122 expected_styles: Vec<HighlightStyle>,
1123 }
1124
1125 let rows = &[
1126 // Strong emphasis
1127 Row {
1128 blocks: vec![HoverBlock {
1129 text: "one **two** three".to_string(),
1130 kind: HoverBlockKind::Markdown,
1131 }],
1132 expected_marked_text: "one «two» three".to_string(),
1133 expected_styles: vec![HighlightStyle {
1134 weight: Some(Weight::BOLD),
1135 ..Default::default()
1136 }],
1137 },
1138 // Links
1139 Row {
1140 blocks: vec three".to_string(),
1142 kind: HoverBlockKind::Markdown,
1143 }],
1144 expected_marked_text: "one «two» three".to_string(),
1145 expected_styles: vec![HighlightStyle {
1146 underline: Some(Underline {
1147 thickness: 1.0.into(),
1148 ..Default::default()
1149 }),
1150 ..Default::default()
1151 }],
1152 },
1153 // Lists
1154 Row {
1155 blocks: vec
1163 - d"
1164 .unindent(),
1165 kind: HoverBlockKind::Markdown,
1166 }],
1167 expected_marked_text: "
1168 lists:
1169 - one
1170 - a
1171 - b
1172 - two
1173 - «c»
1174 - d"
1175 .unindent(),
1176 expected_styles: vec![HighlightStyle {
1177 underline: Some(Underline {
1178 thickness: 1.0.into(),
1179 ..Default::default()
1180 }),
1181 ..Default::default()
1182 }],
1183 },
1184 // Multi-paragraph list items
1185 Row {
1186 blocks: vec![HoverBlock {
1187 text: "
1188 * one two
1189 three
1190
1191 * four five
1192 * six seven
1193 eight
1194
1195 nine
1196 * ten
1197 * six"
1198 .unindent(),
1199 kind: HoverBlockKind::Markdown,
1200 }],
1201 expected_marked_text: "
1202 - one two three
1203 - four five
1204 - six seven eight
1205
1206 nine
1207 - ten
1208 - six"
1209 .unindent(),
1210 expected_styles: vec![HighlightStyle {
1211 underline: Some(Underline {
1212 thickness: 1.0.into(),
1213 ..Default::default()
1214 }),
1215 ..Default::default()
1216 }],
1217 },
1218 ];
1219
1220 for Row {
1221 blocks,
1222 expected_marked_text,
1223 expected_styles,
1224 } in &rows[0..]
1225 {
1226 let rendered = render_blocks(0, &blocks, &Default::default(), None, &style);
1227
1228 let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false);
1229 let expected_highlights = ranges
1230 .into_iter()
1231 .zip(expected_styles.iter().cloned())
1232 .collect::<Vec<_>>();
1233 assert_eq!(
1234 rendered.text, expected_text,
1235 "wrong text for input {blocks:?}"
1236 );
1237 assert_eq!(
1238 rendered.highlights, expected_highlights,
1239 "wrong highlights for input {blocks:?}"
1240 );
1241 }
1242
1243 editor
1244 });
1245 }
1246}