Markdown preview image rendering (#21082)

Mikayla Maki , dovakin0007 , and dovakin0007 created

Closes https://github.com/zed-industries/zed/issues/13246

Supersedes: https://github.com/zed-industries/zed/pull/16192

I couldn't push to the git fork this user was using, so here's the exact
same PR but with some style nits implemented.


Release Notes:

- Added image rendering to the Markdown preview

---------

Co-authored-by: dovakin0007 <dovakin0007@gmail.com>
Co-authored-by: dovakin0007 <73059450+dovakin0007@users.noreply.github.com>

Change summary

crates/language/src/markdown.rs                  |  14 
crates/markdown_preview/src/markdown_elements.rs | 135 ++++++
crates/markdown_preview/src/markdown_parser.rs   | 215 +++++++----
crates/markdown_preview/src/markdown_renderer.rs | 330 ++++++++++++++---
crates/notifications/src/notification_store.rs   |   7 
crates/repl/src/notebook/cell.rs                 |   2 
crates/rich_text/src/rich_text.rs                |  14 
7 files changed, 537 insertions(+), 180 deletions(-)

Detailed changes

crates/language/src/markdown.rs 🔗

@@ -239,12 +239,7 @@ pub async fn parse_markdown_block(
             Event::Start(tag) => match tag {
                 Tag::Paragraph => new_paragraph(text, &mut list_stack),
 
-                Tag::Heading {
-                    level: _,
-                    id: _,
-                    classes: _,
-                    attrs: _,
-                } => {
+                Tag::Heading { .. } => {
                     new_paragraph(text, &mut list_stack);
                     bold_depth += 1;
                 }
@@ -267,12 +262,7 @@ pub async fn parse_markdown_block(
 
                 Tag::Strikethrough => strikethrough_depth += 1,
 
-                Tag::Link {
-                    link_type: _,
-                    dest_url,
-                    title: _,
-                    id: _,
-                } => link_url = Some(dest_url.to_string()),
+                Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
 
                 Tag::List(number) => {
                     list_stack.push((number, false));

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -13,7 +13,7 @@ pub enum ParsedMarkdownElement {
     BlockQuote(ParsedMarkdownBlockQuote),
     CodeBlock(ParsedMarkdownCodeBlock),
     /// A paragraph of text and other inline elements.
-    Paragraph(ParsedMarkdownText),
+    Paragraph(MarkdownParagraph),
     HorizontalRule(Range<usize>),
 }
 
@@ -25,7 +25,13 @@ impl ParsedMarkdownElement {
             Self::Table(table) => table.source_range.clone(),
             Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
             Self::CodeBlock(code_block) => code_block.source_range.clone(),
-            Self::Paragraph(text) => text.source_range.clone(),
+            Self::Paragraph(text) => match &text[0] {
+                MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
+                MarkdownParagraphChunk::Image(image) => match image {
+                    Image::Web { source_range, .. } => source_range.clone(),
+                    Image::Path { source_range, .. } => source_range.clone(),
+                },
+            },
             Self::HorizontalRule(range) => range.clone(),
         }
     }
@@ -35,6 +41,15 @@ impl ParsedMarkdownElement {
     }
 }
 
+pub type MarkdownParagraph = Vec<MarkdownParagraphChunk>;
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub enum MarkdownParagraphChunk {
+    Text(ParsedMarkdownText),
+    Image(Image),
+}
+
 #[derive(Debug)]
 #[cfg_attr(test, derive(PartialEq))]
 pub struct ParsedMarkdown {
@@ -73,7 +88,7 @@ pub struct ParsedMarkdownCodeBlock {
 pub struct ParsedMarkdownHeading {
     pub source_range: Range<usize>,
     pub level: HeadingLevel,
-    pub contents: ParsedMarkdownText,
+    pub contents: MarkdownParagraph,
 }
 
 #[derive(Debug, PartialEq)]
@@ -107,7 +122,7 @@ pub enum ParsedMarkdownTableAlignment {
 #[derive(Debug)]
 #[cfg_attr(test, derive(PartialEq))]
 pub struct ParsedMarkdownTableRow {
-    pub children: Vec<ParsedMarkdownText>,
+    pub children: Vec<MarkdownParagraph>,
 }
 
 impl Default for ParsedMarkdownTableRow {
@@ -123,7 +138,7 @@ impl ParsedMarkdownTableRow {
         }
     }
 
-    pub fn with_children(children: Vec<ParsedMarkdownText>) -> Self {
+    pub fn with_children(children: Vec<MarkdownParagraph>) -> Self {
         Self { children }
     }
 }
@@ -135,7 +150,7 @@ pub struct ParsedMarkdownBlockQuote {
     pub children: Vec<ParsedMarkdownElement>,
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct ParsedMarkdownText {
     /// Where the text is located in the source Markdown document.
     pub source_range: Range<usize>,
@@ -266,10 +281,112 @@ impl Display for Link {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             Link::Web { url } => write!(f, "{}", url),
-            Link::Path {
+            Link::Path { display_path, .. } => write!(f, "{}", display_path.display()),
+        }
+    }
+}
+
+/// A Markdown Image
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub enum Image {
+    Web {
+        source_range: Range<usize>,
+        /// The URL of the Image.
+        url: String,
+        /// Link URL if exists.
+        link: Option<Link>,
+        /// alt text if it exists
+        alt_text: Option<ParsedMarkdownText>,
+    },
+    ///  Image path on the filesystem.
+    Path {
+        source_range: Range<usize>,
+        /// The path as provided in the Markdown document.
+        display_path: PathBuf,
+        /// The absolute path to the item.
+        path: PathBuf,
+        /// Link URL if exists.
+        link: Option<Link>,
+        /// alt text if it exists
+        alt_text: Option<ParsedMarkdownText>,
+    },
+}
+
+impl Image {
+    pub fn identify(
+        source_range: Range<usize>,
+        file_location_directory: Option<PathBuf>,
+        text: String,
+        link: Option<Link>,
+    ) -> Option<Image> {
+        if text.starts_with("http") {
+            return Some(Image::Web {
+                source_range,
+                url: text,
+                link,
+                alt_text: None,
+            });
+        }
+        let path = PathBuf::from(&text);
+        if path.is_absolute() {
+            return Some(Image::Path {
+                source_range,
+                display_path: path.clone(),
+                path,
+                link,
+                alt_text: None,
+            });
+        }
+        if let Some(file_location_directory) = file_location_directory {
+            let display_path = path;
+            let path = file_location_directory.join(text);
+            return Some(Image::Path {
+                source_range,
                 display_path,
-                path: _,
-            } => write!(f, "{}", display_path.display()),
+                path,
+                link,
+                alt_text: None,
+            });
+        }
+        None
+    }
+
+    pub fn with_alt_text(&self, alt_text: ParsedMarkdownText) -> Self {
+        match self {
+            Image::Web {
+                ref source_range,
+                ref url,
+                ref link,
+                ..
+            } => Image::Web {
+                source_range: source_range.clone(),
+                url: url.clone(),
+                link: link.clone(),
+                alt_text: Some(alt_text),
+            },
+            Image::Path {
+                ref source_range,
+                ref display_path,
+                ref path,
+                ref link,
+                ..
+            } => Image::Path {
+                source_range: source_range.clone(),
+                display_path: display_path.clone(),
+                path: path.clone(),
+                link: link.clone(),
+                alt_text: Some(alt_text),
+            },
+        }
+    }
+}
+
+impl Display for Image {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Image::Web { url, .. } => write!(f, "{}", url),
+            Image::Path { display_path, .. } => write!(f, "{}", display_path.display()),
         }
     }
 }

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -4,7 +4,7 @@ use collections::FxHashMap;
 use gpui::FontWeight;
 use language::LanguageRegistry;
 use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
-use std::{ops::Range, path::PathBuf, sync::Arc};
+use std::{ops::Range, path::PathBuf, sync::Arc, vec};
 
 pub async fn parse_markdown(
     markdown_input: &str,
@@ -101,11 +101,11 @@ impl<'a> MarkdownParser<'a> {
             | Event::Code(_)
             | Event::Html(_)
             | Event::FootnoteReference(_)
-            | Event::Start(Tag::Link { link_type: _, dest_url: _, title: _, id: _ })
+            | Event::Start(Tag::Link { .. })
             | Event::Start(Tag::Emphasis)
             | Event::Start(Tag::Strong)
             | Event::Start(Tag::Strikethrough)
-            | Event::Start(Tag::Image { link_type: _, dest_url: _, title: _, id: _ }) => {
+            | Event::Start(Tag::Image { .. }) => {
                 true
             }
             _ => false,
@@ -134,12 +134,7 @@ impl<'a> MarkdownParser<'a> {
                     let text = self.parse_text(false, Some(source_range));
                     Some(vec![ParsedMarkdownElement::Paragraph(text)])
                 }
-                Tag::Heading {
-                    level,
-                    id: _,
-                    classes: _,
-                    attrs: _,
-                } => {
+                Tag::Heading { level, .. } => {
                     let level = *level;
                     self.cursor += 1;
                     let heading = self.parse_heading(level);
@@ -194,22 +189,23 @@ impl<'a> MarkdownParser<'a> {
         &mut self,
         should_complete_on_soft_break: bool,
         source_range: Option<Range<usize>>,
-    ) -> ParsedMarkdownText {
+    ) -> MarkdownParagraph {
         let source_range = source_range.unwrap_or_else(|| {
             self.current()
                 .map(|(_, range)| range.clone())
                 .unwrap_or_default()
         });
 
+        let mut markdown_text_like = Vec::new();
         let mut text = String::new();
         let mut bold_depth = 0;
         let mut italic_depth = 0;
         let mut strikethrough_depth = 0;
         let mut link: Option<Link> = None;
+        let mut image: Option<Image> = None;
         let mut region_ranges: Vec<Range<usize>> = vec![];
         let mut regions: Vec<ParsedRegion> = vec![];
         let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
-
         let mut link_urls: Vec<String> = vec![];
         let mut link_ranges: Vec<Range<usize>> = vec![];
 
@@ -225,8 +221,6 @@ impl<'a> MarkdownParser<'a> {
                     if should_complete_on_soft_break {
                         break;
                     }
-
-                    // `Some text\nSome more text` should be treated as a single line.
                     text.push(' ');
                 }
 
@@ -240,7 +234,6 @@ impl<'a> MarkdownParser<'a> {
 
                 Event::Text(t) => {
                     text.push_str(t.as_ref());
-
                     let mut style = MarkdownHighlightStyle::default();
 
                     if bold_depth > 0 {
@@ -299,7 +292,6 @@ impl<'a> MarkdownParser<'a> {
                                     url: link.as_str().to_string(),
                                 }),
                             });
-
                             last_link_len = end;
                         }
                         last_link_len
@@ -316,13 +308,63 @@ impl<'a> MarkdownParser<'a> {
                             }
                         }
                         if new_highlight {
-                            highlights
-                                .push((last_run_len..text.len(), MarkdownHighlight::Style(style)));
+                            highlights.push((
+                                last_run_len..text.len(),
+                                MarkdownHighlight::Style(style.clone()),
+                            ));
                         }
                     }
-                }
+                    if let Some(mut image) = image.clone() {
+                        let is_valid_image = match image.clone() {
+                            Image::Path { display_path, .. } => {
+                                gpui::ImageSource::try_from(display_path).is_ok()
+                            }
+                            Image::Web { url, .. } => gpui::ImageSource::try_from(url).is_ok(),
+                        };
+                        if is_valid_image {
+                            text.truncate(text.len() - t.len());
+                            if !t.is_empty() {
+                                let alt_text = ParsedMarkdownText {
+                                    source_range: source_range.clone(),
+                                    contents: t.to_string(),
+                                    highlights: highlights.clone(),
+                                    region_ranges: region_ranges.clone(),
+                                    regions: regions.clone(),
+                                };
+                                image = image.with_alt_text(alt_text);
+                            } else {
+                                let alt_text = ParsedMarkdownText {
+                                    source_range: source_range.clone(),
+                                    contents: "img".to_string(),
+                                    highlights: highlights.clone(),
+                                    region_ranges: region_ranges.clone(),
+                                    regions: regions.clone(),
+                                };
+                                image = image.with_alt_text(alt_text);
+                            }
+                            if !text.is_empty() {
+                                let parsed_regions =
+                                    MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                                        source_range: source_range.clone(),
+                                        contents: text.clone(),
+                                        highlights: highlights.clone(),
+                                        region_ranges: region_ranges.clone(),
+                                        regions: regions.clone(),
+                                    });
+                                text = String::new();
+                                highlights = vec![];
+                                region_ranges = vec![];
+                                regions = vec![];
+                                markdown_text_like.push(parsed_regions);
+                            }
 
-                // Note: This event means "inline code" and not "code block"
+                            let parsed_image = MarkdownParagraphChunk::Image(image.clone());
+                            markdown_text_like.push(parsed_image);
+                            style = MarkdownHighlightStyle::default();
+                        }
+                        style.underline = true;
+                    };
+                }
                 Event::Code(t) => {
                     text.push_str(t.as_ref());
                     region_ranges.push(prev_len..text.len());
@@ -336,46 +378,44 @@ impl<'a> MarkdownParser<'a> {
                             }),
                         ));
                     }
-
                     regions.push(ParsedRegion {
                         code: true,
                         link: link.clone(),
                     });
                 }
