From 856ba20261697d63551ba913353e4db5951da961 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 13 Feb 2026 15:25:40 +0530 Subject: [PATCH] markdown_preview: Add Mermaid Diagram Support (#49064) 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 image Release Notes: - Added mermaid diagram rendering support to the markdown preview panel. --------- Co-authored-by: oscarvarto Co-authored-by: oscarvarto Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- Cargo.lock | 58 ++- Cargo.toml | 1 + crates/markdown_preview/Cargo.toml | 1 + .../markdown_preview/src/markdown_elements.rs | 15 + .../markdown_preview/src/markdown_parser.rs | 64 ++- .../src/markdown_preview_view.rs | 80 ++- .../markdown_preview/src/markdown_renderer.rs | 467 +++++++++++++++++- 7 files changed, 611 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f26ed1e1261e48386108e950dd1077e7795a1470..a520dec990c0ce2f361be6f7a4adc3de4366a0d4 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index 3c34f6ec3a2e34e42240f96f2715bd0b601adce9..3df9b505a0032ea0aafd7cdb9eca7d0707d5289a 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index c9cce94de1f10ac85a93663dea09a947586da282..55912c66a017fa22902f9b05e5fa924230710d69 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/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"] } diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 23e0a69b6addef4a963b81a67da198a7e2e1796f..1887da31621901fe7582192770018bd4e53a3c64 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/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), @@ -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, HighlightId)>>, } +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownMermaidDiagram { + pub source_range: Range, + 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 { diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index b17ee5cac455605ce49d0dd436d163e49f2954bd..59f18647d3ca8ac4937b2e411c8b9bb8e33550b7 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/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::().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, + ) -> Option { + 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 { let mut elements = Vec::new(); let Some((_event, _source_range)) = self.previous() else { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 650f369309561d76669289737277b45fb99af5ec..b3e6f2a9be7486b645e726f75c185d505d1fcba6 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/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, + mermaid_state: MermaidState, parsing_markdown_task: Option>>, 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()), ) }) diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 9bff5276bc7a115512d6b2fdff8e615a0b2b61c4..4d26b7e8958a04f1bb64abc5be5502e23896f313 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/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>; +type MermaidDiagramCache = HashMap; + +#[derive(Default)] +pub(crate) struct MermaidState { + cache: MermaidDiagramCache, + order: Vec, +} + +impl MermaidState { + fn get_fallback_image( + idx: usize, + old_order: &[ParsedMarkdownMermaidDiagramContents], + new_order_len: usize, + cache: &MermaidDiagramCache, + ) -> Option> { + // 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, + ) { + 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>>>, + pub(crate) fallback_image: Option>, + _task: Task<()>, +} + +impl CachedMermaidDiagram { + pub(crate) fn new( + contents: ParsedMarkdownMermaidDiagramContents, + fallback_image: Option>, + cx: &mut Context, + ) -> Self { + let result = Arc::new(OnceLock::>>::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>, + fallback_image: Option>, + ) -> 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>, next_id: usize, buffer_font_family: SharedString, @@ -58,14 +190,16 @@ pub struct RenderContext { indent: usize, checkbox_clicked_callback: Option, is_last_child: bool, + mermaid_state: &'a MermaidState, } -impl RenderContext { - pub fn new( +impl<'a> RenderContext<'a> { + pub(crate) fn new( workspace: Option>, + 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 gpui::AnyView>>, label: Option, - 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 { + 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> { + 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 { + 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" + ); + } }