markdown: Add HTML `img` tag support (#36700)

Remco Smits created

Closes #21992

<img width="1406" height="1184" alt="Screenshot 2025-08-21 at 18 09 24"
src="https://github.com/user-attachments/assets/5f14a0d8-c4d9-48ad-b10d-fadfaca258ea"
/>

Code example:

```markdown
# Html Tag
<img src="https://picsum.photos/200/300" alt="Description of image" />

# Html Tag with width and height
<img src="https://picsum.photos/200/300" alt="Description of image" width="100" height="200" />

# Html Tag with style attribute with width and height
<img src="https://picsum.photos/200/300" alt="Description of image" style="width: 100px; height: 200px" />

# Normal Tag
![alt text](https://picsum.photos/200/300)
```

Release Notes:

- Markdown: Added HTML `<img src="/some-image.svg">` tag support

Change summary

Cargo.lock                                       |   2 
crates/markdown_preview/Cargo.toml               |   6 
crates/markdown_preview/src/markdown_elements.rs |  17 
crates/markdown_preview/src/markdown_parser.rs   | 372 +++++++++++++++++
crates/markdown_preview/src/markdown_renderer.rs | 137 +++---
5 files changed, 456 insertions(+), 78 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9953,9 +9953,11 @@ dependencies = [
  "editor",
  "fs",
  "gpui",
+ "html5ever 0.27.0",
  "language",
  "linkify",
  "log",
+ "markup5ever_rcdom",
  "pretty_assertions",
  "pulldown-cmark 0.12.2",
  "settings",

crates/markdown_preview/Cargo.toml 🔗

@@ -19,19 +19,21 @@ anyhow.workspace = true
 async-recursion.workspace = true
 collections.workspace = true
 editor.workspace = true
+fs.workspace = true
 gpui.workspace = true
+html5ever.workspace = true
 language.workspace = true
 linkify.workspace = true
 log.workspace = true
+markup5ever_rcdom.workspace = true
 pretty_assertions.workspace = true
 pulldown-cmark.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true
-workspace.workspace = true
 workspace-hack.workspace = true
-fs.workspace = true
+workspace.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -1,5 +1,6 @@
 use gpui::{
-    FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, UnderlineStyle, px,
+    DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle,
+    UnderlineStyle, px,
 };
 use language::HighlightId;
 use std::{fmt::Display, ops::Range, path::PathBuf};
@@ -15,6 +16,7 @@ pub enum ParsedMarkdownElement {
     /// A paragraph of text and other inline elements.
     Paragraph(MarkdownParagraph),
     HorizontalRule(Range<usize>),
+    Image(Image),
 }
 
 impl ParsedMarkdownElement {
@@ -30,6 +32,7 @@ impl ParsedMarkdownElement {
                 MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
             },
             Self::HorizontalRule(range) => range.clone(),
+            Self::Image(image) => image.source_range.clone(),
         })
     }
 
@@ -290,6 +293,8 @@ pub struct Image {
     pub link: Link,
     pub source_range: Range<usize>,
     pub alt_text: Option<SharedString>,
+    pub width: Option<DefiniteLength>,
+    pub height: Option<DefiniteLength>,
 }
 
 impl Image {
@@ -303,10 +308,20 @@ impl Image {
             source_range,
             link,
             alt_text: None,
+            width: None,
+            height: None,
         })
     }
 
     pub fn set_alt_text(&mut self, alt_text: SharedString) {
         self.alt_text = Some(alt_text);
     }
+
+    pub fn set_width(&mut self, width: DefiniteLength) {
+        self.width = Some(width);
+    }
+
+    pub fn set_height(&mut self, height: DefiniteLength) {
+        self.height = Some(height);
+    }
 }

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -1,10 +1,12 @@
 use crate::markdown_elements::*;
 use async_recursion::async_recursion;
 use collections::FxHashMap;
-use gpui::FontWeight;
+use gpui::{DefiniteLength, FontWeight, px, relative};
+use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink};
 use language::LanguageRegistry;
+use markup5ever_rcdom::RcDom;
 use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