-
                 Event::Start(tag) => match tag {
                     Tag::Emphasis => italic_depth += 1,
                     Tag::Strong => bold_depth += 1,
                     Tag::Strikethrough => strikethrough_depth += 1,
-                    Tag::Link {
-                        link_type: _,
-                        dest_url,
-                        title: _,
-                        id: _,
-                    } => {
+                    Tag::Link { dest_url, .. } => {
                         link = Link::identify(
                             self.file_location_directory.clone(),
                             dest_url.to_string(),
                         );
                     }
+                    Tag::Image { dest_url, .. } => {
+                        image = Image::identify(
+                            source_range.clone(),
+                            self.file_location_directory.clone(),
+                            dest_url.to_string(),
+                            link.clone(),
+                        );
+                    }
                     _ => {
                         break;
                     }
                 },
 
                 Event::End(tag) => match tag {
-                    TagEnd::Emphasis => {
-                        italic_depth -= 1;
-                    }
-                    TagEnd::Strong => {
-                        bold_depth -= 1;
-                    }
-                    TagEnd::Strikethrough => {
-                        strikethrough_depth -= 1;
-                    }
+                    TagEnd::Emphasis => italic_depth -= 1,
+                    TagEnd::Strong => bold_depth -= 1,
+                    TagEnd::Strikethrough => strikethrough_depth -= 1,
                     TagEnd::Link => {
                         link = None;
                     }
+                    TagEnd::Image => {
+                        image = None;
+                    }
                     TagEnd::Paragraph => {
                         self.cursor += 1;
                         break;
@@ -384,7 +424,6 @@ impl<'a> MarkdownParser<'a> {
                         break;
                     }
                 },
-
                 _ => {
                     break;
                 }
@@ -392,14 +431,16 @@ impl<'a> MarkdownParser<'a> {
 
             self.cursor += 1;
         }
-
-        ParsedMarkdownText {
-            source_range,
-            contents: text,
-            highlights,
-            regions,
-            region_ranges,
+        if !text.is_empty() {
+            markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
+                source_range: source_range.clone(),
+                contents: text,
+                highlights,
+                regions,
+                region_ranges,
+            }));
         }
