Fix clicking on file links in editor (#25117)

Conrad Irwin created

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

Change summary

Cargo.lock                                           |   1 
crates/assistant2/src/active_thread.rs               |   2 
crates/assistant_context_editor/src/slash_command.rs |   6 
crates/editor/src/code_context_menus.rs              |  79 ++++-
crates/editor/src/editor.rs                          |  33 +-
crates/editor/src/element.rs                         |  16 
crates/editor/src/hover_popover.rs                   | 169 +++++++++----
crates/language/src/buffer.rs                        |  45 ---
crates/markdown/Cargo.toml                           |   3 
crates/markdown/examples/markdown.rs                 |  90 +------
crates/markdown/src/markdown.rs                      | 137 ++++------
crates/markdown/src/parser.rs                        |  12 
crates/project/src/lsp_store.rs                      |  93 ++++---
crates/project/src/project.rs                        |   6 
crates/recent_projects/src/ssh_connections.rs        |   2 
crates/zed/src/zed/linux_prompts.rs                  |  11 
16 files changed, 353 insertions(+), 352 deletions(-)

Detailed changes

Cargo.lock 🔗

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

crates/assistant2/src/active_thread.rs 🔗

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

crates/assistant_context_editor/src/slash_command.rs 🔗

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

crates/editor/src/code_context_menus.rs 🔗

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

crates/editor/src/editor.rs 🔗

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

crates/editor/src/element.rs 🔗

@@ -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)
             })
         }
     }

crates/editor/src/hover_popover.rs 🔗

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

crates/language/src/buffer.rs 🔗

@@ -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 {

crates/markdown/Cargo.toml 🔗

@@ -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"] }

crates/markdown/examples/markdown.rs 🔗

@@ -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 }
     }
 }

crates/markdown/src/markdown.rs 🔗

@@ -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
                             };

crates/markdown/src/parser.rs 🔗

@@ -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)> {

crates/project/src/lsp_store.rs 🔗

@@ -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 {

crates/project/src/project.rs 🔗

@@ -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;

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -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));

crates/zed/src/zed/linux_prompts.rs 🔗

@@ -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,
+                    )
                 })
             }),
         }