agent_ui: Mermaid diagrams (#56430)

Adds mermaid diagrams to the agent UI

Defaults to rendering the diagram, toggle to switch back to code.

<img width="605" height="837" alt="image"
src="https://github.com/user-attachments/assets/ed87f4e3-77f3-4602-b783-65eda909ad01"
/>
<img width="615" height="334" alt="image"
src="https://github.com/user-attachments/assets/1a422c83-fed4-4099-b895-b5a16e9d2222"
/>


Also makes markdown preview markdown rendering use theme colors:

Before:
<img width="593" height="781" alt="image"
src="https://github.com/user-attachments/assets/8598ca42-d44a-426b-ac4a-fb3cd1288780"
/>

<img width="593" height="781" alt="image"
src="https://github.com/user-attachments/assets/737fdd62-0a37-48dd-a13a-4e11bf80f038"
/>


Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-05-13 09:57:17 +00:00
1 parent d4746b39ee
commit 44dd8096f0
7 files changed
+554 -65

No files matched your search

+33 -3
View File
@@ -17,7 +17,7 @@ use gpui::{
use itertools::Itertools;
use language::language_settings::FormatOnSave;
use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
use markdown::Markdown;
use markdown::{Markdown, MarkdownOptions};
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{
@@ -766,8 +766,18 @@ impl ContentBlock {
cx: &mut App,
) -> ContentBlock {
ContentBlock::Markdown {
markdown: cx
.new(|cx| Markdown::new(content.into(), Some(language_registry.clone()), None, cx)),
markdown: cx.new(|cx| {
Markdown::new_with_options(
content.into(),
Some(language_registry.clone()),
None,
MarkdownOptions {
render_mermaid_diagrams: true,
..Default::default()
},
cx,
)
}),
}
}
@@ -1395,6 +1405,26 @@ impl AcpThread {
&self.entries
}
pub fn invalidate_mermaid_caches(&self, cx: &mut App) {
for entry in &self.entries {
let chunks = match entry {
AgentThreadEntry::AssistantMessage(message) => &message.chunks,
_ => continue,
};
for chunk in chunks {
let block = match chunk {
AssistantMessageChunk::Message { block } => block,
AssistantMessageChunk::Thought { block } => block,
};
if let Some(markdown) = block.markdown() {
markdown.update(cx, |markdown, cx| {
markdown.invalidate_mermaid_cache(cx);
});
}
}
}
}
pub fn session_id(&self) -> &acp::SessionId {
&self.session_id
}
+24 -2
View File
@@ -3,13 +3,35 @@ You are the Zed coding agent running inside the Zed editor. You help users compl
## Communication
- Default to a tone that is concise, direct, and friendly. Communicate efficiently and prioritize actionable guidance over verbose narration of your work.
- Format responses in markdown. Use backticks for file paths, directories, commands, functions, classes, and other code identifiers.
- Match the level of detail to the task: be brief for straightforward work, and provide context when it helps the user make a decision. Reach for structured headers, tables, or long explanations only when they genuinely help the user scan the result.
- Be accurate and truthful. Ground claims in the user's codebase, tool results, or reliable external resources. Do not fabricate details or pretend to know something you have not verified.
- Prioritize technical correctness over affirming the user's assumptions. If something seems wrong or risky, say so respectfully and explain the reasoning.
- Be transparent about uncertainty. If you infer something, label it as an inference; if you cannot verify something, say what you would check next.
- Do not over-apologize when results are unexpected. Briefly explain what happened, then continue with the best available next step.
- To display an image to the user, use standard markdown image syntax: `![alt text](https://example.com/image.png)`. Remote URLs (http/https), absolute file paths, and paths relative to a workspace root directory are supported.
## Formatting Responses
Format responses in markdown. Use backticks for file paths, directories, commands, functions, classes, and other code identifiers.
To display an image to the user, use standard markdown image syntax: `![alt text](https://example.com/image.png)`. Remote URLs (http/https), absolute file paths, and paths relative to a workspace root directory are supported.
To include a mermaid diagram that will be rendered visually, use `mermaid` as the language:
```mermaid
graph TD
A[Start] --> B[End]
```
Mermaid diagrams are automatically themed to match the user's editor theme. Do not include `%%{init}%%` directives or define your own `classDef` styles.
Do *NOT* include inline HTML elements in mermaid diagrams, as they cannot be rendered. It is better to simply skip formatting (e.g. bold/italic/etc.).
When you need accent colors for emphasis (e.g. color-coding layers, categories, or states), use the pre-defined classes `accent0` through `accent7` with the `:::` syntax:
A:::accent0 --> B:::accent1 --> C:::accent2
These classes automatically match the user's theme. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones.
{{#if (gt (len available_tools) 0)}}
## Tool Use
+28
View File
@@ -507,6 +507,9 @@ pub struct ConversationView {
notifications: Vec<WindowHandle<AgentNotification>>,
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
auth_task: Option<Task<()>>,
/// When settings change, use this to see if the theme has changed (which
/// causes mermaid diagrams to re-render).
last_theme_id: Option<String>,
draft_prompt_persist_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
}
@@ -702,6 +705,7 @@ impl ConversationView {
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = vec![
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<AgentBufferFontSize>(window, Self::agent_ui_font_size_changed),
cx.subscribe_in(
@@ -754,6 +758,7 @@ impl ConversationView {
notifications: Vec::new(),
notification_subscriptions: HashMap::default(),
auth_task: None,
last_theme_id: Some(cx.theme().id.clone()),
draft_prompt_persist_task: None,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
@@ -2800,6 +2805,29 @@ impl ConversationView {
}
}
fn invalidate_mermaid_caches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let current_theme_id = cx.theme().id.clone();
if self.last_theme_id.as_ref() == Some(&current_theme_id) {
return;
}
self.last_theme_id = Some(current_theme_id);
if let Some(connected) = self.as_connected() {
let threads: Vec<_> = connected
.conversation
.read(cx)
.threads
.values()
.cloned()
.collect();
for thread in threads {
thread.update(cx, |thread, cx| {
thread.invalidate_mermaid_caches(cx);
});
}
}
}
pub(crate) fn insert_dragged_files(
&self,
paths: Vec<project::ProjectPath>,
+26
View File
@@ -283,6 +283,32 @@ pub struct Hsla {
pub a: f32,
}
#[cfg(feature = "proptest")]
mod property {
use super::Hsla;
use proptest::prelude::*;
impl Hsla {
/// Proptest [`Strategy`] that produces opaque colors (i.e. alpha = 1).
///
/// For truly arbitrary colors, use the [`Arbitrary`] implementation.
pub fn opaque_strategy() -> impl Strategy<Value = Self> {
(0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0).prop_map(|(h, s, l)| Hsla { h, s, l, a: 1. })
}
}
impl Arbitrary for Hsla {
type Strategy = BoxedStrategy<Self>;
type Parameters = ();
fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
(0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0)
.prop_map(|(h, s, l, a)| Hsla { h, s, l, a })
.boxed()
}
}
}
impl PartialEq for Hsla {
fn eq(&self, other: &Self) -> bool {
self.h
+39
View File
@@ -327,6 +327,7 @@ pub struct Markdown {
fallback_code_block_language: Option<LanguageName>,
options: MarkdownOptions,
mermaid_state: MermaidState,
mermaid_showing_code: HashSet<usize>,
copied_code_blocks: HashSet<ElementId>,
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
context_menu_link: Option<SharedString>,
@@ -499,6 +500,7 @@ impl Markdown {
fallback_code_block_language,
options,
mermaid_state: MermaidState::default(),
mermaid_showing_code: HashSet::default(),
copied_code_blocks: HashSet::default(),
code_block_scroll_handles: BTreeMap::default(),
context_menu_link: None,
@@ -535,6 +537,27 @@ impl Markdown {
.retain(|id, _| ids.contains(id));
}
/// Used in the agent panel to force a re-render when the theme changes
pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context<Self>) {
if self.options.render_mermaid_diagrams && !self.parsed_markdown.mermaid_diagrams.is_empty()
{
self.mermaid_state.clear();
let parsed_markdown = self.parsed_markdown.clone();
self.mermaid_state.update(&parsed_markdown, cx);
cx.notify();
}
}
pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool {
self.mermaid_showing_code.contains(&source_offset)
}
pub(crate) fn toggle_mermaid_tab(&mut self, source_offset: usize) {
if !self.mermaid_showing_code.remove(&source_offset) {
self.mermaid_showing_code.insert(source_offset);
}
}
fn clear_code_block_scroll_handles(&mut self) {
self.code_block_scroll_handles.clear();
}
@@ -883,8 +906,11 @@ impl Markdown {
if this.options.render_mermaid_diagrams {
let parsed_markdown = this.parsed_markdown.clone();
this.mermaid_state.update(&parsed_markdown, cx);
this.mermaid_showing_code
.retain(|offset| parsed_markdown.mermaid_diagrams.contains_key(offset));
} else {
this.mermaid_state.clear();
this.mermaid_showing_code.clear();
}
this.pending_parse.take();
if this.should_reparse {
@@ -1777,12 +1803,25 @@ impl Element for MarkdownElement {
&& let Some(mermaid_diagram) =
parsed_markdown.mermaid_diagrams.get(&range.start)
{
let showing_code =
self.markdown.read(cx).is_mermaid_showing_code(range.start);
let copy_button_visibility = match &self.code_block_renderer {
CodeBlockRenderer::Default {
copy_button_visibility,
..
} => *copy_button_visibility,
_ => CopyButtonVisibility::VisibleOnHover,
};
builder.push_sourced_element(
mermaid_diagram.content_range.clone(),
render_mermaid_diagram(
mermaid_diagram,
&mermaid_state,
&self.style,
self.markdown.clone(),
range.start,
showing_code,
copy_button_visibility,
),
);
rendered_mermaid_block = true;
+385 -58
View File
@@ -1,17 +1,21 @@
use collections::HashMap;
use gpui::{
Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img,
pulsating_between,
Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla,
ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between,
};
use std::collections::BTreeMap;
use std::ops::Range;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use ui::CopyButton;
use ui::prelude::*;
use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag};
use settings::Settings as _;
use theme_settings::ThemeSettings;
use super::{Markdown, MarkdownStyle, ParsedMarkdown};
use super::{CopyButtonVisibility, Markdown, MarkdownStyle, ParsedMarkdown};
type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, Arc<CachedMermaidDiagram>>;
@@ -99,11 +103,19 @@ impl CachedMermaidDiagram {
let render_image = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
let render_image_clone = render_image.clone();
let svg_renderer = cx.svg_renderer();
let mermaid_theme = build_mermaid_theme(cx);
let accent_classdefs = build_accent_classdefs(cx);
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 options = mermaid_rs_renderer::RenderOptions {
theme: mermaid_theme,
layout: mermaid_rs_renderer::LayoutConfig::default(),
};
let full_source = format!("{}\n{}", contents.contents, accent_classdefs);
let svg_string =
mermaid_rs_renderer::render_with_options(&full_source, options)?;
let scale = contents.scale as f32 / 100.0;
svg_renderer
.render_single_frame(svg_string.as_bytes(), scale)
@@ -141,6 +153,126 @@ impl CachedMermaidDiagram {
}
}
/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`).
fn hsla_to_hex(color: Hsla) -> String {
let rgba: Rgba = color.to_rgb();
let r = (rgba.r * 255.0).round() as u8;
let g = (rgba.g * 255.0).round() as u8;
let b = (rgba.b * 255.0).round() as u8;
format!("#{r:02x}{g:02x}{b:02x}")
}
fn build_mermaid_theme(cx: &Context<Markdown>) -> mermaid_rs_renderer::Theme {
let colors = cx.theme().colors();
let theme_settings = ThemeSettings::get_global(cx);
let mut theme = mermaid_rs_renderer::Theme::modern();
theme.font_family = theme_settings.ui_font.family.to_string();
theme.background = hsla_to_hex(colors.editor_background);
theme.primary_color = hsla_to_hex(colors.surface_background);
theme.primary_text_color = hsla_to_hex(colors.text);
theme.primary_border_color = hsla_to_hex(colors.border);
theme.line_color = hsla_to_hex(colors.border);
theme.secondary_color = hsla_to_hex(colors.element_background);
theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover);
theme.edge_label_background = hsla_to_hex(colors.editor_background);
theme.cluster_background = hsla_to_hex(colors.panel_background);
theme.cluster_border = hsla_to_hex(colors.border_variant);
theme.text_color = hsla_to_hex(colors.text);
let accents = cx.theme().accents();
let pie_colors: [String; 12] =
std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32)));
theme.pie_colors = pie_colors;
theme.pie_title_text_color = hsla_to_hex(colors.text);
theme.pie_section_text_color = "#fff".to_string();
theme.pie_legend_text_color = hsla_to_hex(colors.text);
theme.pie_stroke_color = hsla_to_hex(colors.border);
theme.pie_outer_stroke_color = hsla_to_hex(colors.border);
theme.sequence_actor_fill = hsla_to_hex(colors.element_background);
theme.sequence_actor_border = hsla_to_hex(colors.border);
theme.sequence_actor_line = hsla_to_hex(colors.border);
theme.sequence_note_fill = hsla_to_hex(colors.surface_background);
theme.sequence_note_border = hsla_to_hex(colors.border_variant);
theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover);
theme.sequence_activation_border = hsla_to_hex(colors.border);
let players = cx.theme().players();
theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor));
theme.git_inv_colors =
std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background));
theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string());
theme.git_commit_label_color = hsla_to_hex(colors.text);
theme.git_commit_label_background = hsla_to_hex(colors.element_background);
theme.git_tag_label_color = hsla_to_hex(colors.text);
theme.git_tag_label_background = hsla_to_hex(colors.element_background);
theme.git_tag_label_border = hsla_to_hex(colors.border);
theme
}
fn build_accent_classdefs(cx: &Context<Markdown>) -> String {
use std::fmt::Write;
let players = &cx.theme().players();
let is_light = cx.theme().appearance.is_light();
let mut defs = String::new();
for (i, player) in players.0.iter().enumerate() {
let (fill, text_color) = accent_fill_and_text(player.background, is_light);
let fill = hsla_to_hex(fill);
let stroke = hsla_to_hex(player.cursor);
let text_color = hsla_to_hex(text_color);
writeln!(
defs,
"classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}"
)
.ok();
}
defs
}
/// Adjusts an accent fill color to ensure readable text contrast.
///
/// On dark themes, darkens the fill and uses white text.
/// On light themes, lightens the fill and uses black text.
/// The fill is adjusted until it meets a minimum WCAG contrast ratio
/// of ~4.5:1 against the chosen text color.
fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) {
let mut fill = color;
if is_light {
// Lighten fill until luminance is high enough for black text.
// Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black.
for _ in 0..50 {
if relative_luminance(fill) >= 0.35 {
break;
}
fill.l = (fill.l + 0.02).min(1.0);
}
(fill, gpui::black())
} else {
// Darken fill until luminance is low enough for white text.
// Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white.
for _ in 0..50 {
if relative_luminance(fill) <= 0.18 {
break;
}
fill.l = (fill.l - 0.02).max(0.0);
}
(fill, gpui::white())
}
}
fn relative_luminance(color: Hsla) -> f32 {
let rgba: Rgba = color.to_rgb();
fn linearize(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b)
}
fn parse_mermaid_info(info: &str) -> Option<u32> {
let mut parts = info.split_whitespace();
if parts.next()? != "mermaid" {
@@ -166,11 +298,22 @@ pub(crate) fn extract_mermaid_diagrams(
let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else {
continue;
};
let CodeBlockKind::FencedLang(info) = kind else {
continue;
};
let Some(scale) = parse_mermaid_info(info.as_ref()) else {
if !metadata.is_fenced_closed {
continue;
}
let scale = match kind {
CodeBlockKind::FencedLang(info) => match parse_mermaid_info(info.as_ref()) {
Some(scale) => scale,
None => continue,
},
CodeBlockKind::FencedSrc(path_range) => {
let path = Path::new(path_range.path.as_ref());
match path.extension().and_then(|ext| ext.to_str()) {
Some("mermaid" | "mmd") => 100,
_ => continue,
}
}
_ => continue,
};
let contents = source[metadata.content_range.clone()]
@@ -196,16 +339,28 @@ pub(crate) fn render_mermaid_diagram(
parsed: &ParsedMarkdownMermaidDiagram,
mermaid_state: &MermaidState,
style: &MarkdownStyle,
markdown: Entity<Markdown>,
source_offset: usize,
showing_code: bool,
copy_button_visibility: CopyButtonVisibility,
) -> AnyElement {
let cached = mermaid_state.cache.get(&parsed.contents);
let mut container = div().w_full();
let render_result = cached.and_then(|cached| cached.render_image.get());
let show_interactive = copy_button_visibility != CopyButtonVisibility::Hidden;
let code = parsed.contents.contents.clone();
let mut container = div().group("code_block").relative().w_full().rounded_lg();
container.style().refine(&style.code_block);
if let Some(result) = cached.and_then(|cached| cached.render_image.get()) {
match result {
Ok(render_image) => container
.child(
div().w_full().child(
match render_result {
Some(Ok(render_image)) => {
let body = if showing_code {
render_mermaid_code_view(&parsed.contents.contents)
} else {
div()
.w_full()
.child(
img(ImageSource::Render(render_image.clone()))
.max_w_full()
.with_fallback(|| {
@@ -213,53 +368,211 @@ pub(crate) fn render_mermaid_diagram(
.child(Label::new("Failed to load mermaid diagram"))
.into_any_element()
}),
),
)
.into_any_element(),
Err(_) => container
.child(StyledText::new(parsed.contents.contents.clone()))
.into_any_element(),
}
} else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
container
.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)),
|element, delta| element.opacity(delta),
),
)
.into_any_element()
} else {
container
.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_element()
.into_any_element()
};
container
.when(show_interactive, |container| {
container.child(render_mermaid_tab_header(
source_offset,
showing_code,
markdown.clone(),
))
})
.child(body)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
Some(Err(_)) => {
// Render failed — show the source code without tabs
container
.child(render_mermaid_code_view(&parsed.contents.contents))
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
None => {
// Still rendering
if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
container
.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)),
|element, delta| element.opacity(delta),
),
)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
} else {
// No fallback — show the code so the user has something to look at
container
.child(render_mermaid_code_view(&parsed.contents.contents))
.child(
div().absolute().top_1().right_2().child(
Label::new("Rendering...")
.size(LabelSize::XSmall)
.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),
),
),
)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
}
}
}
fn render_mermaid_tab_header(
source_offset: usize,
showing_code: bool,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let preview_markdown = markdown.clone();
let code_markdown = markdown;
h_flex()
.gap_0p5()
.p_0p5()
.mb_1()
.child(render_mermaid_tab_button(
"Preview",
source_offset,
!showing_code,
move |_event, _window, cx| {
preview_markdown.update(cx, |md, cx| {
if md.is_mermaid_showing_code(source_offset) {
md.toggle_mermaid_tab(source_offset);
cx.notify();
}
});
},
))
.child(render_mermaid_tab_button(
"Code",
source_offset,
showing_code,
move |_event, _window, cx| {
code_markdown.update(cx, |md, cx| {
if !md.is_mermaid_showing_code(source_offset) {
md.toggle_mermaid_tab(source_offset);
cx.notify();
}
});
},
))
}
fn render_mermaid_tab_button(
label: &'static str,
source_offset: usize,
is_selected: bool,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> impl IntoElement {
div()
.id(ElementId::named_usize(
SharedString::from(format!("mermaid-tab-{label}")),
source_offset,
))
.cursor_pointer()
.px_1p5()
.py_0p5()
.rounded_md()
.text_size(rems(0.75))
.when(is_selected, |this| this.bg(gpui::hsla(0., 0., 0.5, 0.15)))
.when(!is_selected, |this| {
this.hover(|this| this.bg(gpui::hsla(0., 0., 0.5, 0.08)))
})
.child(label)
.on_click(on_click)
}
fn render_mermaid_copy_button(
source_offset: usize,
code: String,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let id = ElementId::named_usize("copy-mermaid-code", source_offset);
h_flex()
.w_4()
.absolute()
.top_0()
.right_0()
.justify_end()
.visible_on_hover("code_block")
.child(CopyButton::new(id.clone(), code.clone()).custom_on_click({
move |_window, cx| {
let id = id.clone();
markdown.update(cx, |this, cx| {
this.copied_code_blocks.insert(id.clone());
cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.copied_code_blocks.remove(&id);
cx.notify();
})
})
.ok();
})
.detach();
});
}
}))
}
fn render_mermaid_code_view(contents: &SharedString) -> AnyElement {
div()
.w_full()
.child(StyledText::new(contents.clone()))
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::{
@@ -271,10 +584,24 @@ mod tests {
MarkdownStyle,
};
use collections::HashMap;
use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size};
use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size};
use std::sync::Arc;
use ui::prelude::*;
#[gpui::property_test]
fn accent_fill_and_text_sufficient_contrast(
#[strategy = Hsla::opaque_strategy()] color: Hsla,
light_mode: bool,
) {
let (fill, text) = super::accent_fill_and_text(color, light_mode);
let fill_luminance = super::relative_luminance(fill);
let text_luminance = super::relative_luminance(text);
let lighter = fill_luminance.max(text_luminance);
let darker = fill_luminance.min(text_luminance);
let contrast_ratio = (lighter + 0.05) / (darker + 0.05);
assert!(contrast_ratio >= 4.5,);
}
fn ensure_theme_initialized(cx: &mut TestAppContext) {
cx.update(|cx| {
if !cx.has_global::<settings::SettingsStore>() {
+19 -2
View File
@@ -227,6 +227,7 @@ pub(crate) fn parse_markdown_with_options(
metadata: CodeBlockMetadata {
content_range: range.clone(),
line_count: 1,
is_fenced_closed: false,
},
}
}
@@ -243,9 +244,22 @@ pub(crate) fn parse_markdown_with_options(
.bytes()
.filter(|c| *c == b'\n')
.count();
let is_fenced_closed = {
let code_block_source = &text[range.clone()];
code_block_source
.trim_end()
.lines()
.last()
.is_some_and(|line| {
let trimmed = line.trim_start();
trimmed.len() >= 3 && trimmed.chars().all(|c| c == '`')
})
};
let metadata = CodeBlockMetadata {
content_range,
line_count,
is_fenced_closed,
};
let info = info.trim();
@@ -735,6 +749,7 @@ pub enum CodeBlockKind {
pub struct CodeBlockMetadata {
pub content_range: Range<usize>,
pub line_count: usize,
pub is_fenced_closed: bool,
}
fn extract_code_content_range(text: &str) -> Range<usize> {
@@ -952,7 +967,8 @@ mod tests {
kind: CodeBlockKind::FencedLang("rust".into()),
metadata: CodeBlockMetadata {
content_range: 8..34,
line_count: 3
line_count: 3,
is_fenced_closed: true,
}
})
),
@@ -980,7 +996,8 @@ mod tests {
kind: CodeBlockKind::Indented,
metadata: CodeBlockMetadata {
content_range: 4..16,
line_count: 1
line_count: 1,
is_fenced_closed: false,
}
})
),