+        markdown_text_like
     }
 
     fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading {
@@ -708,7 +749,6 @@ impl<'a> MarkdownParser<'a> {
                 }
             }
         }
-
         let highlights = if let Some(language) = &language {
             if let Some(registry) = &self.language_registry {
                 let rope: language::Rope = code.as_str().into();
@@ -735,10 +775,14 @@ impl<'a> MarkdownParser<'a> {
 
 #[cfg(test)]
 mod tests {
+    use core::panic;
+
     use super::*;
 
     use gpui::BackgroundExecutor;
-    use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher};
+    use language::{
+        tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
+    };
     use pretty_assertions::assert_eq;
     use ParsedMarkdownListItemType::*;
 
@@ -810,20 +854,29 @@ mod tests {
         assert_eq!(parsed.children.len(), 1);
         assert_eq!(
             parsed.children[0],
-            ParsedMarkdownElement::Paragraph(ParsedMarkdownText {
-                source_range: 0..35,
-                contents: "Some bostrikethroughld text".to_string(),
-                highlights: Vec::new(),
-                region_ranges: Vec::new(),
-                regions: Vec::new(),
-            })
+            ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text(
+                ParsedMarkdownText {
+                    source_range: 0..35,
+                    contents: "Some bostrikethroughld text".to_string(),
+                    highlights: Vec::new(),
+                    region_ranges: Vec::new(),
+                    regions: Vec::new(),
+                }
+            )])
         );
 
-        let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
+        let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
             text
         } else {
             panic!("Expected a paragraph");
         };
+
+        let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] {
+            text
+        } else {
+            panic!("Expected a text");
+        };
+
         assert_eq!(
             paragraph.highlights,
             vec![
@@ -871,6 +924,11 @@ mod tests {
             parsed.children,
             vec![p("Checkout this https://zed.dev link", 0..34)]
         );
+    }
+
+    #[gpui::test]
+    async fn test_image_links_detection() {
+        let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await;
 
         let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
             text
@@ -878,25 +936,22 @@ mod tests {
             panic!("Expected a paragraph");
         };
         assert_eq!(
-            paragraph.highlights,
-            vec![(
-                14..29,
-                MarkdownHighlight::Style(MarkdownHighlightStyle {
-                    underline: true,
-                    ..Default::default()
-                }),
-            )]
+            paragraph[0],
+            MarkdownParagraphChunk::Image(Image::Web {
+                source_range: 0..111,
+                url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
+                link: None,
+                alt_text: Some(
+                        ParsedMarkdownText {
+                        source_range: 0..111,
+                       contents: "test".to_string(),
+                       highlights: vec![],
+                     region_ranges: vec![],
+                      regions: vec![],
+                 },
+                  ),
+            },)
         );
-        assert_eq!(
-            paragraph.regions,
-            vec![ParsedRegion {
-                code: false,
-                link: Some(Link::Web {
-                    url: "https://zed.dev".to_string()
-                }),
-            }]
-        );
-        assert_eq!(paragraph.region_ranges, vec![14..29]);
     }
 
     #[gpui::test]
@@ -1169,7 +1224,7 @@ Some other content
             vec![
                 list_item(0..8, 1, Unordered, vec![p("code", 2..8)]),
                 list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]),
