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

1
Cargo.lock generated
View file

@ -7652,7 +7652,6 @@ dependencies = [
"anyhow",
"assets",
"env_logger 0.11.6",
"futures 0.3.31",
"gpui",
"language",
"languages",

View file

@ -179,7 +179,7 @@ impl ActiveThread {
let markdown = cx.new(|cx| {
Markdown::new(
text,
text.into(),
markdown_style,
Some(self.language_registry.clone()),
None,

View file

@ -5,9 +5,9 @@ use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWor
use editor::{CompletionProvider, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, CompletionDocumentation, LanguageServerId, ToPoint};
use language::{Anchor, Buffer, LanguageServerId, ToPoint};
use parking_lot::Mutex;
use project::CompletionIntent;
use project::{lsp_store::CompletionDocumentation, CompletionIntent};
use rope::Point;
use std::{
cell::RefCell,
@ -121,7 +121,7 @@ impl SlashCommandCompletionProvider {
Some(project::Completion {
old_range: name_range.clone(),
documentation: Some(CompletionDocumentation::SingleLine(
command.description(),
command.description().into(),
)),
new_text,
label: command.label(cx),

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>,

View file

@ -7,7 +7,6 @@ pub use crate::{
use crate::{
diagnostic_set::{DiagnosticEntry, DiagnosticGroup},
language_settings::{language_settings, LanguageSettings},
markdown::parse_markdown,
outline::OutlineItem,
syntax_map::{
SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch,
@ -231,50 +230,6 @@ pub struct Diagnostic {
pub data: Option<Value>,
}
/// TODO - move this into the `project` crate and make it private.
pub async fn prepare_completion_documentation(
documentation: &lsp::Documentation,
language_registry: &Arc<LanguageRegistry>,
language: Option<Arc<Language>>,
) -> CompletionDocumentation {
match documentation {
lsp::Documentation::String(text) => {
if text.lines().count() <= 1 {
CompletionDocumentation::SingleLine(text.clone())
} else {
CompletionDocumentation::MultiLinePlainText(text.clone())
}
}
lsp::Documentation::MarkupContent(lsp::MarkupContent { kind, value }) => match kind {
lsp::MarkupKind::PlainText => {
if value.lines().count() <= 1 {
CompletionDocumentation::SingleLine(value.clone())
} else {
CompletionDocumentation::MultiLinePlainText(value.clone())
}
}
lsp::MarkupKind::Markdown => {
let parsed = parse_markdown(value, Some(language_registry), language).await;
CompletionDocumentation::MultiLineMarkdown(parsed)
}
},
}
}
#[derive(Clone, Debug)]
pub enum CompletionDocumentation {
/// There is no documentation for this completion.
Undocumented,
/// A single line of documentation.
SingleLine(String),
/// Multiple lines of plain text documentation.
MultiLinePlainText(String),
/// Markdown documentation.
MultiLineMarkdown(ParsedMarkdown),
}
/// An operation used to synchronize this buffer with its other replicas.
#[derive(Clone, Debug, PartialEq)]
pub enum Operation {

View file

@ -20,7 +20,6 @@ test-support = [
[dependencies]
anyhow.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
linkify.workspace = true
@ -34,7 +33,7 @@ util.workspace = true
assets.workspace = true
env_logger.workspace = true
gpui = { workspace = true, features = ["test-support"] }
languages.workspace = true
languages = { workspace = true, features = ["load-grammars"] }
node_runtime.workspace = true
settings = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }

View file

@ -15,84 +15,12 @@ const MARKDOWN_EXAMPLE: &str = r#"
## Headings
Headings are created by adding one or more `#` symbols before your heading text. The number of `#` you use will determine the size of the heading.
```rust
gpui::window::ViewContext
impl<'a, V> ViewContext<'a, V>
pub fn on_blur(&mut self, handle: &FocusHandle, listener: impl FnMut(&mut V, &mut iewContext<V>) + 'static) -> Subscription
where
// Bounds from impl:
V: 'static,
```
function a(b: T) {
## Emphasis
Emphasis can be added with italics or bold. *This text will be italic*. _This will also be italic_
## Lists
### Unordered Lists
Unordered lists use asterisks `*`, plus `+`, or minus `-` as list markers.
* Item 1
* Item 2
* Item 2a
* Item 2b
### Ordered Lists
Ordered lists use numbers followed by a period.
1. Item 1
2. Item 2
3. Item 3
1. Item 3a
2. Item 3b
## Links
Links are created using the format [http://zed.dev](https://zed.dev).
They can also be detected automatically, for example https://zed.dev/blog.
They may contain dollar signs:
[https://svelte.dev/docs/svelte/$state](https://svelte.dev/docs/svelte/$state)
https://svelte.dev/docs/svelte/$state
## Images
Images are like links, but with an exclamation mark `!` in front.
```markdown
![This is an image](/images/logo.png)
```
## Code
Inline `code` can be wrapped with backticks `` ` ``.
```markdown
Inline `code` has `back-ticks around` it.
```
Code blocks can be created by indenting lines by four spaces or with triple backticks ```.
```javascript
function test() {
console.log("notice the blank line before this function?");
}
```
## Blockquotes
Blockquotes are created with `>`.
> This is a blockquote.
## Horizontal Rules
Horizontal rules are created using three or more asterisks `***`, dashes `---`, or underscores `___`.
## Line breaks
This is a
\
line break!
---
Remember, markdown processors may have slight differences and extensions, so always refer to the specific documentation or guides relevant to your platform or editor for the best practices and additional features.
"#;
@ -161,7 +89,7 @@ pub fn main() {
};
MarkdownExample::new(
MARKDOWN_EXAMPLE.to_string(),
MARKDOWN_EXAMPLE.into(),
markdown_style,
language_registry,
window,
@ -179,14 +107,22 @@ struct MarkdownExample {
impl MarkdownExample {
pub fn new(
text: String,
text: SharedString,
style: MarkdownStyle,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut App,
) -> Self {
let markdown =
cx.new(|cx| Markdown::new(text, style, Some(language_registry), None, window, cx));
let markdown = cx.new(|cx| {
Markdown::new(
text,
style,
Some(language_registry),
Some("TypeScript".to_string()),
window,
cx,
)
});
Self { markdown }
}
}

View file

@ -1,7 +1,6 @@
pub mod parser;
use crate::parser::CodeBlockKind;
use futures::FutureExt;
use gpui::{
actions, point, quad, AnyElement, App, Bounds, ClipboardItem, CursorStyle, DispatchPhase,
Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla,
@ -12,7 +11,7 @@ use gpui::{
use language::{Language, LanguageRegistry, Rope};
use parser::{parse_links_only, parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd};
use std::{iter, mem, ops::Range, rc::Rc, sync::Arc};
use std::{collections::HashMap, iter, mem, ops::Range, rc::Rc, sync::Arc};
use theme::SyntaxTheme;
use ui::{prelude::*, Tooltip};
use util::{ResultExt, TryFutureExt};
@ -49,7 +48,7 @@ impl Default for MarkdownStyle {
}
pub struct Markdown {
source: String,
source: SharedString,
selection: Selection,
pressed_link: Option<RenderedLink>,
autoscroll_request: Option<usize>,
@ -60,6 +59,7 @@ pub struct Markdown {
focus_handle: FocusHandle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
open_url: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
options: Options,
}
@ -73,7 +73,7 @@ actions!(markdown, [Copy]);
impl Markdown {
pub fn new(
source: String,
source: SharedString,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
@ -97,13 +97,24 @@ impl Markdown {
parse_links_only: false,
copy_code_block_buttons: true,
},
open_url: None,
};
this.parse(window, cx);
this
}
pub fn open_url(
self,
open_url: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
Self {
open_url: Some(Box::new(open_url)),
..self
}
}
pub fn new_text(
source: String,
source: SharedString,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
@ -127,6 +138,7 @@ impl Markdown {
parse_links_only: true,
copy_code_block_buttons: true,
},
open_url: None,
};
this.parse(window, cx);
this
@ -137,11 +149,11 @@ impl Markdown {
}
pub fn append(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.source.push_str(text);
self.source = SharedString::new(self.source.to_string() + text);
self.parse(window, cx);
}
pub fn reset(&mut self, source: String, window: &mut Window, cx: &mut Context<Self>) {
pub fn reset(&mut self, source: SharedString, window: &mut Window, cx: &mut Context<Self>) {
if source == self.source() {
return;
}
@ -176,17 +188,38 @@ impl Markdown {
return;
}
let text = self.source.clone();
let source = self.source.clone();
let parse_text_only = self.options.parse_links_only;
let language_registry = self.language_registry.clone();
let fallback = self.fallback_code_block_language.clone();
let parsed = cx.background_spawn(async move {
let text = SharedString::from(text);
let events = match parse_text_only {
true => Arc::from(parse_links_only(text.as_ref())),
false => Arc::from(parse_markdown(text.as_ref())),
};
if parse_text_only {
return anyhow::Ok(ParsedMarkdown {
events: Arc::from(parse_links_only(source.as_ref())),
source,
languages: HashMap::default(),
});
}
let (events, language_names) = parse_markdown(&source);
let mut languages = HashMap::with_capacity(language_names.len());
for name in language_names {
if let Some(registry) = language_registry.as_ref() {
let language = if !name.is_empty() {
registry.language_for_name(&name)
} else if let Some(fallback) = &fallback {
registry.language_for_name(fallback)
} else {
continue;
};
if let Ok(language) = language.await {
languages.insert(name, language);
}
}
}
anyhow::Ok(ParsedMarkdown {
source: text,
events,
source,
events: Arc::from(events),
languages,
})
});
@ -217,12 +250,7 @@ impl Markdown {
impl Render for Markdown {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
MarkdownElement::new(
cx.entity().clone(),
self.style.clone(),
self.language_registry.clone(),
self.fallback_code_block_language.clone(),
)
MarkdownElement::new(cx.entity().clone(), self.style.clone())
}
}
@ -270,6 +298,7 @@ impl Selection {
pub struct ParsedMarkdown {
source: SharedString,
events: Arc<[(Range<usize>, MarkdownEvent)]>,
languages: HashMap<SharedString, Arc<Language>>,
}
impl ParsedMarkdown {
@ -285,61 +314,11 @@ impl ParsedMarkdown {
pub struct MarkdownElement {
markdown: Entity<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
}
impl MarkdownElement {
fn new(
markdown: Entity<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
) -> Self {
Self {
markdown,
style,
language_registry,
fallback_code_block_language,
}
}
fn load_language(
&self,
name: &str,
window: &mut Window,
cx: &mut App,
) -> Option<Arc<Language>> {
let language_test = self.language_registry.as_ref()?.language_for_name(name);
let language_name = match language_test.now_or_never() {
Some(Ok(_)) => String::from(name),
Some(Err(_)) if !name.is_empty() && self.fallback_code_block_language.is_some() => {
self.fallback_code_block_language.clone().unwrap()
}
_ => String::new(),
};
let language = self
.language_registry
.as_ref()?
.language_for_name(language_name.as_str())
.map(|language| language.ok())
.shared();
match language.clone().now_or_never() {
Some(language) => language,
None => {
let markdown = self.markdown.downgrade();
window
.spawn(cx, |mut cx| async move {
language.await;
markdown.update(&mut cx, |_, cx| cx.notify())
})
.detach_and_log_err(cx);
None
}
}
fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
Self { markdown, style }
}
fn paint_selection(
@ -452,7 +431,7 @@ impl MarkdownElement {
pending: true,
};
window.focus(&markdown.focus_handle);
window.prevent_default()
window.prevent_default();
}
cx.notify();
@ -492,11 +471,15 @@ impl MarkdownElement {
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
move |markdown, event: &MouseUpEvent, phase, _, cx| {
move |markdown, event: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
if let Some(pressed_link) = markdown.pressed_link.take() {
if Some(&pressed_link) == rendered_text.link_for_position(event.position) {
cx.open_url(&pressed_link.destination_url);
if let Some(open_url) = markdown.open_url.as_mut() {
open_url(pressed_link.destination_url, window, cx);
} else {
cx.open_url(&pressed_link.destination_url);
}
}
}
} else if markdown.selection.pending {
@ -617,7 +600,7 @@ impl Element for MarkdownElement {
}
MarkdownTag::CodeBlock(kind) => {
let language = if let CodeBlockKind::Fenced(language) = kind {
self.load_language(language.as_ref(), window, cx)
parsed_markdown.languages.get(language).cloned()
} else {
None
};

View file

@ -2,15 +2,16 @@ use gpui::SharedString;
use linkify::LinkFinder;
pub use pulldown_cmark::TagEnd as MarkdownTagEnd;
use pulldown_cmark::{Alignment, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser};
use std::ops::Range;
use std::{collections::HashSet, ops::Range};
pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
pub fn parse_markdown(text: &str) -> (Vec<(Range<usize>, MarkdownEvent)>, HashSet<SharedString>) {
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
options.remove(pulldown_cmark::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
options.remove(pulldown_cmark::Options::ENABLE_MATH);
let mut events = Vec::new();
let mut languages = HashSet::new();
let mut within_link = false;
let mut within_metadata = false;
for (pulldown_event, mut range) in Parser::new_ext(text, options).into_offset_iter() {
@ -27,6 +28,11 @@ pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
match tag {
pulldown_cmark::Tag::Link { .. } => within_link = true,
pulldown_cmark::Tag::MetadataBlock { .. } => within_metadata = true,
pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(
ref language,
)) => {
languages.insert(SharedString::from(language.to_string()));
}
_ => {}
}
events.push((range, MarkdownEvent::Start(tag.into())))
@ -102,7 +108,7 @@ pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {}
}
}
events
(events, languages)
}
pub fn parse_links_only(mut text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {

View file

@ -26,7 +26,8 @@ use futures::{
};
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, PromptLevel, Task, WeakEntity,
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task,
WeakEntity,
};
use http_client::HttpClient;
use itertools::Itertools as _;
@ -34,13 +35,12 @@ use language::{
language_settings::{
language_settings, FormatOnSave, Formatter, LanguageSettings, SelectedFormatter,
},
markdown, point_to_lsp, prepare_completion_documentation,
point_to_lsp,
proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel,
CompletionDocumentation, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language,
LanguageRegistry, LanguageServerBinaryStatus, LanguageToolchainStore, LocalFile, LspAdapter,
LspAdapterDelegate, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction,
Unclipped,
Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, LanguageRegistry,
LanguageServerBinaryStatus, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate,
Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
};
use lsp::{
notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity,
@ -4204,14 +4204,8 @@ impl LspStore {
cx.foreground_executor().spawn(async move {
let completions = task.await?;
let mut result = Vec::new();
populate_labels_for_completions(
completions,
&language_registry,
language,
lsp_adapter,
&mut result,
)
.await;
populate_labels_for_completions(completions, language, lsp_adapter, &mut result)
.await;
Ok(result)
})
} else if let Some(local) = self.as_local() {
@ -4260,7 +4254,6 @@ impl LspStore {
if let Ok(new_completions) = task.await {
populate_labels_for_completions(
new_completions,
&language_registry,
language.clone(),
lsp_adapter,
&mut completions,
@ -4284,7 +4277,6 @@ impl LspStore {
cx: &mut Context<Self>,
) -> Task<Result<bool>> {
let client = self.upstream_client();
let language_registry = self.languages.clone();
let buffer_id = buffer.read(cx).remote_id();
let buffer_snapshot = buffer.read(cx).snapshot();
@ -4302,7 +4294,6 @@ impl LspStore {
completions.clone(),
completion_index,
client.clone(),
language_registry.clone(),
)
.await
.log_err()
@ -4343,7 +4334,6 @@ impl LspStore {
&buffer_snapshot,
completions.clone(),
completion_index,
language_registry.clone(),
)
.await
.log_err();
@ -4419,22 +4409,14 @@ impl LspStore {
snapshot: &BufferSnapshot,
completions: Rc<RefCell<Box<[Completion]>>>,
completion_index: usize,
language_registry: Arc<LanguageRegistry>,
) -> Result<()> {
let completion_item = completions.borrow()[completion_index]
.lsp_completion
.clone();
if let Some(lsp_documentation) = completion_item.documentation.as_ref() {
let documentation = language::prepare_completion_documentation(
lsp_documentation,
&language_registry,
snapshot.language().cloned(),
)
.await;
if let Some(lsp_documentation) = completion_item.documentation.clone() {
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
completion.documentation = Some(documentation);
completion.documentation = Some(lsp_documentation.into());
} else {
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
@ -4487,7 +4469,6 @@ impl LspStore {
completions: Rc<RefCell<Box<[Completion]>>>,
completion_index: usize,
client: AnyProtoClient,
language_registry: Arc<LanguageRegistry>,
) -> Result<()> {
let lsp_completion = {
let completion = &completions.borrow()[completion_index];
@ -4514,14 +4495,11 @@ impl LspStore {
let documentation = if response.documentation.is_empty() {
CompletionDocumentation::Undocumented
} else if response.documentation_is_markdown {
CompletionDocumentation::MultiLineMarkdown(
markdown::parse_markdown(&response.documentation, Some(&language_registry), None)
.await,
)
CompletionDocumentation::MultiLineMarkdown(response.documentation.into())
} else if response.documentation.lines().count() <= 1 {
CompletionDocumentation::SingleLine(response.documentation)
CompletionDocumentation::SingleLine(response.documentation.into())
} else {
CompletionDocumentation::MultiLinePlainText(response.documentation)
CompletionDocumentation::MultiLinePlainText(response.documentation.into())
};
let mut completions = completions.borrow_mut();
@ -8060,7 +8038,6 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> {
async fn populate_labels_for_completions(
mut new_completions: Vec<CoreCompletion>,
language_registry: &Arc<LanguageRegistry>,
language: Option<Arc<Language>>,
lsp_adapter: Option<Arc<CachedLspAdapter>>,
completions: &mut Vec<Completion>,
@ -8085,8 +8062,8 @@ async fn populate_labels_for_completions(
.zip(lsp_completions)
.zip(labels.into_iter().chain(iter::repeat(None)))
{
let documentation = if let Some(docs) = &lsp_completion.documentation {
Some(prepare_completion_documentation(docs, language_registry, language.clone()).await)
let documentation = if let Some(docs) = lsp_completion.documentation.clone() {
Some(docs.into())
} else {
None
};
@ -8477,6 +8454,46 @@ impl DiagnosticSummary {
}
}
#[derive(Clone, Debug)]
pub enum CompletionDocumentation {
/// There is no documentation for this completion.
Undocumented,
/// A single line of documentation.
SingleLine(SharedString),
/// Multiple lines of plain text documentation.
MultiLinePlainText(SharedString),
/// Markdown documentation.
MultiLineMarkdown(SharedString),
}
impl From<lsp::Documentation> for CompletionDocumentation {
fn from(docs: lsp::Documentation) -> Self {
match docs {
lsp::Documentation::String(text) => {
if text.lines().count() <= 1 {
CompletionDocumentation::SingleLine(text.into())
} else {
CompletionDocumentation::MultiLinePlainText(text.into())
}
}
lsp::Documentation::MarkupContent(lsp::MarkupContent { kind, value }) => match kind {
lsp::MarkupKind::PlainText => {
if value.lines().count() <= 1 {
CompletionDocumentation::SingleLine(value.into())
} else {
CompletionDocumentation::MultiLinePlainText(value.into())
}
}
lsp::MarkupKind::Markdown => {
CompletionDocumentation::MultiLineMarkdown(value.into())
}
},
}
}
}
fn glob_literal_prefix(glob: &Path) -> PathBuf {
glob.components()
.take_while(|component| match component {

View file

@ -58,15 +58,15 @@ use gpui::{
use itertools::Itertools;
use language::{
language_settings::InlayHintKind, proto::split_operations, Buffer, BufferEvent, Capability,
CodeLabel, CompletionDocumentation, File as _, Language, LanguageName, LanguageRegistry,
PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction, Unclipped,
CodeLabel, File as _, Language, LanguageName, LanguageRegistry, PointUtf16, ToOffset,
ToPointUtf16, Toolchain, ToolchainList, Transaction, Unclipped,
};
use lsp::{
CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, LanguageServerId,
LanguageServerName, MessageActionItem,
};
use lsp_command::*;
use lsp_store::{LspFormatTarget, OpenLspBufferHandle};
use lsp_store::{CompletionDocumentation, LspFormatTarget, OpenLspBufferHandle};
use node_runtime::NodeRuntime;
use parking_lot::Mutex;
pub use prettier_store::PrettierStore;

View file

@ -208,7 +208,7 @@ impl SshPrompt {
..Default::default()
};
let markdown =
cx.new(|cx| Markdown::new_text(prompt, markdown_style, None, None, window, cx));
cx.new(|cx| Markdown::new_text(prompt.into(), markdown_style, None, None, window, cx));
self.prompt = Some((markdown, tx));
self.status_message.take();
window.focus(&self.editor.focus_handle(cx));

View file

@ -1,7 +1,7 @@
use gpui::{
div, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight,
InteractiveElement, IntoElement, ParentElement, PromptHandle, PromptLevel, PromptResponse,
Refineable, Render, RenderablePromptHandle, Styled, TextStyleRefinement, Window,
Refineable, Render, RenderablePromptHandle, SharedString, Styled, TextStyleRefinement, Window,
};
use markdown::{Markdown, MarkdownStyle};
use settings::Settings;
@ -48,7 +48,14 @@ pub fn fallback_prompt_renderer(
selection_background_color: { cx.theme().players().local().selection },
..Default::default()
};
Markdown::new(text.to_string(), markdown_style, None, None, window, cx)
Markdown::new(
SharedString::new(text),
markdown_style,
None,
None,
window,
cx,
)
})
}),
}