markdown_preview: Add Mermaid Diagram Support (#49064)

Smit Barmase , oscarvarto , oscarvarto , and Piotr Osiewicz created

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

Adds support for rendering Mermaid diagrams in the markdown preview
using
[mermaid-rs-renderer](https://github.com/1jehuang/mermaid-rs-renderer)
(with [a
patch](https://github.com/1jehuang/mermaid-rs-renderer/pull/35)).

- Renders mermaid diagrams on background task
- Shows the previously cached image while re-computing
- Supports a scale parameter i.e. default 100
- Falls back to raw mermaid source code when render fails

<img width="1512" height="897" alt="image"
src="https://github.com/user-attachments/assets/9157625d-bb62-402f-8a8b-517f28d43f95"
/>

Release Notes:

- Added mermaid diagram rendering support to the markdown preview panel.

---------

Co-authored-by: oscarvarto <contact@oscarvarto.mx>
Co-authored-by: oscarvarto <oscarvarto@users.noreply.github.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

Cargo.lock                                           |  58 +
Cargo.toml                                           |   1 
crates/markdown_preview/Cargo.toml                   |   1 
crates/markdown_preview/src/markdown_elements.rs     |  15 
crates/markdown_preview/src/markdown_parser.rs       |  64 +
crates/markdown_preview/src/markdown_preview_view.rs |  80 +-
crates/markdown_preview/src/markdown_renderer.rs     | 467 +++++++++++++
7 files changed, 611 insertions(+), 75 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3887,7 +3887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b"
 dependencies = [
  "bitflags 2.10.0",
- "fontdb",
+ "fontdb 0.23.0",
  "harfrust",
  "linebender_resource_handle",
  "log",
@@ -6371,6 +6371,20 @@ dependencies = [
  "roxmltree",
 ]
 
+[[package]]
+name = "fontdb"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser 0.20.0",
+]
+
 [[package]]
 name = "fontdb"
 version = "0.23.0"
@@ -6382,7 +6396,7 @@ dependencies = [
  "memmap2",
  "slotmap",
  "tinyvec",
- "ttf-parser",
+ "ttf-parser 0.25.1",
 ]
 
 [[package]]
@@ -8344,7 +8358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
 dependencies = [
  "equivalent",
- "hashbrown 0.16.1",
+ "hashbrown 0.15.5",
  "serde",
  "serde_core",
 ]
@@ -8710,6 +8724,17 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "json5"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
+dependencies = [
+ "pest",
+ "pest_derive",
+ "serde",
+]
+
 [[package]]
 name = "json_dotpath"
 version = "1.1.0"
@@ -9842,6 +9867,7 @@ dependencies = [
  "linkify",
  "log",
  "markup5ever_rcdom",
+ "mermaid-rs-renderer",
  "pretty_assertions",
  "pulldown-cmark 0.13.0",
  "settings",
@@ -10055,6 +10081,22 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "mermaid-rs-renderer"
+version = "0.2.0"
+source = "git+https://github.com/zed-industries/mermaid-rs-renderer?branch=fix-font-family-xml-escaping#d91961aa90bc7b0c09c87a13c91d48e2f05c468d"
+dependencies = [
+ "anyhow",
+ "fontdb 0.16.2",
+ "json5",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.17",
+ "ttf-parser 0.20.0",
+]
+
 [[package]]
 name = "metal"
 version = "0.29.0"
@@ -14500,7 +14542,7 @@ dependencies = [
  "core_maths",
  "log",
  "smallvec",
- "ttf-parser",
+ "ttf-parser 0.25.1",
  "unicode-bidi-mirroring",
  "unicode-ccc",
  "unicode-properties",
@@ -17960,6 +18002,12 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
+[[package]]
+name = "ttf-parser"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+
 [[package]]
 name = "ttf-parser"
 version = "0.25.1"
@@ -18310,7 +18358,7 @@ dependencies = [
  "base64 0.22.1",
  "data-url",
  "flate2",
- "fontdb",
+ "fontdb 0.23.0",
  "imagesize",
  "kurbo",
  "log",

Cargo.toml 🔗

@@ -354,6 +354,7 @@ markdown_preview = { path = "crates/markdown_preview" }
 svg_preview = { path = "crates/svg_preview" }
 media = { path = "crates/media" }
 menu = { path = "crates/menu" }
+mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", branch = "fix-font-family-xml-escaping", default-features = false }
 migrator = { path = "crates/migrator" }
 mistral = { path = "crates/mistral" }
 multi_buffer = { path = "crates/multi_buffer" }

crates/markdown_preview/Cargo.toml 🔗

@@ -35,6 +35,7 @@ urlencoding.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
+mermaid-rs-renderer.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -14,6 +14,7 @@ pub enum ParsedMarkdownElement {
     Table(ParsedMarkdownTable),
     BlockQuote(ParsedMarkdownBlockQuote),
     CodeBlock(ParsedMarkdownCodeBlock),
+    MermaidDiagram(ParsedMarkdownMermaidDiagram),
     /// A paragraph of text and other inline elements.
     Paragraph(MarkdownParagraph),
     HorizontalRule(Range<usize>),
@@ -28,6 +29,7 @@ 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::MermaidDiagram(mermaid) => mermaid.source_range.clone(),
             Self::Paragraph(text) => match text.get(0)? {
                 MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
                 MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
@@ -86,6 +88,19 @@ pub struct ParsedMarkdownCodeBlock {
     pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
 }
 
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub struct ParsedMarkdownMermaidDiagram {
+    pub source_range: Range<usize>,
+    pub contents: ParsedMarkdownMermaidDiagramContents,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct ParsedMarkdownMermaidDiagramContents {
+    pub contents: SharedString,
+    pub scale: u32,
+}
+
 #[derive(Debug)]
 #[cfg_attr(test, derive(PartialEq))]
 pub struct ParsedMarkdownHeading {

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -196,21 +196,29 @@ impl<'a> MarkdownParser<'a> {
                     Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)])
                 }
                 Tag::CodeBlock(kind) => {
-                    let language = match kind {
-                        pulldown_cmark::CodeBlockKind::Indented => None,
+                    let (language, scale) = match kind {
+                        pulldown_cmark::CodeBlockKind::Indented => (None, None),
                         pulldown_cmark::CodeBlockKind::Fenced(language) => {
                             if language.is_empty() {
-                                None
+                                (None, None)
                             } else {
-                                Some(language.to_string())
+                                let parts: Vec<&str> = language.split_whitespace().collect();
+                                let lang = parts.first().map(|s| s.to_string());
+                                let scale = parts.get(1).and_then(|s| s.parse::<u32>().ok());
+                                (lang, scale)
                             }
                         }
                     };
 
                     self.cursor += 1;
 
-                    let code_block = self.parse_code_block(language).await?;
-                    Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
+                    if language.as_deref() == Some("mermaid") {
+                        let mermaid_diagram = self.parse_mermaid_diagram(scale).await?;
+                        Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)])
+                    } else {
+                        let code_block = self.parse_code_block(language).await?;
+                        Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
+                    }
                 }
                 Tag::HtmlBlock => {
                     self.cursor += 1;
@@ -806,6 +814,50 @@ impl<'a> MarkdownParser<'a> {
         })
     }
 
+    async fn parse_mermaid_diagram(
+        &mut self,
+        scale: Option<u32>,
+    ) -> Option<ParsedMarkdownMermaidDiagram> {
+        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 Some((current, _source_range)) = self.current() else {
+                break;
+            };
+
+            match current {
+                Event::Text(text) => {
+                    code.push_str(text);
+                    self.cursor += 1;
+                }
+                Event::End(TagEnd::CodeBlock) => {
+                    self.cursor += 1;
+                    break;
+                }
+                _ => {
+                    break;
+                }
+            }
+        }
+
+        code = code.strip_suffix('\n').unwrap_or(&code).to_string();
+
+        let scale = scale.unwrap_or(100).clamp(10, 500);
+
+        Some(ParsedMarkdownMermaidDiagram {
+            source_range,
+            contents: ParsedMarkdownMermaidDiagramContents {
+                contents: code.into(),
+                scale,
+            },
+        })
+    }
+
     async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
         let mut elements = Vec::new();
         let Some((_event, _source_range)) = self.previous() else {

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -19,7 +19,7 @@ use workspace::item::{Item, ItemHandle};
 use workspace::{Pane, Workspace};
 
 use crate::markdown_elements::ParsedMarkdownElement;
-use crate::markdown_renderer::CheckboxClickedEvent;
+use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
 use crate::{
     OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
     markdown_elements::ParsedMarkdown,
@@ -39,6 +39,7 @@ pub struct MarkdownPreviewView {
     selected_block: usize,
     list_state: ListState,
     language_registry: Arc<LanguageRegistry>,
+    mermaid_state: MermaidState,
     parsing_markdown_task: Option<Task<Result<()>>>,
     mode: MarkdownPreviewMode,
 }
@@ -214,6 +215,7 @@ impl MarkdownPreviewView {
                 contents: None,
                 list_state,
                 language_registry,
+                mermaid_state: Default::default(),
                 parsing_markdown_task: None,
                 image_cache: RetainAllImageCache::new(cx),
                 mode,
@@ -345,7 +347,9 @@ impl MarkdownPreviewView {
                 parse_markdown(&contents, file_location, Some(language_registry)).await
             });
             let contents = parsing_task.await;
+
             view.update(cx, move |view, cx| {
+                view.mermaid_state.update(&contents, cx);
                 let markdown_blocks_count = contents.children.len();
                 view.contents = Some(contents);
                 let scroll_top = view.list_state.logical_scroll_top();
@@ -571,39 +575,35 @@ impl Render for MarkdownPreviewView {
                                 return div().into_any();
                             };
 
-                            let mut render_cx =
-                                RenderContext::new(Some(this.workspace.clone()), window, cx)
-                                    .with_checkbox_clicked_callback(cx.listener(
-                                        move |this, e: &CheckboxClickedEvent, window, cx| {
-                                            if let Some(editor) = this
-                                                .active_editor
-                                                .as_ref()
-                                                .map(|s| s.editor.clone())
-                                            {
-                                                editor.update(cx, |editor, cx| {
-                                                    let task_marker =
-                                                        if e.checked() { "[x]" } else { "[ ]" };
-
-                                                    editor.edit(
-                                                        [(
-                                                            MultiBufferOffset(
-                                                                e.source_range().start,
-                                                            )
-                                                                ..MultiBufferOffset(
-                                                                    e.source_range().end,
-                                                                ),
-                                                            task_marker,
-                                                        )],
-                                                        cx,
-                                                    );
-                                                });
-                                                this.parse_markdown_from_active_editor(
-                                                    false, window, cx,
-                                                );
-                                                cx.notify();
-                                            }
-                                        },
-                                    ));
+                            let mut render_cx = RenderContext::new(
+                                Some(this.workspace.clone()),
+                                &this.mermaid_state,
+                                window,
+                                cx,
+                            )
+                            .with_checkbox_clicked_callback(cx.listener(
+                                move |this, e: &CheckboxClickedEvent, window, cx| {
+                                    if let Some(editor) =
+                                        this.active_editor.as_ref().map(|s| s.editor.clone())
+                                    {
+                                        editor.update(cx, |editor, cx| {
+                                            let task_marker =
+                                                if e.checked() { "[x]" } else { "[ ]" };
+
+                                            editor.edit(
+                                                [(
+                                                    MultiBufferOffset(e.source_range().start)
+                                                        ..MultiBufferOffset(e.source_range().end),
+                                                    task_marker,
+                                                )],
+                                                cx,
+                                            );
+                                        });
+                                        this.parse_markdown_from_active_editor(false, window, cx);
+                                        cx.notify();
+                                    }
+                                },
+                            ));
 
                             let block = contents.children.get(ix).unwrap();
                             let rendered_block = render_markdown_block(block, &mut render_cx);
@@ -613,6 +613,8 @@ impl Render for MarkdownPreviewView {
                                 contents.children.get(ix + 1),
                             );
 
+                            let selected_block = this.selected_block;
+                            let scaled_rems = render_cx.scaled_rems(1.0);
                             div()
                                 .id(ix)
                                 .when(should_apply_padding, |this| {
@@ -643,11 +645,11 @@ impl Render for MarkdownPreviewView {
                                     let indicator = div()
                                         .h_full()
                                         .w(px(4.0))
-                                        .when(ix == this.selected_block, |this| {
+                                        .when(ix == selected_block, |this| {
                                             this.bg(cx.theme().colors().border)
                                         })
                                         .group_hover("markdown-block", |s| {
-                                            if ix == this.selected_block {
+                                            if ix == selected_block {
                                                 s
                                             } else {
                                                 s.bg(cx.theme().colors().border_variant)
@@ -658,11 +660,7 @@ impl Render for MarkdownPreviewView {
                                     container.child(
                                         div()
                                             .relative()
-                                            .child(
-                                                div()
-                                                    .pl(render_cx.scaled_rems(1.0))
-                                                    .child(rendered_block),
-                                            )
+                                            .child(div().pl(scaled_rems).child(rendered_block))
                                             .child(indicator.absolute().left_0().top_0()),
                                     )
                                 })

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -1,20 +1,26 @@
-use crate::markdown_elements::{
-    HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
-    ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
-    ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
-    ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
+use crate::{
+    markdown_elements::{
+        HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
+        ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
+        ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType,
+        ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable,
+        ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
+    },
+    markdown_preview_view::MarkdownPreviewView,
 };
+use collections::HashMap;
 use fs::normalize_path;
 use gpui::{
-    AbsoluteLength, AnyElement, App, AppContext as _, Context, Div, Element, ElementId, Entity,
-    HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers,
-    ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, WeakEntity,
-    Window, div, img, rems,
+    AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
+    Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
+    Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
+    StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
 };
 use settings::Settings;
 use std::{
     ops::{Mul, Range},
-    sync::Arc,
+    sync::{Arc, OnceLock},
+    time::Duration,
     vec,
 };
 use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
@@ -38,8 +44,134 @@ impl CheckboxClickedEvent {
 
 type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
 
+type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, CachedMermaidDiagram>;
+
+#[derive(Default)]
+pub(crate) struct MermaidState {
+    cache: MermaidDiagramCache,
+    order: Vec<ParsedMarkdownMermaidDiagramContents>,
+}
+
+impl MermaidState {
+    fn get_fallback_image(
+        idx: usize,
+        old_order: &[ParsedMarkdownMermaidDiagramContents],
+        new_order_len: usize,
+        cache: &MermaidDiagramCache,
+    ) -> Option<Arc<RenderImage>> {
+        // When the diagram count changes e.g. addition or removal, positional matching
+        // is unreliable since a new diagram at index i likely doesn't correspond to the
+        // old diagram at index i. We only allow fallbacks when counts match, which covers
+        // the common case of editing a diagram in-place.
+        //
+        // Swapping two diagrams would briefly show the stale fallback, but that's an edge
+        // case we don't handle.
+        if old_order.len() != new_order_len {
+            return None;
+        }
+        old_order.get(idx).and_then(|old_content| {
+            cache.get(old_content).and_then(|old_cached| {
+                old_cached
+                    .render_image
+                    .get()
+                    .and_then(|result| result.as_ref().ok().cloned())
+                    // Chain fallbacks for rapid edits.
+                    .or_else(|| old_cached.fallback_image.clone())
+            })
+        })
+    }
+
+    pub(crate) fn update(
+        &mut self,
+        parsed: &ParsedMarkdown,
+        cx: &mut Context<MarkdownPreviewView>,
+    ) {
+        use crate::markdown_elements::ParsedMarkdownElement;
+        use std::collections::HashSet;
+
+        let mut new_order = Vec::new();
+        for element in parsed.children.iter() {
+            if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element {
+                new_order.push(mermaid_diagram.contents.clone());
+            }
+        }
+
+        for (idx, new_content) in new_order.iter().enumerate() {
+            if !self.cache.contains_key(new_content) {
+                let fallback =
+                    Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
+                self.cache.insert(
+                    new_content.clone(),
+                    CachedMermaidDiagram::new(new_content.clone(), fallback, cx),
+                );
+            }
+        }
+
+        let new_order_set: HashSet<_> = new_order.iter().cloned().collect();
+        self.cache
+            .retain(|content, _| new_order_set.contains(content));
+        self.order = new_order;
+    }
+}
+
+pub(crate) struct CachedMermaidDiagram {
+    pub(crate) render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
+    pub(crate) fallback_image: Option<Arc<RenderImage>>,
+    _task: Task<()>,
+}
+
+impl CachedMermaidDiagram {
+    pub(crate) fn new(
+        contents: ParsedMarkdownMermaidDiagramContents,
+        fallback_image: Option<Arc<RenderImage>>,
+        cx: &mut Context<MarkdownPreviewView>,
+    ) -> Self {
+        let result = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
+        let result_clone = result.clone();
+        let svg_renderer = cx.svg_renderer();
+
+        let _task = cx.spawn(async move |this, cx| {
+            let value = cx
+                .background_spawn(async move {
+                    let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
+                    let scale = contents.scale as f32 / 100.0;
+                    svg_renderer
+                        .render_single_frame(svg_string.as_bytes(), scale, true)
+                        .map_err(|e| anyhow::anyhow!("{}", e))
+                })
+                .await;
+            let _ = result_clone.set(value);
+            this.update(cx, |_, cx| {
+                cx.notify();
+            })
+            .ok();
+        });
+
+        Self {
+            render_image: result,
+            fallback_image,
+            _task,
+        }
+    }
+
+    #[cfg(test)]
+    fn new_for_test(
+        render_image: Option<Arc<RenderImage>>,
+        fallback_image: Option<Arc<RenderImage>>,
+    ) -> Self {
+        let result = Arc::new(OnceLock::new());
+        if let Some(img) = render_image {
+            let _ = result.set(Ok(img));
+        }
+        Self {
+            render_image: result,
+            fallback_image,
+            _task: Task::ready(()),
+        }
+    }
+}
 #[derive(Clone)]
-pub struct RenderContext {
+pub struct RenderContext<'a> {
     workspace: Option<WeakEntity<Workspace>>,
     next_id: usize,
     buffer_font_family: SharedString,
@@ -58,14 +190,16 @@ pub struct RenderContext {
     indent: usize,
     checkbox_clicked_callback: Option<CheckboxClickedCallback>,
     is_last_child: bool,
+    mermaid_state: &'a MermaidState,
 }
 
-impl RenderContext {
-    pub fn new(
+impl<'a> RenderContext<'a> {
+    pub(crate) fn new(
         workspace: Option<WeakEntity<Workspace>>,
+        mermaid_state: &'a MermaidState,
         window: &mut Window,
         cx: &mut App,
-    ) -> RenderContext {
+    ) -> Self {
         let theme = cx.theme().clone();
 
         let settings = ThemeSettings::get_global(cx);
@@ -95,6 +229,7 @@ impl RenderContext {
             code_span_background_color: theme.colors().editor_document_highlight_read_background,
             checkbox_clicked_callback: None,
             is_last_child: false,
+            mermaid_state,
         }
     }
 
@@ -163,7 +298,8 @@ pub fn render_parsed_markdown(
     window: &mut Window,
     cx: &mut App,
 ) -> Div {
-    let mut cx = RenderContext::new(workspace, window, cx);
+    let cache = Default::default();
+    let mut cx = RenderContext::new(workspace, &cache, window, cx);
 
     v_flex().gap_3().children(
         parsed
@@ -181,6 +317,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte
         Table(table) => render_markdown_table(table, cx),
         BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
         CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
+        MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx),
         HorizontalRule(_) => render_markdown_rule(cx),
         Image(image) => render_markdown_image(image, cx),
     }
@@ -320,7 +457,7 @@ struct MarkdownCheckbox {
     style: ui::ToggleStyle,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
     label: Option<SharedString>,
-    render_cx: RenderContext,
+    base_rem: Rems,
 }
 
 impl MarkdownCheckbox {
@@ -336,7 +473,7 @@ impl MarkdownCheckbox {
             tooltip: None,
             label: None,
             placeholder: false,
-            render_cx,
+            base_rem: render_cx.scaled_rems(1.0),
         }
     }
 
@@ -379,7 +516,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
         } else {
             Color::Selected
         };
-        let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small
+        let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small
         let icon = match self.toggle_state {
             ToggleState::Selected => {
                 if self.placeholder {
@@ -404,7 +541,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
         let border_color = self.border_color(cx);
         let hover_border_color = border_color.alpha(0.7);
 
-        let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px)
+        let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px)
 
         let checkbox = h_flex()
             .id(self.id.clone())
@@ -418,9 +555,9 @@ impl gpui::RenderOnce for MarkdownCheckbox {
                     .flex_none()
                     .justify_center()
                     .items_center()
-                    .m(self.render_cx.scaled_rems(0.25)) // was .m_1
-                    .size(self.render_cx.scaled_rems(1.0)) // was .size_4
-                    .rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs
+                    .m(self.base_rem.mul(0.25)) // was .m_1
+                    .size(self.base_rem.mul(1.0)) // was .size_4
+                    .rounded(self.base_rem.mul(0.125)) // was .rounded_xs
                     .border_1()
                     .bg(bg_color)
                     .border_color(border_color)
@@ -437,7 +574,7 @@ impl gpui::RenderOnce for MarkdownCheckbox {
                                 .flex_none()
                                 .rounded_full()
                                 .bg(color.color(cx).alpha(0.5))
-                                .size(self.render_cx.scaled_rems(0.25)), // was .size_1
+                                .size(self.base_rem.mul(0.25)), // was .size_1
                         )
                     })
                     .children(icon),
@@ -651,6 +788,89 @@ fn render_markdown_code_block(
         .into_any()
 }
 
+fn render_mermaid_diagram(
+    parsed: &ParsedMarkdownMermaidDiagram,
+    cx: &mut RenderContext,
+) -> AnyElement {
+    let cached = cx.mermaid_state.cache.get(&parsed.contents);
+
+    if let Some(result) = cached.and_then(|c| c.render_image.get()) {
+        match result {
+            Ok(render_image) => cx
+                .with_common_p(div())
+                .px_3()
+                .py_3()
+                .bg(cx.code_block_background_color)
+                .rounded_sm()
+                .child(
+                    div().w_full().child(
+                        img(ImageSource::Render(render_image.clone()))
+                            .max_w_full()
+                            .with_fallback(|| {
+                                div()
+                                    .child(Label::new("Failed to load mermaid diagram"))
+                                    .into_any_element()
+                            }),
+                    ),
+                )
+                .into_any(),
+            Err(_) => cx
+                .with_common_p(div())
+                .px_3()
+                .py_3()
+                .bg(cx.code_block_background_color)
+                .rounded_sm()
+                .child(StyledText::new(parsed.contents.contents.clone()))
+                .into_any(),
+        }
+    } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
+        cx.with_common_p(div())
+            .px_3()
+            .py_3()
+            .bg(cx.code_block_background_color)
+            .rounded_sm()
+            .child(
+                div()
+                    .w_full()
+                    .child(
+                        img(ImageSource::Render(fallback.clone()))
+                            .max_w_full()
+                            .with_fallback(|| {
+                                div()
+                                    .child(Label::new("Failed to load mermaid diagram"))
+                                    .into_any_element()
+                            }),
+                    )
+                    .with_animation(
+                        "mermaid-fallback-pulse",
+                        Animation::new(Duration::from_secs(2))
+                            .repeat()
+                            .with_easing(pulsating_between(0.6, 1.0)),
+                        |el, delta| el.opacity(delta),
+                    ),
+            )
+            .into_any()
+    } else {
+        cx.with_common_p(div())
+            .px_3()
+            .py_3()
+            .bg(cx.code_block_background_color)
+            .rounded_sm()
+            .child(
+                Label::new("Rendering mermaid diagram...")
+                    .color(Color::Muted)
+                    .with_animation(
+                        "mermaid-loading-pulse",
+                        Animation::new(Duration::from_secs(2))
+                            .repeat()
+                            .with_easing(pulsating_between(0.4, 0.8)),
+                        |label, delta| label.alpha(delta),
+                    ),
+            )
+            .into_any()
+    }
+}
+
 fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
     cx.with_common_p(div())
         .children(render_markdown_text(parsed, cx))
@@ -917,6 +1137,7 @@ fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
     use crate::markdown_elements::ParsedMarkdownTableColumn;
     use crate::markdown_elements::ParsedMarkdownText;
 
@@ -1074,4 +1295,204 @@ mod tests {
         assert_eq!(list_item_prefix(1, false, 3), "‣ ");
         assert_eq!(list_item_prefix(1, false, 4), "⁃ ");
     }
+
+    fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
+        ParsedMarkdownMermaidDiagramContents {
+            contents: SharedString::from(s.to_string()),
+            scale: 1,
+        }
+    }
+
+    fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
+        diagrams
+            .iter()
+            .map(|diagram| mermaid_contents(diagram))
+            .collect()
+    }
+
+    fn mermaid_fallback(
+        new_diagram: &str,
+        new_full_order: &[ParsedMarkdownMermaidDiagramContents],
+        old_full_order: &[ParsedMarkdownMermaidDiagramContents],
+        cache: &MermaidDiagramCache,
+    ) -> Option<Arc<RenderImage>> {
+        let new_content = mermaid_contents(new_diagram);
+        let idx = new_full_order
+            .iter()
+            .position(|content| content == &new_content)?;
+        MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
+    }
+
+    fn mock_render_image() -> Arc<RenderImage> {
+        Arc::new(RenderImage::new(Vec::new()))
+    }
+
+    #[test]
+    fn test_mermaid_fallback_on_edit() {
+        let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+
+        let svg_b = mock_render_image();
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph B"),
+            CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback =
+            mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
+
+        assert!(
+            fallback.is_some(),
+            "Should use old diagram as fallback when editing"
+        );
+        assert!(
+            Arc::ptr_eq(&fallback.unwrap(), &svg_b),
+            "Fallback should be the old diagram's SVG"
+        );
+    }
+
+    #[test]
+    fn test_mermaid_no_fallback_on_add_in_middle() {
+        let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
+
+        assert!(
+            fallback.is_none(),
+            "Should NOT use fallback when adding new diagram"
+        );
+    }
+
+    #[test]
+    fn test_mermaid_fallback_chains_on_rapid_edits() {
+        let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
+
+        let original_svg = mock_render_image();
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph B modified"),
+            // Still rendering, but has fallback from original "graph B"
+            CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback = mermaid_fallback(
+            "graph B modified again",
+            &new_full_order,
+            &old_full_order,
+            &cache,
+        );
+
+        assert!(
+            fallback.is_some(),
+            "Should chain fallback when previous render not complete"
+        );
+        assert!(
+            Arc::ptr_eq(&fallback.unwrap(), &original_svg),
+            "Fallback should chain through to the original SVG"
+        );
+    }
+
+    #[test]
+    fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
+        let old_full_order = mermaid_sequence(&["graph A"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
+
+        assert!(
+            fallback.is_none(),
+            "Should NOT have fallback when adding diagram at end"
+        );
+    }
+
+    #[test]
+    fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
+        let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
+        let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
+
+        let svg_a = mock_render_image();
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph B"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
+
+        assert!(
+            fallback.is_some(),
+            "Should use old diagram as fallback when editing one of duplicate blocks"
+        );
+        assert!(
+            Arc::ptr_eq(&fallback.unwrap(), &svg_a),
+            "Fallback should be the old duplicate diagram's image"
+        );
+    }
+
+    #[test]
+    fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
+        let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
+
+        let svg_a = mock_render_image();
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
+        );
+        cache.insert(
+            mermaid_contents("graph B"),
+            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
+        );
+
+        let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
+
+        assert!(
+            fallback.is_some(),
+            "Should use old diagram as fallback when editing the second duplicate block"
+        );
+        assert!(
+            Arc::ptr_eq(&fallback.unwrap(), &svg_a),
+            "Fallback should be the old duplicate diagram's image"
+        );
+    }
 }