-                list_item(20..49, 1, Unordered, vec![p("link", 22..49)],)
+                list_item(20..49, 1, Unordered, vec![p("link", 22..49)],),
             ],
         );
     }
@@ -1312,7 +1367,7 @@ fn main() {
         ))
     }
 
-    fn h1(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
+    fn h1(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
         ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
             source_range,
             level: HeadingLevel::H1,
@@ -1320,7 +1375,7 @@ fn main() {
         })
     }
 
-    fn h2(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
+    fn h2(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
         ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
             source_range,
             level: HeadingLevel::H2,
@@ -1328,7 +1383,7 @@ fn main() {
         })
     }
 
-    fn h3(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
+    fn h3(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
         ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
             source_range,
             level: HeadingLevel::H3,
@@ -1340,14 +1395,14 @@ fn main() {
         ParsedMarkdownElement::Paragraph(text(contents, source_range))
     }
 
-    fn text(contents: &str, source_range: Range<usize>) -> ParsedMarkdownText {
-        ParsedMarkdownText {
+    fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
+        vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
             highlights: Vec::new(),
             region_ranges: Vec::new(),
             regions: Vec::new(),
             source_range,
             contents: contents.to_string(),
-        }
+        })]
     }
 
     fn block_quote(
@@ -1401,7 +1456,7 @@ fn main() {
         }
     }
 
