Selectable diagnostic popover text (#14518)
Release Notes: - Added the ability to select and copy text from diagnostic popovers - Fixed #12695 https://github.com/user-attachments/assets/b896bacf-ecbc-48f5-8228-a3821f0b1a4e --------- Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
parent
583b6235fb
commit
78a2539d59
4 changed files with 206 additions and 84 deletions
|
@ -2718,14 +2718,9 @@ impl EditorElement {
|
|||
);
|
||||
|
||||
let hover_popovers = self.editor.update(cx, |editor, cx| {
|
||||
editor.hover_state.render(
|
||||
&snapshot,
|
||||
&self.style,
|
||||
visible_display_row_range.clone(),
|
||||
max_size,
|
||||
editor.workspace.as_ref().map(|(w, _)| w.clone()),
|
||||
cx,
|
||||
)
|
||||
editor
|
||||
.hover_state
|
||||
.render(&snapshot, visible_display_row_range.clone(), max_size, cx)
|
||||
});
|
||||
let Some((position, hover_popovers)) = hover_popovers else {
|
||||
return;
|
||||
|
|
|
@ -3,13 +3,12 @@ use crate::{
|
|||
hover_links::{InlayHighlight, RangeInEditor},
|
||||
scroll::ScrollAmount,
|
||||
Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
|
||||
EditorStyle, Hover, RangeToAnchorExt,
|
||||
Hover, RangeToAnchorExt,
|
||||
};
|
||||
use gpui::{
|
||||
div, px, AnyElement, AsyncWindowContext, CursorStyle, FontWeight, Hsla, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, SharedString, Size,
|
||||
StatefulInteractiveElement, StyleRefinement, Styled, Task, TextStyleRefinement, View,
|
||||
ViewContext, WeakView,
|
||||
div, px, AnyElement, AsyncWindowContext, FontWeight, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, ScrollHandle, Size, StatefulInteractiveElement,
|
||||
StyleRefinement, Styled, Task, TextStyleRefinement, View, ViewContext,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{DiagnosticEntry, Language, LanguageRegistry};
|
||||
|
@ -22,9 +21,8 @@ use std::rc::Rc;
|
|||
use std::{borrow::Cow, cell::RefCell};
|
||||
use std::{ops::Range, sync::Arc, time::Duration};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, window_is_transparent, Tooltip};
|
||||
use ui::{prelude::*, window_is_transparent};
|
||||
use util::TryFutureExt;
|
||||
use workspace::Workspace;
|
||||
pub const HOVER_DELAY_MILLIS: u64 = 350;
|
||||
pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
|
||||
|
||||
|
@ -64,6 +62,18 @@ pub fn show_keyboard_hover(editor: &mut Editor, cx: &mut ViewContext<Editor>) ->
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
let diagnostic_popover = editor.hover_state.diagnostic_popover.clone();
|
||||
if let Some(d) = diagnostic_popover {
|
||||
let keyboard_grace = d.keyboard_grace.borrow();
|
||||
if *keyboard_grace {
|
||||
if let Some(anchor) = d.anchor {
|
||||
show_hover(editor, anchor, false, cx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -215,6 +225,7 @@ fn show_hover(
|
|||
if !ignore_timeout {
|
||||
if same_info_hover(editor, &snapshot, anchor)
|
||||
|| same_diagnostic_hover(editor, &snapshot, anchor)
|
||||
|| editor.hover_state.diagnostic_popover.is_some()
|
||||
{
|
||||
// Hover triggered from same location as last time. Don't show again.
|
||||
return;
|
||||
|
@ -285,12 +296,85 @@ fn show_hover(
|
|||
})
|
||||
});
|
||||
|
||||
let diagnostic_popover = if let Some(local_diagnostic) = local_diagnostic {
|
||||
let text = match local_diagnostic.diagnostic.source {
|
||||
Some(ref source) => {
|
||||
format!("{source}: {}", local_diagnostic.diagnostic.message)
|
||||
}
|
||||
None => local_diagnostic.diagnostic.message.clone(),
|
||||
};
|
||||
|
||||
let mut border_color: Option<Hsla> = None;
|
||||
let mut background_color: Option<Hsla> = None;
|
||||
|
||||
let parsed_content = cx
|
||||
.new_view(|cx| {
|
||||
let status_colors = cx.theme().status();
|
||||
|
||||
match local_diagnostic.diagnostic.severity {
|
||||
DiagnosticSeverity::ERROR => {
|
||||
background_color = Some(status_colors.error_background);
|
||||
border_color = Some(status_colors.error_border);
|
||||
}
|
||||
DiagnosticSeverity::WARNING => {
|
||||
background_color = Some(status_colors.warning_background);
|
||||
border_color = Some(status_colors.warning_border);
|
||||
}
|
||||
DiagnosticSeverity::INFORMATION => {
|
||||
background_color = Some(status_colors.info_background);
|
||||
border_color = Some(status_colors.info_border);
|
||||
}
|
||||
DiagnosticSeverity::HINT => {
|
||||
background_color = Some(status_colors.hint_background);
|
||||
border_color = Some(status_colors.hint_border);
|
||||
}
|
||||
_ => {
|
||||
background_color = Some(status_colors.ignored_background);
|
||||
border_color = Some(status_colors.ignored_border);
|
||||
}
|
||||
};
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let mut base_text_style = cx.text_style();
|
||||
base_text_style.refine(&TextStyleRefinement {
|
||||
font_family: Some(settings.ui_font.family.clone()),
|
||||
font_size: Some(settings.ui_font_size.into()),
|
||||
color: Some(cx.theme().colors().editor_foreground),
|
||||
background_color: Some(gpui::transparent_black()),
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
let markdown_style = MarkdownStyle {
|
||||
base_text_style,
|
||||
selection_background_color: { cx.theme().players().local().selection },
|
||||
link: TextStyleRefinement {
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(cx.theme().colors().editor_foreground),
|
||||
wavy: false,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
Markdown::new_text(text, markdown_style.clone(), None, cx, None)
|
||||
})
|
||||
.ok();
|
||||
|
||||
Some(DiagnosticPopover {
|
||||
local_diagnostic,
|
||||
primary_diagnostic,
|
||||
parsed_content,
|
||||
border_color,
|
||||
background_color,
|
||||
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
|
||||
anchor: Some(anchor),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.hover_state.diagnostic_popover =
|
||||
local_diagnostic.map(|local_diagnostic| DiagnosticPopover {
|
||||
local_diagnostic,
|
||||
primary_diagnostic,
|
||||
});
|
||||
this.hover_state.diagnostic_popover = diagnostic_popover;
|
||||
})?;
|
||||
|
||||
let hovers_response = hover_request.await;
|
||||
|
@ -495,10 +579,8 @@ impl HoverState {
|
|||
pub fn render(
|
||||
&mut self,
|
||||
snapshot: &EditorSnapshot,
|
||||
style: &EditorStyle,
|
||||
visible_rows: Range<DisplayRow>,
|
||||
max_size: Size<Pixels>,
|
||||
_workspace: Option<WeakView<Workspace>>,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
) -> Option<(DisplayPoint, Vec<AnyElement>)> {
|
||||
// If there is a diagnostic, position the popovers based on that.
|
||||
|
@ -533,7 +615,7 @@ impl HoverState {
|
|||
let mut elements = Vec::new();
|
||||
|
||||
if let Some(diagnostic_popover) = self.diagnostic_popover.as_ref() {
|
||||
elements.push(diagnostic_popover.render(style, max_size, cx));
|
||||
elements.push(diagnostic_popover.render(max_size, cx));
|
||||
}
|
||||
for info_popover in &mut self.info_popovers {
|
||||
elements.push(info_popover.render(max_size, cx));
|
||||
|
@ -551,6 +633,13 @@ impl HoverState {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(diagnostic_popover) = &self.diagnostic_popover {
|
||||
if let Some(markdown_view) = &diagnostic_popover.parsed_content {
|
||||
if markdown_view.focus_handle(cx).is_focused(cx) {
|
||||
hover_popover_is_focused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hover_popover_is_focused;
|
||||
}
|
||||
}
|
||||
|
@ -606,84 +695,55 @@ impl InfoPopover {
|
|||
pub struct DiagnosticPopover {
|
||||
local_diagnostic: DiagnosticEntry<Anchor>,
|
||||
primary_diagnostic: Option<DiagnosticEntry<Anchor>>,
|
||||
parsed_content: Option<View<Markdown>>,
|
||||
border_color: Option<Hsla>,
|
||||
background_color: Option<Hsla>,
|
||||
pub keyboard_grace: Rc<RefCell<bool>>,
|
||||
pub anchor: Option<Anchor>,
|
||||
}
|
||||
|
||||
impl DiagnosticPopover {
|
||||
pub fn render(
|
||||
&self,
|
||||
style: &EditorStyle,
|
||||
max_size: Size<Pixels>,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
) -> AnyElement {
|
||||
let text = match &self.local_diagnostic.diagnostic.source {
|
||||
Some(source) => format!("{source}: {}", self.local_diagnostic.diagnostic.message),
|
||||
None => self.local_diagnostic.diagnostic.message.clone(),
|
||||
};
|
||||
|
||||
let status_colors = cx.theme().status();
|
||||
|
||||
struct DiagnosticColors {
|
||||
pub background: Hsla,
|
||||
pub border: Hsla,
|
||||
pub fn render(&self, max_size: Size<Pixels>, cx: &mut ViewContext<Editor>) -> AnyElement {
|
||||
let keyboard_grace = Rc::clone(&self.keyboard_grace);
|
||||
let mut markdown_div = div().py_1().px_2();
|
||||
if let Some(markdown) = &self.parsed_content {
|
||||
markdown_div = markdown_div.child(markdown.clone());
|
||||
}
|
||||
|
||||
let diagnostic_colors = match self.local_diagnostic.diagnostic.severity {
|
||||
DiagnosticSeverity::ERROR => DiagnosticColors {
|
||||
background: status_colors.error_background,
|
||||
border: status_colors.error_border,
|
||||
},
|
||||
DiagnosticSeverity::WARNING => DiagnosticColors {
|
||||
background: status_colors.warning_background,
|
||||
border: status_colors.warning_border,
|
||||
},
|
||||
DiagnosticSeverity::INFORMATION => DiagnosticColors {
|
||||
background: status_colors.info_background,
|
||||
border: status_colors.info_border,
|
||||
},
|
||||
DiagnosticSeverity::HINT => DiagnosticColors {
|
||||
background: status_colors.hint_background,
|
||||
border: status_colors.hint_border,
|
||||
},
|
||||
_ => DiagnosticColors {
|
||||
background: status_colors.ignored_background,
|
||||
border: status_colors.ignored_border,
|
||||
},
|
||||
};
|
||||
if let Some(background_color) = &self.background_color {
|
||||
markdown_div = markdown_div.bg(*background_color);
|
||||
}
|
||||
|
||||
div()
|
||||
if let Some(border_color) = &self.border_color {
|
||||
markdown_div = markdown_div
|
||||
.border_1()
|
||||
.border_color(*border_color)
|
||||
.rounded_lg();
|
||||
}
|
||||
|
||||
let diagnostic_div = div()
|
||||
.id("diagnostic")
|
||||
.block()
|
||||
.max_h(max_size.height)
|
||||
.elevation_2_borderless(cx)
|
||||
// Don't draw the background color if the theme
|
||||
// allows transparent surfaces.
|
||||
.when(window_is_transparent(cx), |this| {
|
||||
this.bg(gpui::transparent_black())
|
||||
})
|
||||
.cursor(CursorStyle::PointingHand)
|
||||
.tooltip(move |cx| Tooltip::for_action("Go To Diagnostic", &crate::GoToDiagnostic, cx))
|
||||
// Prevent a mouse move on the popover from being propagated to the editor,
|
||||
// because that would dismiss the popover.
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
// Prevent a mouse down on the popover from being propagated to the editor,
|
||||
// because that would move the cursor.
|
||||
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
|
||||
.on_click(cx.listener(|editor, _, cx| editor.go_to_diagnostic(&Default::default(), cx)))
|
||||
.child(
|
||||
div()
|
||||
.id("diagnostic-inner")
|
||||
.overflow_y_scroll()
|
||||
.max_w(max_size.width)
|
||||
.max_h(max_size.height)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(diagnostic_colors.background)
|
||||
.text_color(style.text.color)
|
||||
.border_1()
|
||||
.border_color(diagnostic_colors.border)
|
||||
.rounded_lg()
|
||||
.child(SharedString::from(text)),
|
||||
)
|
||||
.into_any_element()
|
||||
.on_mouse_down(MouseButton::Left, move |_, cx| {
|
||||
let mut keyboard_grace = keyboard_grace.borrow_mut();
|
||||
*keyboard_grace = false;
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(markdown_div);
|
||||
|
||||
diagnostic_div.into_any_element()
|
||||
}
|
||||
|
||||
pub fn activation_info(&self) -> (usize, Anchor) {
|
||||
|
|
|
@ -10,7 +10,7 @@ use gpui::{
|
|||
TextStyle, TextStyleRefinement, View,
|
||||
};
|
||||
use language::{Language, LanguageRegistry, Rope};
|
||||
use parser::{parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd};
|
||||
use parser::{parse_links_only, parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd};
|
||||
|
||||
use std::{iter, mem, ops::Range, rc::Rc, sync::Arc};
|
||||
use theme::SyntaxTheme;
|
||||
|
@ -61,6 +61,7 @@ pub struct Markdown {
|
|||
focus_handle: FocusHandle,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
fallback_code_block_language: Option<String>,
|
||||
parse_links_only: bool,
|
||||
}
|
||||
|
||||
actions!(markdown, [Copy]);
|
||||
|
@ -86,6 +87,33 @@ impl Markdown {
|
|||
focus_handle,
|
||||
language_registry,
|
||||
fallback_code_block_language,
|
||||
parse_links_only: false,
|
||||
};
|
||||
this.parse(cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub fn new_text(
|
||||
source: String,
|
||||
style: MarkdownStyle,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
fallback_code_block_language: Option<String>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let mut this = Self {
|
||||
source,
|
||||
selection: Selection::default(),
|
||||
pressed_link: None,
|
||||
autoscroll_request: None,
|
||||
style,
|
||||
should_reparse: false,
|
||||
parsed_markdown: ParsedMarkdown::default(),
|
||||
pending_parse: None,
|
||||
focus_handle,
|
||||
language_registry,
|
||||
fallback_code_block_language,
|
||||
parse_links_only: true,
|
||||
};
|
||||
this.parse(cx);
|
||||
this
|
||||
|
@ -136,9 +164,13 @@ impl Markdown {
|
|||
}
|
||||
|
||||
let text = self.source.clone();
|
||||
let parse_text_only = self.parse_links_only;
|
||||
let parsed = cx.background_executor().spawn(async move {
|
||||
let text = SharedString::from(text);
|
||||
let events = Arc::from(parse_markdown(text.as_ref()));
|
||||
let events = match parse_text_only {
|
||||
true => Arc::from(parse_links_only(text.as_ref())),
|
||||
false => Arc::from(parse_markdown(text.as_ref())),
|
||||
};
|
||||
anyhow::Ok(ParsedMarkdown {
|
||||
source: text,
|
||||
events,
|
||||
|
|
|
@ -88,6 +88,41 @@ pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
|
|||
events
|
||||
}
|
||||
|
||||
pub fn parse_links_only(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
|
||||
let mut events = Vec::new();
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut text_range = Range {
|
||||
start: 0,
|
||||
end: text.len(),
|
||||
};
|
||||
for link in finder.links(&text[text_range.clone()]) {
|
||||
let link_range = text_range.start + link.start()..text_range.start + link.end();
|
||||
|
||||
if link_range.start > text_range.start {
|
||||
events.push((text_range.start..link_range.start, MarkdownEvent::Text));
|
||||
}
|
||||
|
||||
events.push((
|
||||
link_range.clone(),
|
||||
MarkdownEvent::Start(MarkdownTag::Link {
|
||||
link_type: LinkType::Autolink,
|
||||
dest_url: SharedString::from(link.as_str().to_string()),
|
||||
title: SharedString::default(),
|
||||
id: SharedString::default(),
|
||||
}),
|
||||
));
|
||||
events.push((link_range.clone(), MarkdownEvent::Text));
|
||||
events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link)));
|
||||
|
||||
text_range.start = link_range.end;
|
||||
}
|
||||
|
||||
events.push((text_range, MarkdownEvent::Text));
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// A static-lifetime equivalent of pulldown_cmark::Event so we can cache the
|
||||
/// parse result for rendering without resorting to unsafe lifetime coercion.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue