From 2a347a79820b637290c3b376bc923625338754be Mon Sep 17 00:00:00 2001 From: Gery Wibowo Date: Thu, 12 Feb 2026 14:52:53 +0700 Subject: [PATCH] Add Mermaid diagram preview crate Introduce a new `mermaid_preview` crate that renders live previews of Mermaid diagram files (.mmd, .mermaid) using `mermaid-rs-renderer`. Includes keybindings for opening the preview in-place or in a side split on all platforms. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 69 ++++ Cargo.toml | 3 + assets/keymaps/default-linux.json | 8 + assets/keymaps/default-macos.json | 8 + assets/keymaps/default-windows.json | 8 + crates/mermaid_preview/Cargo.toml | 23 ++ crates/mermaid_preview/src/mermaid_preview.rs | 24 ++ .../src/mermaid_preview_view.rs | 355 ++++++++++++++++++ crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed_actions/src/lib.rs | 14 + 11 files changed, 514 insertions(+) create mode 100644 crates/mermaid_preview/Cargo.toml create mode 100644 crates/mermaid_preview/src/mermaid_preview.rs create mode 100644 crates/mermaid_preview/src/mermaid_preview_view.rs diff --git a/Cargo.lock b/Cargo.lock index 1d1712b6f28894..953827f094a951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4521,6 +4521,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dagre_rust" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0e44f22d2a32979a1265aa1a70f6e2f4f43ec0d562d483a1c818f9a4121926" +dependencies = [ + "graphlib_rust", + "ordered_hashmap", +] + [[package]] name = "dap" version = "0.1.0" @@ -7563,6 +7573,15 @@ dependencies = [ "util", ] +[[package]] +name = "graphlib_rust" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47de2c5685b5bb3689d6b0977f38959f3d7d82d3556627cfebbfaa42eda05ea" +dependencies = [ + "ordered_hashmap", +] + [[package]] name = "grid" version = "0.18.0" @@ -8775,6 +8794,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" @@ -10107,6 +10137,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mermaid-rs-renderer" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809edd3a9b85b6ae4bbe646317c2de9ea2ff7cb984e4e064625e048d9b1ef7f4" +dependencies = [ + "anyhow", + "dagre_rust", + "graphlib_rust", + "json5", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "mermaid_preview" +version = "0.1.0" +dependencies = [ + "anyhow", + "file_icons", + "gpui", + "language", + "mermaid-rs-renderer", + "multi_buffer", + "ui", + "workspace", + "zed_actions", +] + [[package]] name = "metal" version = "0.29.0" @@ -11337,6 +11399,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ordered_hashmap" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1ea499d242299d564879c8b375c11285f5a26d62d8010768909d6d099c4c5a" + [[package]] name = "ouroboros" version = "0.18.5" @@ -21118,6 +21186,7 @@ dependencies = [ "markdown", "markdown_preview", "menu", + "mermaid_preview", "migrator", "mimalloc", "miniprofiler_ui", diff --git a/Cargo.toml b/Cargo.toml index ecb469f6d83780..00eb17a01ffe3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ members = [ "crates/lsp", "crates/markdown", "crates/markdown_preview", + "crates/mermaid_preview", "crates/media", "crates/menu", "crates/migrator", @@ -353,6 +354,7 @@ lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } +mermaid_preview = { path = "crates/mermaid_preview" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } @@ -564,6 +566,7 @@ log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "fb6bcad59522455a041b7eb9579f706e5cfb2d6f" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" +mermaid-rs-renderer = { version = "0.1.2", default-features = false } metal = "0.29" minidumper = "0.8" moka = { version = "0.12.10", features = ["sync"] } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index bf38edfeb85e07..1a5a2c6d88a53b 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -558,6 +558,14 @@ "ctrl-shift-v": "svg::OpenPreview", }, }, + { + "context": "Editor && (extension == mmd || extension == mermaid)", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "mermaid::OpenPreviewToTheSide", + "ctrl-shift-v": "mermaid::OpenPreview", + }, + }, { "context": "Editor && mode == full", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index bca1e42d9ceaf9..bc9367508fc730 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -604,6 +604,14 @@ "cmd-shift-v": "svg::OpenPreview", }, }, + { + "context": "Editor && (extension == mmd || extension == mermaid)", + "use_key_equivalents": true, + "bindings": { + "cmd-k v": "mermaid::OpenPreviewToTheSide", + "cmd-shift-v": "mermaid::OpenPreview", + }, + }, { "context": "Editor && mode == full", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0f117a75688e44..3a3787fdd0d4a3 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -557,6 +557,14 @@ "ctrl-shift-v": "svg::OpenPreview", }, }, + { + "context": "Editor && (extension == mmd || extension == mermaid)", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "mermaid::OpenPreviewToTheSide", + "ctrl-shift-v": "mermaid::OpenPreview", + }, + }, { "context": "Editor && mode == full", "use_key_equivalents": true, diff --git a/crates/mermaid_preview/Cargo.toml b/crates/mermaid_preview/Cargo.toml new file mode 100644 index 00000000000000..50773ddf22c782 --- /dev/null +++ b/crates/mermaid_preview/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "mermaid_preview" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/mermaid_preview.rs" + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +language.workspace = true +multi_buffer.workspace = true +file_icons.workspace = true +ui.workspace = true +workspace.workspace = true +zed_actions.workspace = true +mermaid-rs-renderer.workspace = true diff --git a/crates/mermaid_preview/src/mermaid_preview.rs b/crates/mermaid_preview/src/mermaid_preview.rs new file mode 100644 index 00000000000000..e1ab40b27ddd58 --- /dev/null +++ b/crates/mermaid_preview/src/mermaid_preview.rs @@ -0,0 +1,24 @@ +use gpui::{App, actions}; +use workspace::Workspace; + +pub mod mermaid_preview_view; + +pub use zed_actions::preview::mermaid::{OpenPreview, OpenPreviewToTheSide}; + +actions!( + mermaid, + [ + /// Opens a following Mermaid preview that syncs with the editor. + OpenFollowingPreview + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, window, cx| { + let Some(window) = window else { + return; + }; + crate::mermaid_preview_view::MermaidPreviewView::register(workspace, window, cx); + }) + .detach(); +} diff --git a/crates/mermaid_preview/src/mermaid_preview_view.rs b/crates/mermaid_preview/src/mermaid_preview_view.rs new file mode 100644 index 00000000000000..9426f36a65b482 --- /dev/null +++ b/crates/mermaid_preview/src/mermaid_preview_view.rs @@ -0,0 +1,355 @@ +use std::mem; +use std::sync::Arc; + +use anyhow::anyhow; +use file_icons::FileIcons; +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, + RenderImage, Styled, Subscription, Task, WeakEntity, Window, div, img, +}; +use language::{Buffer, BufferEvent}; +use multi_buffer::MultiBuffer; +use ui::prelude::*; +use workspace::item::Item; +use workspace::{Pane, Workspace}; + +use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide}; + +pub struct MermaidPreviewView { + focus_handle: FocusHandle, + buffer: Option>, + current_image: Option, SharedString>>, + _refresh: Task<()>, + _buffer_subscription: Option, + _workspace_subscription: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MermaidPreviewMode { + Default, + Follow, +} + +impl MermaidPreviewView { + pub fn new( + mode: MermaidPreviewMode, + active_buffer: Entity, + workspace_handle: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + let workspace_subscription = if mode == MermaidPreviewMode::Follow + && let Some(workspace) = workspace_handle.upgrade() + { + Some(Self::subscribe_to_workspace(workspace, window, cx)) + } else { + None + }; + + let buffer = active_buffer.read_with(cx, |buffer, _cx| buffer.as_singleton()); + + let subscription = buffer + .as_ref() + .map(|buffer| Self::create_buffer_subscription(buffer, window, cx)); + + let mut this = Self { + focus_handle: cx.focus_handle(), + buffer, + current_image: None, + _buffer_subscription: subscription, + _workspace_subscription: workspace_subscription, + _refresh: Task::ready(()), + }; + this.render_diagram(window, cx); + + this + }) + } + + fn subscribe_to_workspace( + workspace: Entity, + window: &Window, + cx: &mut Context, + ) -> Subscription { + cx.subscribe_in( + &workspace, + window, + move |this: &mut MermaidPreviewView, workspace, event: &workspace::Event, window, cx| { + if let workspace::Event::ActiveItemChanged = event { + let workspace = workspace.read(cx); + if let Some(active_item) = workspace.active_item(cx) + && let Some(buffer) = active_item.downcast::() + && Self::is_mermaid_file(&buffer, cx) + { + let Some(buffer) = buffer.read(cx).as_singleton() else { + return; + }; + if this.buffer.as_ref() != Some(&buffer) { + this._buffer_subscription = + Some(Self::create_buffer_subscription(&buffer, window, cx)); + this.buffer = Some(buffer); + this.render_diagram(window, cx); + cx.notify(); + } + } else { + this.set_current(None, window, cx); + } + } + }, + ) + } + + fn render_diagram(&mut self, window: &Window, cx: &mut Context) { + let Some(buffer) = self.buffer.as_ref() else { + return; + }; + + let renderer = cx.svg_renderer(); + let content = buffer.read(cx).snapshot(); + let mermaid_text = content.text(); + + let background_task = cx.background_spawn(async move { + let svg_string = mermaid_rs_renderer::render(&mermaid_text) + .map_err(|error| anyhow!("Mermaid render error: {error}"))?; + renderer + .render_single_frame(svg_string.as_bytes(), 1.0, true) + .map_err(|error| anyhow!("{error}")) + }); + + self._refresh = cx.spawn_in(window, async move |this, cx| { + let result = background_task.await; + + this.update_in(cx, |view, window, cx| { + let current = result.map_err(|error| error.to_string().into()); + view.set_current(Some(current), window, cx); + }) + .ok(); + }); + } + + fn set_current( + &mut self, + image: Option, SharedString>>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(Ok(image)) = mem::replace(&mut self.current_image, image) { + window.drop_image(image).ok(); + } + cx.notify(); + } + + fn find_existing_preview_item_idx( + pane: &Pane, + buffer: &Entity, + cx: &App, + ) -> Option { + let buffer_id = buffer.entity_id(); + pane.items_of_type::() + .find(|view| { + view.read(cx) + .buffer + .as_ref() + .is_some_and(|buffer| buffer.entity_id() == buffer_id) + }) + .and_then(|view| pane.index_for_item(&view)) + } + + pub fn resolve_active_item_as_mermaid_buffer( + workspace: &Workspace, + cx: &mut Context, + ) -> Option> { + workspace + .active_item(cx)? + .act_as::(cx) + .filter(|buffer| Self::is_mermaid_file(buffer, cx)) + } + + fn create_mermaid_view( + mode: MermaidPreviewMode, + workspace: &mut Workspace, + buffer: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let workspace_handle = workspace.weak_handle(); + MermaidPreviewView::new(mode, buffer, workspace_handle, window, cx) + } + + fn create_buffer_subscription( + buffer: &Entity, + window: &Window, + cx: &mut Context, + ) -> Subscription { + cx.subscribe_in( + buffer, + window, + move |this, _buffer, event: &BufferEvent, window, cx| match event { + BufferEvent::Edited | BufferEvent::Saved => { + this.render_diagram(window, cx); + } + _ => {} + }, + ) + } + + pub fn is_mermaid_file(buffer: &Entity, cx: &App) -> bool { + buffer + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).file()) + .is_some_and(|file| { + let path = std::path::Path::new(file.file_name(cx)); + path.extension().is_some_and(|ext| { + ext.eq_ignore_ascii_case("mmd") || ext.eq_ignore_ascii_case("mermaid") + }) + }) + } + + pub fn register( + workspace: &mut Workspace, + _window: &mut Window, + _cx: &mut Context, + ) { + workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { + if let Some(buffer) = Self::resolve_active_item_as_mermaid_buffer(workspace, cx) + && Self::is_mermaid_file(&buffer, cx) + { + let view = Self::create_mermaid_view( + MermaidPreviewMode::Default, + workspace, + buffer.clone(), + window, + cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + if let Some(existing_view_idx) = + Self::find_existing_preview_item_idx(pane, &buffer, cx) + { + pane.activate_item(existing_view_idx, true, true, window, cx); + } else { + pane.add_item(Box::new(view), true, true, None, window, cx) + } + }); + cx.notify(); + } + }); + + workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| { + if let Some(buffer) = Self::resolve_active_item_as_mermaid_buffer(workspace, cx) + && Self::is_mermaid_file(&buffer, cx) + { + let buffer_clone = buffer.clone(); + let view = Self::create_mermaid_view( + MermaidPreviewMode::Default, + workspace, + buffer_clone, + window, + cx, + ); + let pane = workspace + .find_pane_in_direction(workspace::SplitDirection::Right, cx) + .unwrap_or_else(|| { + workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ) + }); + pane.update(cx, |pane, cx| { + if let Some(existing_view_idx) = + Self::find_existing_preview_item_idx(pane, &buffer, cx) + { + pane.activate_item(existing_view_idx, true, true, window, cx); + } else { + pane.add_item(Box::new(view), false, false, None, window, cx) + } + }); + cx.notify(); + } + }); + + workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| { + if let Some(buffer) = Self::resolve_active_item_as_mermaid_buffer(workspace, cx) + && Self::is_mermaid_file(&buffer, cx) + { + let view = Self::create_mermaid_view( + MermaidPreviewMode::Follow, + workspace, + buffer, + window, + cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(view), true, true, None, window, cx) + }); + cx.notify(); + } + }); + } +} + +impl Render for MermaidPreviewView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("MermaidPreview") + .key_context("MermaidPreview") + .track_focus(&self.focus_handle(cx)) + .size_full() + .bg(cx.theme().colors().editor_background) + .flex() + .justify_center() + .items_center() + .map(|this| match self.current_image.clone() { + Some(Ok(image)) => { + this.child(img(image).max_w_full().max_h_full().with_fallback(|| { + h_flex() + .p_4() + .gap_2() + .child(Icon::new(IconName::Warning)) + .child("Failed to render Mermaid diagram") + .into_any_element() + })) + } + Some(Err(error)) => this.child(div().p_4().child(error).into_any_element()), + None => this.child(div().p_4().child("No Mermaid file selected")), + }) + } +} + +impl Focusable for MermaidPreviewView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter<()> for MermaidPreviewView {} + +impl Item for MermaidPreviewView { + type Event = (); + + fn tab_icon(&self, _window: &Window, cx: &App) -> Option { + self.buffer + .as_ref() + .and_then(|buffer| buffer.read(cx).file()) + .and_then(|file| FileIcons::get_icon(file.path().as_std_path(), cx)) + .map(Icon::from_path) + .or_else(|| Some(Icon::new(IconName::Image))) + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + self.buffer + .as_ref() + .and_then(|buffer| buffer.read(cx).file()) + .map(|name| format!("Preview {}", name.file_name(cx)).into()) + .unwrap_or_else(|| "Mermaid Preview".into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("mermaid preview: open") + } + + fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {} +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 924352c46a5655..349cda854e5a0b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -154,6 +154,7 @@ line_ending_selector.workspace = true log.workspace = true markdown.workspace = true markdown_preview.workspace = true +mermaid_preview.workspace = true menu.workspace = true migrator.workspace = true miniprofiler_ui.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6dc23fa4c585fa..1c835aba36d235 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -684,6 +684,7 @@ fn main() { feedback::init(cx); markdown_preview::init(cx); svg_preview::init(cx); + mermaid_preview::init(cx); onboarding::init(cx); settings_ui::init(cx); keymap_editor::init(cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 874cb569a2d430..87e3d7e6881763 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -728,4 +728,18 @@ pub mod preview { ] ); } + + pub mod mermaid { + use gpui::actions; + + actions!( + mermaid, + [ + /// Opens a Mermaid diagram preview for the current file. + OpenPreview, + /// Opens a Mermaid diagram preview in a split pane. + OpenPreviewToTheSide, + ] + ); + } }