-    fn row(children: Vec<ParsedMarkdownText>) -> ParsedMarkdownTableRow {
+    fn row(children: Vec<MarkdownParagraph>) -> ParsedMarkdownTableRow {
         ParsedMarkdownTableRow { children }
     }
 

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -1,29 +1,33 @@
 use crate::markdown_elements::{
-    HeadingLevel, Link, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock,
-    ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownListItem,
-    ParsedMarkdownListItemType, ParsedMarkdownTable, ParsedMarkdownTableAlignment,
-    ParsedMarkdownTableRow, ParsedMarkdownText,
+    HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
+    ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
+    ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
+    ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText,
 };
 use gpui::{
-    div, px, rems, AbsoluteLength, AnyElement, ClipboardItem, DefiniteLength, Div, Element,
-    ElementId, HighlightStyle, Hsla, InteractiveText, IntoElement, Keystroke, Length, Modifiers,
-    ParentElement, SharedString, Styled, StyledText, TextStyle, WeakView, WindowContext,
+    div, img, px, rems, AbsoluteLength, AnyElement, ClipboardItem, DefiniteLength, Div, Element,
+    ElementId, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Length,
+    Modifiers, ParentElement, Resource, SharedString, Styled, StyledText, TextStyle, WeakView,
+    WindowContext,
 };
 use settings::Settings;
 use std::{
     ops::{Mul, Range},
+    path::Path,
     sync::Arc,
+    vec,
 };
 use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
 use ui::{
     h_flex, relative, v_flex, Checkbox, Clickable, FluentBuilder, IconButton, IconName, IconSize,
-    InteractiveElement, LinkPreview, Selection, StatefulInteractiveElement, StyledExt, Tooltip,
-    VisibleOnHover,
+    InteractiveElement, LinkPreview, Selection, StatefulInteractiveElement, StyledExt, StyledImage,
+    Tooltip, VisibleOnHover,
 };
 use workspace::Workspace;
 
 type CheckboxClickedCallback = Arc<Box<dyn Fn(bool, Range<usize>, &mut WindowContext)>>;
 
+#[derive(Clone)]
 pub struct RenderContext {
     workspace: Option<WeakView<Workspace>>,
     next_id: usize,
@@ -153,7 +157,7 @@ fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContex
         .text_color(color)
         .pt(rems(0.15))
         .pb_1()
-        .child(render_markdown_text(&parsed.contents, cx))
+        .children(render_markdown_text(&parsed.contents, cx))
         .whitespace_normal()
         .into_any()
 }
@@ -231,17 +235,29 @@ fn render_markdown_list_item(
     cx.with_common_p(item).into_any()
 }
 
+fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
+    paragraphs
+        .iter()
+        .map(|paragraph| match paragraph {
+            MarkdownParagraphChunk::Text(text) => text.contents.len(),
+            // TODO: Scale column width based on image size
+            MarkdownParagraphChunk::Image(_) => 1,
+        })
+        .sum()
+}
+
 fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
     let mut max_lengths: Vec<usize> = vec![0; parsed.header.children.len()];
 
     for (index, cell) in parsed.header.children.iter().enumerate() {
-        let length = cell.contents.len();
+        let length = paragraph_len(&cell);
         max_lengths[index] = length;
     }
 
     for row in &parsed.body {
         for (index, cell) in row.children.iter().enumerate() {
-            let length = cell.contents.len();
+            let length = paragraph_len(&cell);
+
             if length > max_lengths[index] {
                 max_lengths[index] = length;
             }
@@ -307,11 +323,10 @@ fn render_markdown_table_row(
         };
 
         let max_width = max_column_widths.get(index).unwrap_or(&0.0);
-
         let mut cell = container
             .w(Length::Definite(relative(*max_width)))
             .h_full()
-            .child(contents)
+            .children(contents)
             .px_2()
             .py_1()
             .border_color(cx.border_color);
@@ -398,18 +413,219 @@ fn render_markdown_code_block(
         .into_any()
 }
 
-fn render_markdown_paragraph(parsed: &ParsedMarkdownText, cx: &mut RenderContext) -> AnyElement {
+fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
     cx.with_common_p(div())
-        .child(render_markdown_text(parsed, cx))
+        .children(render_markdown_text(parsed, cx))
+        .flex()
         .into_any_element()
 }
 
-fn render_markdown_text(parsed: &ParsedMarkdownText, cx: &mut RenderContext) -> AnyElement {
-    let element_id = cx.next_id(&parsed.source_range);
+fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
+    let mut any_element = vec![];
+    // these values are cloned in-order satisfy borrow checker
+    let syntax_theme = cx.syntax_theme.clone();
+    let workspace_clone = cx.workspace.clone();
+    let code_span_bg_color = cx.code_span_background_color;
+    let text_style = cx.text_style.clone();
+
+    for parsed_region in parsed_new {
+        match parsed_region {
+            MarkdownParagraphChunk::Text(parsed) => {
+                let element_id = cx.next_id(&parsed.source_range);
+
+                let highlights = gpui::combine_highlights(
+                    parsed.highlights.iter().filter_map(|(range, highlight)| {
+                        highlight
+                            .to_highlight_style(&syntax_theme)
+                            .map(|style| (range.clone(), style))
+                    }),
+                    parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
+                        |(region, range)| {
+                            if region.code {
+                                Some((
+                                    range.clone(),
+                                    HighlightStyle {
+                                        background_color: Some(code_span_bg_color),
+                                        ..Default::default()
+                                    },
+                                ))
+                            } else {
+                                None
+                            }
+                        },
+                    ),
+                );
+                let mut links = Vec::new();
+                let mut link_ranges = Vec::new();
+                for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
+                    if let Some(link) = region.link.clone() {
+                        links.push(link);
+                        link_ranges.push(range.clone());
+                    }
+                }
+                let workspace = workspace_clone.clone();
+                let element = div()
+                    .child(
+                        InteractiveText::new(
+                            element_id,
+                            StyledText::new(parsed.contents.clone())
+                                .with_highlights(&text_style, highlights),
+                        )
+                        .tooltip({
+                            let links = links.clone();
+                            let link_ranges = link_ranges.clone();
+                            move |idx, cx| {
+                                for (ix, range) in link_ranges.iter().enumerate() {
+                                    if range.contains(&idx) {
+                                        return Some(LinkPreview::new(&links[ix].to_string(), cx));
+                                    }
+                                }
+                                None
+                            }
+                        })
+                        .on_click(
+                            link_ranges,
+                            move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
+                                Link::Web { url } => window_cx.open_url(url),
+                                Link::Path { path, .. } => {
+                                    if let Some(workspace) = &workspace {
+                                        _ = workspace.update(window_cx, |workspace, cx| {
+                                            workspace
+                                                .open_abs_path(path.clone(), false, cx)
+                                                .detach();
+                                        });
+                                    }
+                                }
+                            },
+                        ),
+                    )
+                    .into_any();
+                any_element.push(element);
+            }
+
+            MarkdownParagraphChunk::Image(image) => {
+                let (link, source_range, image_source, alt_text) = match image {
+                    Image::Web {
+                        link,
+                        source_range,
+                        url,
+                        alt_text,
+                    } => (
+                        link,
+                        source_range,
+                        Resource::Uri(url.clone().into()),
+                        alt_text,
+                    ),
+                    Image::Path {
+                        link,
+                        source_range,
+                        path,
+                        alt_text,
+                        ..
+                    } => {
+                        let image_path = Path::new(path.to_str().unwrap());
+                        (
+                            link,
+                            source_range,
+                            Resource::Path(Arc::from(image_path)),
+                            alt_text,
+                        )
+                    }
+                };
+
+                let element_id = cx.next_id(source_range);
+
+                match link {
+                    None => {
+                        let fallback_workspace = workspace_clone.clone();
+                        let fallback_syntax_theme = syntax_theme.clone();
+                        let fallback_text_style = text_style.clone();
+                        let fallback_alt_text = alt_text.clone();
+                        let element_id_new = element_id.clone();
+                        let element = div()
+                            .child(img(ImageSource::Resource(image_source)).with_fallback({
+                                move || {
+                                    fallback_text(
+                                        fallback_alt_text.clone().unwrap(),
+                                        element_id.clone(),
+                                        &fallback_syntax_theme,
+                                        code_span_bg_color,
+                                        fallback_workspace.clone(),
+                                        &fallback_text_style,
+                                    )
+                                }
+                            }))
+                            .id(element_id_new)
+                            .into_any();
+                        any_element.push(element);
+                    }
+                    Some(link) => {
+                        let link_click = link.clone();
+                        let link_tooltip = link.clone();
+                        let fallback_workspace = workspace_clone.clone();
+                        let fallback_syntax_theme = syntax_theme.clone();
+                        let fallback_text_style = text_style.clone();
+                        let fallback_alt_text = alt_text.clone();
+                        let element_id_new = element_id.clone();
+                        let image_element = div()
+                            .child(img(ImageSource::Resource(image_source)).with_fallback({
+                                move || {
+                                    fallback_text(
+                                        fallback_alt_text.clone().unwrap(),
+                                        element_id.clone(),
+                                        &fallback_syntax_theme,
+                                        code_span_bg_color,
+                                        fallback_workspace.clone(),
+                                        &fallback_text_style,
+                                    )
+                                }
+                            }))
+                            .id(element_id_new)
+                            .tooltip(move |cx| LinkPreview::new(&link_tooltip.to_string(), cx))
+                            .on_click({
+                                let workspace = workspace_clone.clone();
+                                move |_event, window_cx| match &link_click {
+                                    Link::Web { url } => window_cx.open_url(url),
+                                    Link::Path { path, .. } => {
+                                        if let Some(workspace) = &workspace {
+                                            _ = workspace.update(window_cx, |workspace, cx| {
+                                                workspace
+                                                    .open_abs_path(path.clone(), false, cx)
+                                                    .detach();
+                                            });
+                                        }
+                                    }
+                                }
+                            })
+                            .into_any();
+                        any_element.push(image_element);
+                    }
+                }
+            }
+        }
+    }
+
+    any_element
+}
+
+fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
+    let rule = div().w_full().h(px(2.)).bg(cx.border_color);
+    div().pt_3().pb_3().child(rule).into_any()
+}
+
+fn fallback_text(
+    parsed: ParsedMarkdownText,
+    source_range: ElementId,
+    syntax_theme: &theme::SyntaxTheme,
+    code_span_bg_color: Hsla,
+    workspace: Option<WeakView<Workspace>>,
+    text_style: &TextStyle,
+) -> AnyElement {
+    let element_id = source_range;
 
     let highlights = gpui::combine_highlights(
         parsed.highlights.iter().filter_map(|(range, highlight)| {
-            let highlight = highlight.to_highlight_style(&cx.syntax_theme)?;
+            let highlight = highlight.to_highlight_style(syntax_theme)?;
             Some((range.clone(), highlight))
         }),
         parsed
@@ -421,7 +637,7 @@ fn render_markdown_text(parsed: &ParsedMarkdownText, cx: &mut RenderContext) ->
                     Some((
                         range.clone(),
                         HighlightStyle {
-                            background_color: Some(cx.code_span_background_color),
+                            background_color: Some(code_span_bg_color),
                             ..Default::default()
                         },
                     ))
@@ -430,7 +646,6 @@ fn render_markdown_text(parsed: &ParsedMarkdownText, cx: &mut RenderContext) ->
                 }
             }),
     );