-use std::{ops::Range, path::PathBuf, sync::Arc, vec};
+use std::{cell::RefCell, collections::HashMap, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec};
 
 pub async fn parse_markdown(
     markdown_input: &str,
@@ -172,9 +174,14 @@ impl<'a> MarkdownParser<'a> {
 
                     self.cursor += 1;
 
-                    let code_block = self.parse_code_block(language).await;
+                    let code_block = self.parse_code_block(language).await?;
                     Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
                 }
+                Tag::HtmlBlock => {
+                    self.cursor += 1;
+
+                    Some(self.parse_html_block().await)
+                }
                 _ => None,
             },
             Event::Rule => {
@@ -378,7 +385,7 @@ impl<'a> MarkdownParser<'a> {
                     TagEnd::Image => {
                         if let Some(mut image) = image.take() {
                             if !text.is_empty() {
-                                image.alt_text = Some(std::mem::take(&mut text).into());
+                                image.set_alt_text(std::mem::take(&mut text).into());
                             }
                             markdown_text_like.push(MarkdownParagraphChunk::Image(image));
                         }
@@ -695,13 +702,22 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
-    async fn parse_code_block(&mut self, language: Option<String>) -> ParsedMarkdownCodeBlock {
-        let (_event, source_range) = self.previous().unwrap();
+    async fn parse_code_block(
+        &mut self,
+        language: Option<String>,
+    ) -> Option<ParsedMarkdownCodeBlock> {
+        let Some((_event, source_range)) = self.previous() else {
+            return None;
+        };
+
         let source_range = source_range.clone();
         let mut code = String::new();
 
         while !self.eof() {
-            let (current, _source_range) = self.current().unwrap();
+            let Some((current, _source_range)) = self.current() else {
+                break;
+            };
+
             match current {
                 Event::Text(text) => {
                     code.push_str(text);
@@ -734,23 +750,190 @@ impl<'a> MarkdownParser<'a> {
             None
         };
 
-        ParsedMarkdownCodeBlock {
+        Some(ParsedMarkdownCodeBlock {
             source_range,
             contents: code.into(),
             language,
             highlights,
+        })
+    }
+
+    async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
+        let mut elements = Vec::new();
+        let Some((_event, _source_range)) = self.previous() else {
+            return elements;
+        };
+
+        while !self.eof() {
+            let Some((current, source_range)) = self.current() else {
+                break;
+            };
+            let source_range = source_range.clone();
+            match current {
+                Event::Html(html) => {
+                    let mut cursor = std::io::Cursor::new(html.as_bytes());
+                    let Some(dom) = parse_document(RcDom::default(), ParseOpts::default())
+                        .from_utf8()
+                        .read_from(&mut cursor)
+                        .ok()
+                    else {
+                        self.cursor += 1;
+                        continue;
+                    };
+
+                    self.cursor += 1;
+
+                    self.parse_html_node(source_range, &dom.document, &mut elements);
+                }
+                Event::End(TagEnd::CodeBlock) => {
+                    self.cursor += 1;
+                    break;
+                }
+                _ => {
+                    break;
+                }
+            }
+        }
+
+        elements
+    }
+
+    fn parse_html_node(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+        elements: &mut Vec<ParsedMarkdownElement>,
+    ) {
+        match &node.data {
+            markup5ever_rcdom::NodeData::Document => {
+                self.consume_children(source_range, node, elements);
+            }
+            markup5ever_rcdom::NodeData::Doctype { .. } => {}
+            markup5ever_rcdom::NodeData::Text { contents } => {
+                elements.push(ParsedMarkdownElement::Paragraph(vec![
+                    MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                        source_range,
+                        contents: contents.borrow().to_string(),
+                        highlights: Vec::default(),
+                        region_ranges: Vec::default(),
+                        regions: Vec::default(),
+                    }),
+                ]));
+            }
+            markup5ever_rcdom::NodeData::Comment { .. } => {}
+            markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
+                if local_name!("img") == name.local {
+                    if let Some(image) = self.extract_image(source_range, attrs) {
+                        elements.push(ParsedMarkdownElement::Image(image));
+                    }
+                } else {
+                    self.consume_children(source_range, node, elements);
+                }
+            }
+            markup5ever_rcdom::NodeData::ProcessingInstruction { .. } => {}
+        }
+    }
+
+    fn consume_children(
+        &self,
+        source_range: Range<usize>,
+        node: &Rc<markup5ever_rcdom::Node>,
+        elements: &mut Vec<ParsedMarkdownElement>,
+    ) {
+        for node in node.children.borrow().iter() {
+            self.parse_html_node(source_range.clone(), node, elements);
+        }
+    }
+
+    fn attr_value(
+        attrs: &RefCell<Vec<html5ever::Attribute>>,
+        name: html5ever::LocalName,
+    ) -> Option<String> {
+        attrs.borrow().iter().find_map(|attr| {
+            if attr.name.local == name {
+                Some(attr.value.to_string())
+            } else {
+                None
+            }
+        })
+    }
+
+    fn extract_styles_from_attributes(
+        attrs: &RefCell<Vec<html5ever::Attribute>>,
+    ) -> HashMap<String, String> {
+        let mut styles = HashMap::new();
+
+        if let Some(style) = Self::attr_value(attrs, local_name!("style")) {
+            for decl in style.split(';') {
+                let mut parts = decl.splitn(2, ':');
+                if let Some((key, value)) = parts.next().zip(parts.next()) {
+                    styles.insert(
+                        key.trim().to_lowercase().to_string(),
+                        value.trim().to_string(),
+                    );
+                }
+            }
+        }
+
+        styles
+    }
+
+    fn extract_image(
+        &self,
+        source_range: Range<usize>,
+        attrs: &RefCell<Vec<html5ever::Attribute>>,
+    ) -> Option<Image> {
+        let src = Self::attr_value(attrs, local_name!("src"))?;
+
+        let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?;
+
+        if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) {
+            image.set_alt_text(alt.into());
+        }
+
+        let styles = Self::extract_styles_from_attributes(attrs);
+
+        if let Some(width) = Self::attr_value(attrs, local_name!("width"))
+            .or_else(|| styles.get("width").cloned())
+            .and_then(|width| Self::parse_length(&width))
+        {
+            image.set_width(width);
+        }
+
+        if let Some(height) = Self::attr_value(attrs, local_name!("height"))
+            .or_else(|| styles.get("height").cloned())
+            .and_then(|height| Self::parse_length(&height))
+        {
+            image.set_height(height);
+        }
+
+        Some(image)
+    }
+
+    /// Parses the width/height attribute value of an html element (e.g. img element)
+    fn parse_length(value: &str) -> Option<DefiniteLength> {
+        if value.ends_with("%") {
+            value
+                .trim_end_matches("%")
+                .parse::<f32>()
+                .ok()
+                .map(|value| relative(value / 100.))
+        } else {
+            value
+                .trim_end_matches("px")
+                .parse()
+                .ok()
+                .map(|value| px(value).into())
         }
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use core::panic;
-
     use super::*;
-
     use ParsedMarkdownListItemType::*;
-    use gpui::BackgroundExecutor;
+    use core::panic;
+    use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
     use language::{
         HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, tree_sitter_rust,
     };
@@ -925,6 +1108,8 @@ mod tests {
                     url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
                 },
                 alt_text: Some("test".into()),
+                height: None,
+                width: None,
             },)
         );
     }
