workspace: Use markdown to render LSP notification content (#44215)

Mayank Verma and Danilo Leal created

Closes #43657

Release Notes:

- Improved LSP notification messages by adding markdown rendering with
clickable URLs, inline code, etc.

<table>
  <tr>
    <td>Before</td>
    <td>After</td>
  </tr>
  <tr>
<td><img width="408" height="153" alt="screenshot-notification-before"
src="https://github.com/user-attachments/assets/53b026de-335f-4c39-937f-590c3b7ea571"
/></td>
<td><img width="408" height="153" alt="screenshot-notification-after"
src="https://github.com/user-attachments/assets/9d6a7baa-8304-4a52-a5d0-0bacf7ea69f9"
/></td>
  </tr>
</table>

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                            |  1 
crates/workspace/Cargo.toml           |  1 
crates/workspace/src/notifications.rs | 77 ++++++++++++++++++++++++----
3 files changed, 67 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -20101,6 +20101,7 @@ dependencies = [
  "itertools 0.14.0",
  "language",
  "log",
+ "markdown",
  "menu",
  "node_runtime",
  "parking_lot",

crates/workspace/Cargo.toml 🔗

@@ -45,6 +45,7 @@ itertools.workspace = true
 language.workspace = true
 log.workspace = true
 menu.workspace = true
+markdown.workspace = true
 node_runtime.workspace = true
 parking_lot.workspace = true
 postage.workspace = true

crates/workspace/src/notifications.rs 🔗

@@ -3,9 +3,12 @@ use anyhow::Context as _;
 use gpui::{
     AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, ClipboardItem, Context,
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
-    Task, svg,
+    Task, TextStyleRefinement, UnderlineStyle, svg,
 };
+use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
+use settings::Settings;
+use theme::ThemeSettings;
 
 use std::ops::Deref;
 use std::sync::{Arc, LazyLock};
@@ -216,6 +219,7 @@ pub struct LanguageServerPrompt {
     focus_handle: FocusHandle,
     request: Option<project::LanguageServerPromptRequest>,
     scroll_handle: ScrollHandle,
+    markdown: Entity<Markdown>,
 }
 
 impl Focusable for LanguageServerPrompt {
@@ -228,10 +232,13 @@ impl Notification for LanguageServerPrompt {}
 
 impl LanguageServerPrompt {
     pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self {
+        let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx));
+
         Self {
             focus_handle: cx.focus_handle(),
             request: Some(request),
             scroll_handle: ScrollHandle::new(),
+            markdown,
         }
     }
 
@@ -262,7 +269,7 @@ impl Render for LanguageServerPrompt {
         };
 
         let (icon, color) = match request.level {
-            PromptLevel::Info => (IconName::Info, Color::Accent),
+            PromptLevel::Info => (IconName::Info, Color::Muted),
             PromptLevel::Warning => (IconName::Warning, Color::Warning),
             PromptLevel::Critical => (IconName::XCircle, Color::Error),
         };
@@ -291,16 +298,15 @@ impl Render for LanguageServerPrompt {
                     .child(
                         h_flex()
                             .justify_between()
-                            .items_start()
                             .child(
                                 h_flex()
                                     .gap_2()
-                                    .child(Icon::new(icon).color(color))
+                                    .child(Icon::new(icon).color(color).size(IconSize::Small))
                                     .child(Label::new(request.lsp_name.clone())),
                             )
                             .child(
                                 h_flex()
-                                    .gap_2()
+                                    .gap_1()
                                     .child(
                                         IconButton::new("copy", IconName::Copy)
                                             .on_click({
@@ -317,15 +323,17 @@ impl Render for LanguageServerPrompt {
                                         IconButton::new(close_id, close_icon)
                                             .tooltip(move |_window, cx| {
                                                 if suppress {
-                                                    Tooltip::for_action(
-                                                        "Suppress.\nClose with click.",
-                                                        &SuppressNotification,
+                                                    Tooltip::with_meta(
+                                                        "Suppress",
+                                                        Some(&SuppressNotification),
+                                                        "Click to close",
                                                         cx,
                                                     )
                                                 } else {
-                                                    Tooltip::for_action(
-                                                        "Close.\nSuppress with shift-click.",
-                                                        &menu::Cancel,
+                                                    Tooltip::with_meta(
+                                                        "Close",
+                                                        Some(&menu::Cancel),
+                                                        "Suppress with shift-click",
                                                         cx,
                                                     )
                                                 }
@@ -342,7 +350,16 @@ impl Render for LanguageServerPrompt {
                                     ),
                             ),
                     )
-                    .child(Label::new(request.message.to_string()).size(LabelSize::Small))
+                    .child(
+                        MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx))
+                            .text_size(TextSize::Small.rems(cx))
+                            .code_block_renderer(markdown::CodeBlockRenderer::Default {
+                                copy_button: false,
+                                copy_button_on_hover: false,
+                                border: false,
+                            })
+                            .on_url_click(|link, _, cx| cx.open_url(&link)),
+                    )
                     .children(request.actions.iter().enumerate().map(|(ix, action)| {
                         let this_handle = cx.entity();
                         Button::new(ix, action.title.clone())
@@ -369,6 +386,42 @@ fn workspace_error_notification_id() -> NotificationId {
     NotificationId::unique::<WorkspaceErrorNotification>()
 }
 
+fn 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),
+        font_fallbacks: ui_font_fallbacks,
+        color: Some(cx.theme().colors().text),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style,
+        selection_background_color: cx.theme().colors().element_selection_background,
+        inline_code: TextStyleRefinement {
+            background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
+            font_family: Some(buffer_font_family),
+            font_fallbacks: buffer_font_fallbacks,
+            ..Default::default()
+        },
+        link: TextStyleRefinement {
+            underline: Some(UnderlineStyle {
+                thickness: px(1.),
+                color: Some(cx.theme().colors().text_accent),
+                wavy: false,
+            }),
+            ..Default::default()
+        },
+        ..Default::default()
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct ErrorMessagePrompt {
     message: SharedString,