-
     let mut links = Vec::new();
     let mut link_ranges = Vec::new();
     for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
@@ -439,45 +654,38 @@ fn render_markdown_text(parsed: &ParsedMarkdownText, cx: &mut RenderContext) ->
             link_ranges.push(range.clone());
         }
     }
-
-    let workspace = cx.workspace.clone();
-
-    InteractiveText::new(
-        element_id,
-        StyledText::new(parsed.contents.clone()).with_highlights(&cx.text_style, highlights),
-    )
-    .tooltip({
-        let links = links.clone();
-        let link_ranges = link_ranges.clone();
-        move |idx, cx| {
-            for (ix, range) in link_ranges.iter().enumerate() {
-                if range.contains(&idx) {
-                    return Some(LinkPreview::new(&links[ix].to_string(), cx));
-                }
-            }
-            None
-        }
-    })
-    .on_click(
-        link_ranges,
-        move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
-            Link::Web { url } => window_cx.open_url(url),
-            Link::Path {
-                path,
-                display_path: _,
-            } => {
-                if let Some(workspace) = &workspace {
-                    _ = workspace.update(window_cx, |workspace, cx| {
-                        workspace.open_abs_path(path.clone(), false, cx).detach();
-                    });
+    let element = div()
+        .child(
+            InteractiveText::new(
+                element_id,
+                StyledText::new(parsed.contents.clone()).with_highlights(text_style, highlights),
+            )
+            .tooltip({
+                let links = links.clone();
+                let link_ranges = link_ranges.clone();
+                move |idx, cx| {
+                    for (ix, range) in link_ranges.iter().enumerate() {
+                        if range.contains(&idx) {
+                            return Some(LinkPreview::new(&links[ix].to_string(), cx));
+                        }
+                    }
+                    None
                 }
-            }
-        },
-    )
-    .into_any_element()
-}
-
-fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
-    let rule = div().w_full().h(px(2.)).bg(cx.border_color);
-    div().pt_3().pb_3().child(rule).into_any()
+            })
+            .on_click(
+                link_ranges,
+                move |clicked_range_ix, window_cx| match &links[clicked_range_ix] {
+                    Link::Web { url } => window_cx.open_url(url),
+                    Link::Path { path, .. } => {
+                        if let Some(workspace) = &workspace {
+                            _ = workspace.update(window_cx, |workspace, cx| {
+                                workspace.open_abs_path(path.clone(), false, cx).detach();
+                            });
+                        }
+                    }
+                },
+            ),
+        )
+        .into_any();
+    return element;
 }