@@ -946,6 +1131,8 @@ mod tests {
                     url: "http://example.com/foo.png".to_string(),
                 },
                 alt_text: None,
+                height: None,
+                width: None,
             },)
         );
     }
@@ -965,6 +1152,8 @@ mod tests {
                     url: "http://example.com/foo.png".to_string(),
                 },
                 alt_text: Some("foo bar baz".into()),
+                height: None,
+                width: None,
             }),],
         );
     }
@@ -990,6 +1179,8 @@ mod tests {
                         url: "http://example.com/foo.png".to_string(),
                     },
                     alt_text: Some("foo".into()),
+                    height: None,
+                    width: None,
                 }),
                 MarkdownParagraphChunk::Text(ParsedMarkdownText {
                     source_range: 0..81,
@@ -1004,11 +1195,168 @@ mod tests {
                         url: "http://example.com/bar.png".to_string(),
                     },
                     alt_text: Some("bar".into()),
+                    height: None,
+                    width: None,
                 })
             ]
         );
     }
 
+    #[test]
+    fn test_parse_length() {
+        // Test percentage values
+        assert_eq!(
+            MarkdownParser::parse_length("50%"),
+            Some(DefiniteLength::Fraction(0.5))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("100%"),
+            Some(DefiniteLength::Fraction(1.0))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("25%"),
+            Some(DefiniteLength::Fraction(0.25))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("0%"),
+            Some(DefiniteLength::Fraction(0.0))
+        );
+
+        // Test pixel values
+        assert_eq!(
+            MarkdownParser::parse_length("100px"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("50px"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("0px"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
+        );
+
+        // Test values without units (should be treated as pixels)
+        assert_eq!(
+            MarkdownParser::parse_length("100"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("42"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
+        );
+
+        // Test invalid values
+        assert_eq!(MarkdownParser::parse_length("invalid"), None);
+        assert_eq!(MarkdownParser::parse_length("px"), None);
+        assert_eq!(MarkdownParser::parse_length("%"), None);
+        assert_eq!(MarkdownParser::parse_length(""), None);
+        assert_eq!(MarkdownParser::parse_length("abc%"), None);
+        assert_eq!(MarkdownParser::parse_length("abcpx"), None);
+
+        // Test decimal values
+        assert_eq!(
+            MarkdownParser::parse_length("50.5%"),
+            Some(DefiniteLength::Fraction(0.505))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("100.25px"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
+        );
+        assert_eq!(
+            MarkdownParser::parse_length("42.0"),
+            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
+        );
+    }
+
+    #[gpui::test]
+    async fn test_html_image_tag() {
+        let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
+
+        let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+            panic!("Expected a image element");
+        };
+        assert_eq!(
+            image.clone(),
+            Image {
+                source_range: 0..40,
+                link: Link::Web {
+                    url: "http://example.com/foo.png".to_string(),
+                },
+                alt_text: None,
+                height: None,
+                width: None,
+            },
+        );
+    }
+
+    #[gpui::test]
+    async fn test_html_image_tag_with_alt_text() {
+        let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
+
+        let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+            panic!("Expected a image element");
+        };
+        assert_eq!(
+            image.clone(),
+            Image {
+                source_range: 0..50,
+                link: Link::Web {
+                    url: "http://example.com/foo.png".to_string(),
+                },
+                alt_text: Some("Foo".into()),
+                height: None,
+                width: None,
+            },
+        );
+    }
+
+    #[gpui::test]
+    async fn test_html_image_tag_with_height_and_width() {
+        let parsed =
+            parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
+
+        let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+            panic!("Expected a image element");
+        };
+        assert_eq!(
+            image.clone(),
+            Image {
+                source_range: 0..65,
+                link: Link::Web {
+                    url: "http://example.com/foo.png".to_string(),
+                },
+                alt_text: None,
+                height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
+                width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
+            },
+        );
+    }
+
+    #[gpui::test]
+    async fn test_html_image_style_tag_with_height_and_width() {
+        let parsed = parse(
+            "<img src=\"http://example.com/foo.png\" style=\"height:100px; width:200px;\" />",
+        )
+        .await;
+
+        let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+            panic!("Expected a image element");
+        };
+        assert_eq!(
+            image.clone(),
+            Image {
+                source_range: 0..75,
+                link: Link::Web {
+                    url: "http://example.com/foo.png".to_string(),
+                },
+                alt_text: None,
+                height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
+                width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
+            },
+        );
+    }
+
     #[gpui::test]
     async fn test_header_only_table() {
         let markdown = "\

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -1,5 +1,5 @@
 use crate::markdown_elements::{
-    HeadingLevel, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
+    HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
     ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
     ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
     ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
@@ -164,6 +164,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte
         BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
         CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
         HorizontalRule(_) => render_markdown_rule(cx),
+        Image(image) => render_markdown_image(image, cx),
     }
 }
 
@@ -722,65 +723,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
             }
 
             MarkdownParagraphChunk::Image(image) => {
-                let image_resource = match image.link.clone() {
-                    Link::Web { url } => Resource::Uri(url.into()),
-                    Link::Path { path, .. } => Resource::Path(Arc::from(path)),
-                };
-
-                let element_id = cx.next_id(&image.source_range);
-
-                let image_element = div()
-                    .id(element_id)
-                    .cursor_pointer()
-                    .child(
-                        img(ImageSource::Resource(image_resource))
-                            .max_w_full()
-                            .with_fallback({
-                                let alt_text = image.alt_text.clone();
-                                move || div().children(alt_text.clone()).into_any_element()
-                            }),
-                    )
-                    .tooltip({
-                        let link = image.link.clone();
-                        move |_, cx| {
-                            InteractiveMarkdownElementTooltip::new(
-                                Some(link.to_string()),
-                                "open image",
-                                cx,
-                            )
-                            .into()
-                        }
-                    })
-                    .on_click({
-                        let workspace = workspace_clone.clone();
-                        let link = image.link.clone();
-                        move |_, window, cx| {
-                            if window.modifiers().secondary() {
-                                match &link {
-                                    Link::Web { url } => cx.open_url(url),
-                                    Link::Path { path, .. } => {
-                                        if let Some(workspace) = &workspace {
-                                            _ = workspace.update(cx, |workspace, cx| {
-                                                workspace
-                                                    .open_abs_path(
-                                                        path.clone(),
-                                                        OpenOptions {
-                                                            visible: Some(OpenVisible::None),
-                                                            ..Default::default()
-                                                        },
-                                                        window,
-                                                        cx,
-                                                    )
-                                                    .detach();
-                                            });
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    })
-                    .into_any();
-                any_element.push(image_element);
+                any_element.push(render_markdown_image(image, cx));
             }
         }
     }
@@ -793,18 +736,86 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
     div().py(cx.scaled_rems(0.5)).child(rule).into_any()
 }
 
+fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
+    let image_resource = match image.link.clone() {
+        Link::Web { url } => Resource::Uri(url.into()),
+        Link::Path { path, .. } => Resource::Path(Arc::from(path)),
+    };
+
+    let element_id = cx.next_id(&image.source_range);
+    let workspace = cx.workspace.clone();
+
+    div()
+        .id(element_id)
+        .cursor_pointer()
+        .child(
+            img(ImageSource::Resource(image_resource))
+                .max_w_full()
+                .with_fallback({
+                    let alt_text = image.alt_text.clone();
+                    move || div().children(alt_text.clone()).into_any_element()
+                })
+                .when_some(image.height, |this, height| this.h(height))
+                .when_some(image.width, |this, width| this.w(width)),
+        )
+        .tooltip({
+            let link = image.link.clone();
+            let alt_text = image.alt_text.clone();
+            move |_, cx| {
+                InteractiveMarkdownElementTooltip::new(
+                    Some(alt_text.clone().unwrap_or(link.to_string().into())),
+                    "open image",
+                    cx,
+                )
+                .into()
+            }
+        })
+        .on_click({
+            let link = image.link.clone();
+            move |_, window, cx| {
+                if window.modifiers().secondary() {
+                    match &link {
+                        Link::Web { url } => cx.open_url(url),
+                        Link::Path { path, .. } => {
+                            if let Some(workspace) = &workspace {
+                                _ = workspace.update(cx, |workspace, cx| {
+                                    workspace
+                                        .open_abs_path(
+                                            path.clone(),
+                                            OpenOptions {
+                                                visible: Some(OpenVisible::None),
+                                                ..Default::default()
+                                            },
+                                            window,
+                                            cx,
+                                        )
+                                        .detach();
+                                });
+                            }
+                        }
+                    }
+                }
+            }
+        })
+        .into_any()
+}
+
 struct InteractiveMarkdownElementTooltip {
     tooltip_text: Option<SharedString>,
-    action_text: String,
+    action_text: SharedString,
 }
 
 impl InteractiveMarkdownElementTooltip {
-    pub fn new(tooltip_text: Option<String>, action_text: &str, cx: &mut App) -> Entity<Self> {
+    pub fn new(
+        tooltip_text: Option<SharedString>,
+        action_text: impl Into<SharedString>,
+        cx: &mut App,
+    ) -> Entity<Self> {
         let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
 
         cx.new(|_cx| Self {
             tooltip_text,
-            action_text: action_text.to_string(),
+            action_text: action_text.into(),
         })
     }
 }