Fix clicking on file links in editor (#25117)

Closes #18641
Contributes: #13194

Release Notes:

- Open LSP documentation file links in Zed not the system opener
- Render completion documentation markdown consistently with
documentation markdown
This commit is contained in:
Conrad Irwin 2025-02-18 22:54:35 -07:00 committed by GitHub
parent ebbc6a9752
commit 1678e3cbf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 353 additions and 352 deletions

View file

@ -1,14 +1,16 @@
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, FontWeight,
div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, Focusable, FontWeight,
ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText,
UniformListScrollHandle, WeakEntity,
UniformListScrollHandle,
};
use language::Buffer;
use language::{CodeLabel, CompletionDocumentation};
use language::CodeLabel;
use lsp::LanguageServerId;
use markdown::Markdown;
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::lsp_store::CompletionDocumentation;
use project::{CodeAction, Completion, TaskSourceKind};
use std::{
@ -21,12 +23,12 @@ use std::{
use task::ResolvedTask;
use ui::{prelude::*, Color, IntoElement, ListItem, Pixels, Popover, Styled};
use util::ResultExt;
use workspace::Workspace;
use crate::hover_popover::{hover_markdown_style, open_markdown_url};
use crate::{
actions::{ConfirmCodeAction, ConfirmCompletion},
render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks,
split_words, styled_runs_for_code_label, CodeActionProvider, CompletionId, CompletionProvider,
DisplayRow, Editor, EditorStyle, ResolvedTasks,
};
pub const MENU_GAP: Pixels = px(4.);
@ -137,17 +139,27 @@ impl CodeContextMenu {
}
pub fn render_aside(
&self,
style: &EditorStyle,
&mut self,
editor: &Editor,
max_size: Size<Pixels>,
workspace: Option<WeakEntity<Workspace>>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
match self {
CodeContextMenu::Completions(menu) => menu.render_aside(style, max_size, workspace, cx),
CodeContextMenu::Completions(menu) => menu.render_aside(editor, max_size, window, cx),
CodeContextMenu::CodeActions(_) => None,
}
}
pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
match self {
CodeContextMenu::Completions(completions_menu) => completions_menu
.markdown_element
.as_ref()
.is_some_and(|markdown| markdown.focus_handle(cx).contains_focused(window, cx)),
CodeContextMenu::CodeActions(_) => false,
}
}
}
pub enum ContextMenuOrigin {
@ -169,6 +181,7 @@ pub struct CompletionsMenu {
resolve_completions: bool,
show_completion_documentation: bool,
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
markdown_element: Option<Entity<Markdown>>,
}
impl CompletionsMenu {
@ -199,6 +212,7 @@ impl CompletionsMenu {
scroll_handle: UniformListScrollHandle::new(),
resolve_completions: true,
last_rendered_range: RefCell::new(None).into(),
markdown_element: None,
}
}
@ -255,6 +269,7 @@ impl CompletionsMenu {
resolve_completions: false,
show_completion_documentation: false,
last_rendered_range: RefCell::new(None).into(),
markdown_element: None,
}
}
@ -556,10 +571,10 @@ impl CompletionsMenu {
}
fn render_aside(
&self,
style: &EditorStyle,
&mut self,
editor: &Editor,
max_size: Size<Pixels>,
workspace: Option<WeakEntity<Workspace>>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
if !self.show_completion_documentation {
@ -571,17 +586,35 @@ impl CompletionsMenu {
.documentation
.as_ref()?
{
CompletionDocumentation::MultiLinePlainText(text) => {
div().child(SharedString::from(text.clone()))
CompletionDocumentation::MultiLinePlainText(text) => div().child(text.clone()),
CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.is_empty() => {
let markdown = self.markdown_element.get_or_insert_with(|| {
cx.new(|cx| {
let languages = editor
.workspace
.as_ref()
.and_then(|(workspace, _)| workspace.upgrade())
.map(|workspace| workspace.read(cx).app_state().languages.clone());
let language = editor
.language_at(self.initial_position, cx)
.map(|l| l.name().to_proto());
Markdown::new(
SharedString::default(),
hover_markdown_style(window, cx),
languages,
language,
window,
cx,
)
.copy_code_block_buttons(false)
.open_url(open_markdown_url)
})
});
markdown.update(cx, |markdown, cx| {
markdown.reset(parsed.clone(), window, cx);
});
div().child(markdown.clone())
}
CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.text.is_empty() => div()
.child(render_parsed_markdown(
"completions_markdown",
parsed,
&style,
workspace,
cx,
)),
CompletionDocumentation::MultiLineMarkdown(_) => return None,
CompletionDocumentation::SingleLine(_) => return None,
CompletionDocumentation::Undocumented => return None,

View file

@ -99,9 +99,9 @@ use itertools::Itertools;
use language::{
language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
CompletionDocumentation, CursorShape, Diagnostic, DiskState, EditPredictionsMode, EditPreview,
HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection,
SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
CursorShape, Diagnostic, DiskState, EditPredictionsMode, EditPreview, HighlightedText,
IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject,
TransactionId, TreeSitterOptions,
};
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
use linked_editing_ranges::refresh_linked_ranges;
@ -132,7 +132,7 @@ use multi_buffer::{
ToOffsetUtf16,
};
use project::{
lsp_store::{FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
project_settings::{GitGutterSetting, ProjectSettings},
CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink,
PrepareRenameResponse, Project, ProjectItem, ProjectTransaction, TaskSourceKind,
@ -6221,19 +6221,14 @@ impl Editor {
}
fn render_context_menu_aside(
&self,
style: &EditorStyle,
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
self.context_menu.borrow().as_ref().and_then(|menu| {
self.context_menu.borrow_mut().as_mut().and_then(|menu| {
if menu.visible() {
menu.render_aside(
style,
max_size,
self.workspace.as_ref().map(|(w, _)| w.clone()),
cx,
)
menu.render_aside(self, max_size, window, cx)
} else {
None
}
@ -14926,8 +14921,14 @@ impl Editor {
if !self.hover_state.focused(window, cx) {
hide_hover(self, cx);
}
self.hide_context_menu(window, cx);
if !self
.context_menu
.borrow()
.as_ref()
.is_some_and(|context_menu| context_menu.focused(window, cx))
{
self.hide_context_menu(window, cx);
}
self.discard_inline_completion(false, cx);
cx.emit(EditorEvent::Blurred);
cx.notify();
@ -15674,7 +15675,7 @@ fn snippet_completions(
documentation: snippet
.description
.clone()
.map(CompletionDocumentation::SingleLine),
.map(|description| CompletionDocumentation::SingleLine(description.into())),
lsp_completion: lsp::CompletionItem {
label: snippet.prefix.first().unwrap().clone(),
kind: Some(CompletionItemKind::SNIPPET),

View file

@ -3426,9 +3426,11 @@ impl EditorElement {
available_within_viewport.right - px(1.),
MENU_ASIDE_MAX_WIDTH,
);
let Some(mut aside) =
self.render_context_menu_aside(size(max_width, max_height - POPOVER_Y_PADDING), cx)
else {
let Some(mut aside) = self.render_context_menu_aside(
size(max_width, max_height - POPOVER_Y_PADDING),
window,
cx,
) else {
return;
};
aside.layout_as_root(AvailableSpace::min_size(), window, cx);
@ -3450,7 +3452,7 @@ impl EditorElement {
),
) - POPOVER_Y_PADDING,
);
let Some(mut aside) = self.render_context_menu_aside(max_size, cx) else {
let Some(mut aside) = self.render_context_menu_aside(max_size, window, cx) else {
return;
};
let actual_size = aside.layout_as_root(AvailableSpace::min_size(), window, cx);
@ -3491,7 +3493,7 @@ impl EditorElement {
// Skip drawing if it doesn't fit anywhere.
if let Some((aside, position)) = positioned_aside {
window.defer_draw(aside, position, 1);
window.defer_draw(aside, position, 2);
}
}
@ -3512,14 +3514,14 @@ impl EditorElement {
fn render_context_menu_aside(
&self,
max_size: Size<Pixels>,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
if max_size.width < px(100.) || max_size.height < px(12.) {
None
} else {
self.editor.update(cx, |editor, cx| {
editor.render_context_menu_aside(&self.style, max_size, cx)
editor.render_context_menu_aside(max_size, window, cx)
})
}
}

View file

@ -1,7 +1,7 @@
use crate::{
display_map::{invisibles::is_invisible, InlayOffset, ToDisplayPoint},
hover_links::{InlayHighlight, RangeInEditor},
scroll::ScrollAmount,
scroll::{Autoscroll, ScrollAmount},
Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
Hover,
};
@ -18,12 +18,14 @@ use markdown::{Markdown, MarkdownStyle};
use multi_buffer::ToOffset;
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
use settings::Settings;
use std::rc::Rc;
use std::{borrow::Cow, cell::RefCell};
use std::{ops::Range, sync::Arc, time::Duration};
use std::{path::PathBuf, rc::Rc};
use theme::ThemeSettings;
use ui::{prelude::*, theme_is_transparent, Scrollbar, ScrollbarState};
use url::Url;
use util::TryFutureExt;
use workspace::Workspace;
pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
@ -356,7 +358,15 @@ fn show_hover(
},
..Default::default()
};
Markdown::new_text(text, markdown_style.clone(), None, None, window, cx)
Markdown::new_text(
SharedString::new(text),
markdown_style.clone(),
None,
None,
window,
cx,
)
.open_url(open_markdown_url)
})
.ok();
@ -558,69 +568,122 @@ async fn parse_blocks(
let rendered_block = cx
.new_window_entity(|window, cx| {
let settings = ThemeSettings::get_global(cx);
let ui_font_family = settings.ui_font.family.clone();
let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
let buffer_font_family = settings.buffer_font.family.clone();
let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
font_family: Some(ui_font_family.clone()),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
});
let markdown_style = MarkdownStyle {
base_text_style,
code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx),
inline_code: TextStyleRefinement {
background_color: Some(cx.theme().colors().background),
font_family: Some(buffer_font_family),
font_fallbacks: buffer_font_fallbacks,
..Default::default()
},
rule_color: cx.theme().colors().border,
block_quote_border_color: Color::Muted.color(cx),
block_quote: TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: TextStyleRefinement {
color: Some(cx.theme().colors().editor_foreground),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(cx.theme().colors().editor_foreground),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: { cx.theme().players().local().selection },
heading: StyleRefinement::default()
.font_weight(FontWeight::BOLD)
.text_base()
.mt(rems(1.))
.mb_0(),
};
Markdown::new(
combined_text,
markdown_style.clone(),
combined_text.into(),
hover_markdown_style(window, cx),
Some(language_registry.clone()),
fallback_language_name,
window,
cx,
)
.copy_code_block_buttons(false)
.open_url(open_markdown_url)
})
.ok();
rendered_block
}
pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
let settings = ThemeSettings::get_global(cx);
let ui_font_family = settings.ui_font.family.clone();
let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
let buffer_font_family = settings.buffer_font.family.clone();
let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
let mut base_text_style = window.text_style();
base_text_style.refine(&TextStyleRefinement {
font_family: Some(ui_font_family.clone()),
font_fallbacks: ui_font_fallbacks,
color: Some(cx.theme().colors().editor_foreground),
..Default::default()
});
MarkdownStyle {
base_text_style,
code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx),
inline_code: TextStyleRefinement {
background_color: Some(cx.theme().colors().background),
font_family: Some(buffer_font_family),
font_fallbacks: buffer_font_fallbacks,
..Default::default()
},
rule_color: cx.theme().colors().border,
block_quote_border_color: Color::Muted.color(cx),
block_quote: TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: TextStyleRefinement {
color: Some(cx.theme().colors().editor_foreground),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(cx.theme().colors().editor_foreground),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: { cx.theme().players().local().selection },
heading: StyleRefinement::default()
.font_weight(FontWeight::BOLD)
.text_base()
.mt(rems(1.))
.mb_0(),
}
}
pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) {
if let Ok(uri) = Url::parse(&link) {
if uri.scheme() == "file" {
if let Some(workspace) = window.root::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
let task =
workspace.open_abs_path(PathBuf::from(uri.path()), false, window, cx);
cx.spawn_in(window, |_, mut cx| async move {
let item = task.await?;
// Ruby LSP uses URLs with #L1,1-4,4
// we'll just take the first number and assume it's a line number
let Some(fragment) = uri.fragment() else {
return anyhow::Ok(());
};
let mut accum = 0u32;
for c in fragment.chars() {
if c >= '0' && c <= '9' && accum < u32::MAX / 2 {
accum *= 10;
accum += c as u32 - '0' as u32;
} else if accum > 0 {
break;
}
}
if accum == 0 {
return Ok(());
}
let Some(editor) = cx.update(|_, cx| item.act_as::<Editor>(cx))? else {
return Ok(());
};
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(
Some(Autoscroll::fit()),
window,
cx,
|selections| {
selections.select_ranges([text::Point::new(accum - 1, 0)
..text::Point::new(accum - 1, 0)]);
},
);
})
})
.detach_and_log_err(cx);
});
return;
}
}
}
cx.open_url(&link);
}
#[derive(Default, Debug)]
pub struct HoverState {
pub info_popovers: Vec<InfoPopover>,