crates/notifications/src/notification_store.rs 🔗

@@ -238,11 +238,8 @@ impl NotificationStore {
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
             if let Some(notification) = envelope.payload.notification {
-                if let Some(rpc::Notification::ChannelMessageMention {
-                    message_id,
-                    sender_id: _,
-                    channel_id: _,
-                }) = Notification::from_proto(&notification)
+                if let Some(rpc::Notification::ChannelMessageMention { message_id, .. }) =
+                    Notification::from_proto(&notification)
                 {
                     let fetch_message_task = this.channel_store.update(cx, |this, cx| {
                         this.fetch_channel_messages(vec![message_id], cx)

crates/rich_text/src/rich_text.rs 🔗

@@ -310,12 +310,7 @@ pub fn render_markdown_mut(
             }
             Event::Start(tag) => match tag {
                 Tag::Paragraph => new_paragraph(text, &mut list_stack),
-                Tag::Heading {
-                    level: _,
-                    id: _,
-                    classes: _,
-                    attrs: _,
-                } => {
+                Tag::Heading { .. } => {
                     new_paragraph(text, &mut list_stack);
                     bold_depth += 1;
                 }
@@ -333,12 +328,7 @@ pub fn render_markdown_mut(
                 Tag::Emphasis => italic_depth += 1,
                 Tag::Strong => bold_depth += 1,
                 Tag::Strikethrough => strikethrough_depth += 1,
-                Tag::Link {
-                    link_type: _,
-                    dest_url,
-                    title: _,
-                    id: _,
-                } => link_url = Some(dest_url.to_string()),
+                Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
                 Tag::List(number) => {
                     list_stack.push((number, false));
                 }