1use crate::{
2 markdown_elements::{
3 HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
4 ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
5 ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType,
6 ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable,
7 ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
8 },
9 markdown_preview_view::MarkdownPreviewView,
10};
11use collections::HashMap;
12use gpui::{
13 AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
14 Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
15 Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
16 StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
17};
18use settings::Settings;
19use std::{
20 ops::{Mul, Range},
21 sync::{Arc, OnceLock},
22 time::Duration,
23 vec,
24};
25use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
26use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
27use util::normalize_path;
28use workspace::{OpenOptions, OpenVisible, Workspace};
29
30pub struct CheckboxClickedEvent {
31 pub checked: bool,
32 pub source_range: Range<usize>,
33}
34
35impl CheckboxClickedEvent {
36 pub fn source_range(&self) -> Range<usize> {
37 self.source_range.clone()
38 }
39
40 pub fn checked(&self) -> bool {
41 self.checked
42 }
43}
44
45type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
46
47type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, CachedMermaidDiagram>;
48
49#[derive(Default)]
50pub(crate) struct MermaidState {
51 cache: MermaidDiagramCache,
52 order: Vec<ParsedMarkdownMermaidDiagramContents>,
53}
54
55impl MermaidState {
56 fn get_fallback_image(
57 idx: usize,
58 old_order: &[ParsedMarkdownMermaidDiagramContents],
59 new_order_len: usize,
60 cache: &MermaidDiagramCache,
61 ) -> Option<Arc<RenderImage>> {
62 // When the diagram count changes e.g. addition or removal, positional matching
63 // is unreliable since a new diagram at index i likely doesn't correspond to the
64 // old diagram at index i. We only allow fallbacks when counts match, which covers
65 // the common case of editing a diagram in-place.
66 //
67 // Swapping two diagrams would briefly show the stale fallback, but that's an edge
68 // case we don't handle.
69 if old_order.len() != new_order_len {
70 return None;
71 }
72 old_order.get(idx).and_then(|old_content| {
73 cache.get(old_content).and_then(|old_cached| {
74 old_cached
75 .render_image
76 .get()
77 .and_then(|result| result.as_ref().ok().cloned())
78 // Chain fallbacks for rapid edits.
79 .or_else(|| old_cached.fallback_image.clone())
80 })
81 })
82 }
83
84 pub(crate) fn update(
85 &mut self,
86 parsed: &ParsedMarkdown,
87 cx: &mut Context<MarkdownPreviewView>,
88 ) {
89 use crate::markdown_elements::ParsedMarkdownElement;
90 use std::collections::HashSet;
91
92 let mut new_order = Vec::new();
93 for element in parsed.children.iter() {
94 if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element {
95 new_order.push(mermaid_diagram.contents.clone());
96 }
97 }
98
99 for (idx, new_content) in new_order.iter().enumerate() {
100 if !self.cache.contains_key(new_content) {
101 let fallback =
102 Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
103 self.cache.insert(
104 new_content.clone(),
105 CachedMermaidDiagram::new(new_content.clone(), fallback, cx),
106 );
107 }
108 }
109
110 let new_order_set: HashSet<_> = new_order.iter().cloned().collect();
111 self.cache
112 .retain(|content, _| new_order_set.contains(content));
113 self.order = new_order;
114 }
115}
116
117pub(crate) struct CachedMermaidDiagram {
118 pub(crate) render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
119 pub(crate) fallback_image: Option<Arc<RenderImage>>,
120 _task: Task<()>,
121}
122
123impl CachedMermaidDiagram {
124 pub(crate) fn new(
125 contents: ParsedMarkdownMermaidDiagramContents,
126 fallback_image: Option<Arc<RenderImage>>,
127 cx: &mut Context<MarkdownPreviewView>,
128 ) -> Self {
129 let result = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
130 let result_clone = result.clone();
131 let svg_renderer = cx.svg_renderer();
132
133 let _task = cx.spawn(async move |this, cx| {
134 let value = cx
135 .background_spawn(async move {
136 let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
137 let scale = contents.scale as f32 / 100.0;
138 svg_renderer
139 .render_single_frame(svg_string.as_bytes(), scale, true)
140 .map_err(|e| anyhow::anyhow!("{}", e))
141 })
142 .await;
143 let _ = result_clone.set(value);
144 this.update(cx, |_, cx| {
145 cx.notify();
146 })
147 .ok();
148 });
149
150 Self {
151 render_image: result,
152 fallback_image,
153 _task,
154 }
155 }
156
157 #[cfg(test)]
158 fn new_for_test(
159 render_image: Option<Arc<RenderImage>>,
160 fallback_image: Option<Arc<RenderImage>>,
161 ) -> Self {
162 let result = Arc::new(OnceLock::new());
163 if let Some(img) = render_image {
164 let _ = result.set(Ok(img));
165 }
166 Self {
167 render_image: result,
168 fallback_image,
169 _task: Task::ready(()),
170 }
171 }
172}
173#[derive(Clone)]
174pub struct RenderContext<'a> {
175 workspace: Option<WeakEntity<Workspace>>,
176 next_id: usize,
177 buffer_font_family: SharedString,
178 buffer_text_style: TextStyle,
179 text_style: TextStyle,
180 border_color: Hsla,
181 title_bar_background_color: Hsla,
182 panel_background_color: Hsla,
183 text_color: Hsla,
184 link_color: Hsla,
185 window_rem_size: Pixels,
186 text_muted_color: Hsla,
187 code_block_background_color: Hsla,
188 code_span_background_color: Hsla,
189 syntax_theme: Arc<SyntaxTheme>,
190 indent: usize,
191 checkbox_clicked_callback: Option<CheckboxClickedCallback>,
192 is_last_child: bool,
193 mermaid_state: &'a MermaidState,
194}
195
196impl<'a> RenderContext<'a> {
197 pub(crate) fn new(
198 workspace: Option<WeakEntity<Workspace>>,
199 mermaid_state: &'a MermaidState,
200 window: &mut Window,
201 cx: &mut App,
202 ) -> Self {
203 let theme = cx.theme().clone();
204
205 let settings = ThemeSettings::get_global(cx);
206 let buffer_font_family = settings.buffer_font.family.clone();
207 let buffer_font_features = settings.buffer_font.features.clone();
208 let mut buffer_text_style = window.text_style();
209 buffer_text_style.font_family = buffer_font_family.clone();
210 buffer_text_style.font_features = buffer_font_features;
211 buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
212
213 RenderContext {
214 workspace,
215 next_id: 0,
216 indent: 0,
217 buffer_font_family,
218 buffer_text_style,
219 text_style: window.text_style(),
220 syntax_theme: theme.syntax().clone(),
221 border_color: theme.colors().border,
222 title_bar_background_color: theme.colors().title_bar_background,
223 panel_background_color: theme.colors().panel_background,
224 text_color: theme.colors().text,
225 link_color: theme.colors().text_accent,
226 window_rem_size: window.rem_size(),
227 text_muted_color: theme.colors().text_muted,
228 code_block_background_color: theme.colors().surface_background,
229 code_span_background_color: theme.colors().editor_document_highlight_read_background,
230 checkbox_clicked_callback: None,
231 is_last_child: false,
232 mermaid_state,
233 }
234 }
235
236 pub fn with_checkbox_clicked_callback(
237 mut self,
238 callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static,
239 ) -> Self {
240 self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
241 self
242 }
243
244 fn next_id(&mut self, span: &Range<usize>) -> ElementId {
245 let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
246 self.next_id += 1;
247 ElementId::from(SharedString::from(id))
248 }
249
250 /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
251 /// buffer font size changes. The callees of this function should be reimplemented to use real
252 /// relative sizing once that is implemented in GPUI
253 pub fn scaled_rems(&self, rems: f32) -> Rems {
254 self.buffer_text_style
255 .font_size
256 .to_rems(self.window_rem_size)
257 .mul(rems)
258 }
259
260 /// This ensures that children inside of block quotes
261 /// have padding between them.
262 ///
263 /// For example, for this markdown:
264 ///
265 /// ```markdown
266 /// > This is a block quote.
267 /// >
268 /// > And this is the next paragraph.
269 /// ```
270 ///
271 /// We give padding between "This is a block quote."
272 /// and "And this is the next paragraph."
273 fn with_common_p(&self, element: Div) -> Div {
274 if self.indent > 0 && !self.is_last_child {
275 element.pb(self.scaled_rems(0.75))
276 } else {
277 element
278 }
279 }
280
281 /// The is used to indicate that the current element is the last child or not of its parent.
282 ///
283 /// Then we can avoid adding padding to the bottom of the last child.
284 fn with_last_child<R>(&mut self, is_last: bool, render: R) -> AnyElement
285 where
286 R: FnOnce(&mut Self) -> AnyElement,
287 {
288 self.is_last_child = is_last;
289 let element = render(self);
290 self.is_last_child = false;
291 element
292 }
293}
294
295pub fn render_parsed_markdown(
296 parsed: &ParsedMarkdown,
297 workspace: Option<WeakEntity<Workspace>>,
298 window: &mut Window,
299 cx: &mut App,
300) -> Div {
301 let cache = Default::default();
302 let mut cx = RenderContext::new(workspace, &cache, window, cx);
303
304 v_flex().gap_3().children(
305 parsed
306 .children
307 .iter()
308 .map(|block| render_markdown_block(block, &mut cx)),
309 )
310}
311pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
312 use ParsedMarkdownElement::*;
313 match block {
314 Paragraph(text) => render_markdown_paragraph(text, cx),
315 Heading(heading) => render_markdown_heading(heading, cx),
316 ListItem(list_item) => render_markdown_list_item(list_item, cx),
317 Table(table) => render_markdown_table(table, cx),
318 BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
319 CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
320 MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx),
321 HorizontalRule(_) => render_markdown_rule(cx),
322 Image(image) => render_markdown_image(image, cx),
323 }
324}
325
326fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
327 let size = match parsed.level {
328 HeadingLevel::H1 => 2.,
329 HeadingLevel::H2 => 1.5,
330 HeadingLevel::H3 => 1.25,
331 HeadingLevel::H4 => 1.,
332 HeadingLevel::H5 => 0.875,
333 HeadingLevel::H6 => 0.85,
334 };
335
336 let text_size = cx.scaled_rems(size);
337
338 // was `DefiniteLength::from(text_size.mul(1.25))`
339 // let line_height = DefiniteLength::from(text_size.mul(1.25));
340 let line_height = text_size * 1.25;
341
342 // was `rems(0.15)`
343 // let padding_top = cx.scaled_rems(0.15);
344 let padding_top = rems(0.15);
345
346 // was `.pb_1()` = `rems(0.25)`
347 // let padding_bottom = cx.scaled_rems(0.25);
348 let padding_bottom = rems(0.25);
349
350 let color = match parsed.level {
351 HeadingLevel::H6 => cx.text_muted_color,
352 _ => cx.text_color,
353 };
354 div()
355 .line_height(line_height)
356 .text_size(text_size)
357 .text_color(color)
358 .pt(padding_top)
359 .pb(padding_bottom)
360 .children(render_markdown_text(&parsed.contents, cx))
361 .whitespace_normal()
362 .into_any()
363}
364
365fn render_markdown_list_item(
366 parsed: &ParsedMarkdownListItem,
367 cx: &mut RenderContext,
368) -> AnyElement {
369 use ParsedMarkdownListItemType::*;
370 let depth = parsed.depth.saturating_sub(1) as usize;
371
372 let bullet = match &parsed.item_type {
373 Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(),
374 Unordered => list_item_prefix(1, false, depth).into_any_element(),
375 Task(checked, range) => div()
376 .id(cx.next_id(range))
377 .mt(cx.scaled_rems(3.0 / 16.0))
378 .child(
379 MarkdownCheckbox::new(
380 "checkbox",
381 if *checked {
382 ToggleState::Selected
383 } else {
384 ToggleState::Unselected
385 },
386 cx.clone(),
387 )
388 .when_some(
389 cx.checkbox_clicked_callback.clone(),
390 |this, callback| {
391 this.on_click({
392 let range = range.clone();
393 move |selection, window, cx| {
394 let checked = match selection {
395 ToggleState::Selected => true,
396 ToggleState::Unselected => false,
397 _ => return,
398 };
399
400 if window.modifiers().secondary() {
401 callback(
402 &CheckboxClickedEvent {
403 checked,
404 source_range: range.clone(),
405 },
406 window,
407 cx,
408 );
409 }
410 }
411 })
412 },
413 ),
414 )
415 .hover(|s| s.cursor_pointer())
416 .tooltip(|_, cx| {
417 InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
418 })
419 .into_any_element(),
420 };
421 let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
422
423 let contents: Vec<AnyElement> = parsed
424 .content
425 .iter()
426 .map(|c| render_markdown_block(c, cx))
427 .collect();
428
429 let item = h_flex()
430 .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32)))
431 .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5())
432 .items_start()
433 .children(vec![
434 bullet,
435 v_flex()
436 .children(contents)
437 .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0)))
438 .pr(cx.scaled_rems(1.0))
439 .w_full(),
440 ]);
441
442 cx.with_common_p(item).into_any()
443}
444
445/// # MarkdownCheckbox ///
446/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
447/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
448/// app are not visually affected
449#[derive(gpui::IntoElement)]
450struct MarkdownCheckbox {
451 id: ElementId,
452 toggle_state: ToggleState,
453 disabled: bool,
454 placeholder: bool,
455 on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
456 filled: bool,
457 style: ui::ToggleStyle,
458 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
459 label: Option<SharedString>,
460 base_rem: Rems,
461}
462
463impl MarkdownCheckbox {
464 /// Creates a new [`Checkbox`].
465 fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
466 Self {
467 id: id.into(),
468 toggle_state: checked,
469 disabled: false,
470 on_click: None,
471 filled: false,
472 style: ui::ToggleStyle::default(),
473 tooltip: None,
474 label: None,
475 placeholder: false,
476 base_rem: render_cx.scaled_rems(1.0),
477 }
478 }
479
480 /// Binds a handler to the [`Checkbox`] that will be called when clicked.
481 fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
482 self.on_click = Some(Box::new(handler));
483 self
484 }
485
486 fn bg_color(&self, cx: &App) -> Hsla {
487 let style = self.style.clone();
488 match (style, self.filled) {
489 (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
490 (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
491 (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
492 (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
493 (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
494 (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
495 }
496 }
497
498 fn border_color(&self, cx: &App) -> Hsla {
499 if self.disabled {
500 return cx.theme().colors().border_variant;
501 }
502
503 match self.style.clone() {
504 ui::ToggleStyle::Ghost => cx.theme().colors().border,
505 ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
506 ui::ToggleStyle::Custom(color) => color.opacity(0.3),
507 }
508 }
509}
510
511impl gpui::RenderOnce for MarkdownCheckbox {
512 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
513 let group_id = format!("checkbox_group_{:?}", self.id);
514 let color = if self.disabled {
515 Color::Disabled
516 } else {
517 Color::Selected
518 };
519 let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small
520 let icon = match self.toggle_state {
521 ToggleState::Selected => {
522 if self.placeholder {
523 None
524 } else {
525 Some(
526 ui::Icon::new(IconName::Check)
527 .size(icon_size_small)
528 .color(color),
529 )
530 }
531 }
532 ToggleState::Indeterminate => Some(
533 ui::Icon::new(IconName::Dash)
534 .size(icon_size_small)
535 .color(color),
536 ),
537 ToggleState::Unselected => None,
538 };
539
540 let bg_color = self.bg_color(cx);
541 let border_color = self.border_color(cx);
542 let hover_border_color = border_color.alpha(0.7);
543
544 let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px)
545
546 let checkbox = h_flex()
547 .id(self.id.clone())
548 .justify_center()
549 .items_center()
550 .size(size)
551 .group(group_id.clone())
552 .child(
553 div()
554 .flex()
555 .flex_none()
556 .justify_center()
557 .items_center()
558 .m(self.base_rem.mul(0.25)) // was .m_1
559 .size(self.base_rem.mul(1.0)) // was .size_4
560 .rounded(self.base_rem.mul(0.125)) // was .rounded_xs
561 .border_1()
562 .bg(bg_color)
563 .border_color(border_color)
564 .when(self.disabled, |this| this.cursor_not_allowed())
565 .when(self.disabled, |this| {
566 this.bg(cx.theme().colors().element_disabled.opacity(0.6))
567 })
568 .when(!self.disabled, |this| {
569 this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
570 })
571 .when(self.placeholder, |this| {
572 this.child(
573 div()
574 .flex_none()
575 .rounded_full()
576 .bg(color.color(cx).alpha(0.5))
577 .size(self.base_rem.mul(0.25)), // was .size_1
578 )
579 })
580 .children(icon),
581 );
582
583 h_flex()
584 .id(self.id)
585 .gap(ui::DynamicSpacing::Base06.rems(cx))
586 .child(checkbox)
587 .when_some(
588 self.on_click.filter(|_| !self.disabled),
589 |this, on_click| {
590 this.on_click(move |_, window, cx| {
591 on_click(&self.toggle_state.inverse(), window, cx)
592 })
593 },
594 )
595 // TODO: Allow label size to be different from default.
596 // TODO: Allow label color to be different from muted.
597 .when_some(self.label, |this, label| {
598 this.child(Label::new(label).color(Color::Muted))
599 })
600 .when_some(self.tooltip, |this, tooltip| {
601 this.tooltip(move |window, cx| tooltip(window, cx))
602 })
603 }
604}
605
606fn calculate_table_columns_count(rows: &Vec<ParsedMarkdownTableRow>) -> usize {
607 let mut actual_column_count = 0;
608 for row in rows {
609 actual_column_count = actual_column_count.max(
610 row.columns
611 .iter()
612 .map(|column| column.col_span)
613 .sum::<usize>(),
614 );
615 }
616 actual_column_count
617}
618
619fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
620 let actual_header_column_count = calculate_table_columns_count(&parsed.header);
621 let actual_body_column_count = calculate_table_columns_count(&parsed.body);
622 let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count);
623
624 let total_rows = parsed.header.len() + parsed.body.len();
625
626 // Track which grid cells are occupied by spanning cells
627 let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
628
629 let mut cells = Vec::with_capacity(total_rows * max_column_count);
630
631 for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() {
632 let mut col_idx = 0;
633
634 for cell in row.columns.iter() {
635 // Skip columns occupied by row-spanning cells from previous rows
636 while col_idx < max_column_count && grid_occupied[row_idx][col_idx] {
637 col_idx += 1;
638 }
639
640 if col_idx >= max_column_count {
641 break;
642 }
643
644 let container = match cell.alignment {
645 ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
646 ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
647 ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
648 };
649
650 let cell_element = container
651 .col_span(cell.col_span.min(max_column_count - col_idx) as u16)
652 .row_span(cell.row_span.min(total_rows - row_idx) as u16)
653 .children(render_markdown_text(&cell.children, cx))
654 .px_2()
655 .py_1()
656 .when(col_idx > 0, |this| this.border_l_1())
657 .when(row_idx > 0, |this| this.border_t_1())
658 .border_color(cx.border_color)
659 .when(cell.is_header, |this| {
660 this.bg(cx.title_bar_background_color)
661 })
662 .when(cell.row_span > 1, |this| this.justify_center())
663 .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
664
665 cells.push(cell_element);
666
667 // Mark grid positions as occupied for row-spanning cells
668 for r in 0..cell.row_span {
669 for c in 0..cell.col_span {
670 if row_idx + r < total_rows && col_idx + c < max_column_count {
671 grid_occupied[row_idx + r][col_idx + c] = true;
672 }
673 }
674 }
675
676 col_idx += cell.col_span;
677 }
678
679 // Fill remaining columns with empty cells if needed
680 while col_idx < max_column_count {
681 if grid_occupied[row_idx][col_idx] {
682 col_idx += 1;
683 continue;
684 }
685
686 let empty_cell = div()
687 .when(col_idx > 0, |this| this.border_l_1())
688 .when(row_idx > 0, |this| this.border_t_1())
689 .border_color(cx.border_color)
690 .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
691
692 cells.push(empty_cell);
693 col_idx += 1;
694 }
695 }
696
697 cx.with_common_p(v_flex().items_start())
698 .when_some(parsed.caption.as_ref(), |this, caption| {
699 this.children(render_markdown_text(caption, cx))
700 })
701 .child(
702 div()
703 .rounded_sm()
704 .overflow_hidden()
705 .border_1()
706 .border_color(cx.border_color)
707 .min_w_0()
708 .grid()
709 .grid_cols_max_content(max_column_count as u16)
710 .children(cells),
711 )
712 .into_any()
713}
714
715fn render_markdown_block_quote(
716 parsed: &ParsedMarkdownBlockQuote,
717 cx: &mut RenderContext,
718) -> AnyElement {
719 cx.indent += 1;
720
721 let children: Vec<AnyElement> = parsed
722 .children
723 .iter()
724 .enumerate()
725 .map(|(ix, child)| {
726 cx.with_last_child(ix + 1 == parsed.children.len(), |cx| {
727 render_markdown_block(child, cx)
728 })
729 })
730 .collect();
731
732 cx.indent -= 1;
733
734 cx.with_common_p(div())
735 .child(
736 div()
737 .border_l_4()
738 .border_color(cx.border_color)
739 .pl_3()
740 .children(children),
741 )
742 .into_any()
743}
744
745fn render_markdown_code_block(
746 parsed: &ParsedMarkdownCodeBlock,
747 cx: &mut RenderContext,
748) -> AnyElement {
749 let body = if let Some(highlights) = parsed.highlights.as_ref() {
750 StyledText::new(parsed.contents.clone()).with_default_highlights(
751 &cx.buffer_text_style,
752 highlights.iter().filter_map(|(range, highlight_id)| {
753 highlight_id
754 .style(cx.syntax_theme.as_ref())
755 .map(|style| (range.clone(), style))
756 }),
757 )
758 } else {
759 StyledText::new(parsed.contents.clone())
760 };
761
762 let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone())
763 .tooltip_label("Copy Codeblock")
764 .visible_on_hover("markdown-block");
765
766 let font = gpui::Font {
767 family: cx.buffer_font_family.clone(),
768 features: cx.buffer_text_style.font_features.clone(),
769 ..Default::default()
770 };
771
772 cx.with_common_p(div())
773 .font(font)
774 .px_3()
775 .py_3()
776 .bg(cx.code_block_background_color)
777 .rounded_sm()
778 .child(body)
779 .child(
780 div()
781 .h_flex()
782 .absolute()
783 .right_1()
784 .top_1()
785 .child(copy_block_button),
786 )
787 .into_any()
788}
789
790fn render_mermaid_diagram(
791 parsed: &ParsedMarkdownMermaidDiagram,
792 cx: &mut RenderContext,
793) -> AnyElement {
794 let cached = cx.mermaid_state.cache.get(&parsed.contents);
795
796 if let Some(result) = cached.and_then(|c| c.render_image.get()) {
797 match result {
798 Ok(render_image) => cx
799 .with_common_p(div())
800 .px_3()
801 .py_3()
802 .bg(cx.code_block_background_color)
803 .rounded_sm()
804 .child(
805 div().w_full().child(
806 img(ImageSource::Render(render_image.clone()))
807 .max_w_full()
808 .with_fallback(|| {
809 div()
810 .child(Label::new("Failed to load mermaid diagram"))
811 .into_any_element()
812 }),
813 ),
814 )
815 .into_any(),
816 Err(_) => cx
817 .with_common_p(div())
818 .px_3()
819 .py_3()
820 .bg(cx.code_block_background_color)
821 .rounded_sm()
822 .child(StyledText::new(parsed.contents.contents.clone()))
823 .into_any(),
824 }
825 } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
826 cx.with_common_p(div())
827 .px_3()
828 .py_3()
829 .bg(cx.code_block_background_color)
830 .rounded_sm()
831 .child(
832 div()
833 .w_full()
834 .child(
835 img(ImageSource::Render(fallback.clone()))
836 .max_w_full()
837 .with_fallback(|| {
838 div()
839 .child(Label::new("Failed to load mermaid diagram"))
840 .into_any_element()
841 }),
842 )
843 .with_animation(
844 "mermaid-fallback-pulse",
845 Animation::new(Duration::from_secs(2))
846 .repeat()
847 .with_easing(pulsating_between(0.6, 1.0)),
848 |el, delta| el.opacity(delta),
849 ),
850 )
851 .into_any()
852 } else {
853 cx.with_common_p(div())
854 .px_3()
855 .py_3()
856 .bg(cx.code_block_background_color)
857 .rounded_sm()
858 .child(
859 Label::new("Rendering mermaid diagram...")
860 .color(Color::Muted)
861 .with_animation(
862 "mermaid-loading-pulse",
863 Animation::new(Duration::from_secs(2))
864 .repeat()
865 .with_easing(pulsating_between(0.4, 0.8)),
866 |label, delta| label.alpha(delta),
867 ),
868 )
869 .into_any()
870 }
871}
872
873fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
874 cx.with_common_p(div())
875 .children(render_markdown_text(parsed, cx))
876 .flex()
877 .flex_col()
878 .into_any_element()
879}
880
881fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
882 let mut any_element = Vec::with_capacity(parsed_new.len());
883 // these values are cloned in-order satisfy borrow checker
884 let syntax_theme = cx.syntax_theme.clone();
885 let workspace_clone = cx.workspace.clone();
886 let code_span_bg_color = cx.code_span_background_color;
887 let text_style = cx.text_style.clone();
888 let link_color = cx.link_color;
889
890 for parsed_region in parsed_new {
891 match parsed_region {
892 MarkdownParagraphChunk::Text(parsed) => {
893 let trimmed = parsed.contents.trim();
894 if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
895 let checked = trimmed != "[ ]";
896 let element = div()
897 .child(MarkdownCheckbox::new(
898 cx.next_id(&parsed.source_range),
899 if checked {
900 ToggleState::Selected
901 } else {
902 ToggleState::Unselected
903 },
904 cx.clone(),
905 ))
906 .into_any();
907 any_element.push(element);
908 continue;
909 }
910
911 let element_id = cx.next_id(&parsed.source_range);
912
913 let highlights = gpui::combine_highlights(
914 parsed.highlights.iter().filter_map(|(range, highlight)| {
915 highlight
916 .to_highlight_style(&syntax_theme)
917 .map(|style| (range.clone(), style))
918 }),
919 parsed.regions.iter().filter_map(|(range, region)| {
920 if region.code {
921 Some((
922 range.clone(),
923 HighlightStyle {
924 background_color: Some(code_span_bg_color),
925 ..Default::default()
926 },
927 ))
928 } else if region.link.is_some() {
929 Some((
930 range.clone(),
931 HighlightStyle {
932 color: Some(link_color),
933 ..Default::default()
934 },
935 ))
936 } else {
937 None
938 }
939 }),
940 );
941 let mut links = Vec::new();
942 let mut link_ranges = Vec::new();
943 for (range, region) in parsed.regions.iter() {
944 if let Some(link) = region.link.clone() {
945 links.push(link);
946 link_ranges.push(range.clone());
947 }
948 }
949 let workspace = workspace_clone.clone();
950 let element = div()
951 .child(
952 InteractiveText::new(
953 element_id,
954 StyledText::new(parsed.contents.clone())
955 .with_default_highlights(&text_style, highlights),
956 )
957 .tooltip({
958 let links = links.clone();
959 let link_ranges = link_ranges.clone();
960 move |idx, _, cx| {
961 for (ix, range) in link_ranges.iter().enumerate() {
962 if range.contains(&idx) {
963 return Some(LinkPreview::new(&links[ix].to_string(), cx));
964 }
965 }
966 None
967 }
968 })
969 .on_click(
970 link_ranges,
971 move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
972 Link::Web { url } => cx.open_url(url),
973 Link::Path { path, .. } => {
974 if let Some(workspace) = &workspace {
975 _ = workspace.update(cx, |workspace, cx| {
976 workspace
977 .open_abs_path(
978 normalize_path(path.clone().as_path()),
979 OpenOptions {
980 visible: Some(OpenVisible::None),
981 ..Default::default()
982 },
983 window,
984 cx,
985 )
986 .detach();
987 });
988 }
989 }
990 },
991 ),
992 )
993 .into_any();
994 any_element.push(element);
995 }
996
997 MarkdownParagraphChunk::Image(image) => {
998 any_element.push(render_markdown_image(image, cx));
999 }
1000 }
1001 }
1002
1003 any_element
1004}
1005
1006fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
1007 let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
1008 div().py(cx.scaled_rems(0.5)).child(rule).into_any()
1009}
1010
1011fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
1012 let image_resource = match image.link.clone() {
1013 Link::Web { url } => Resource::Uri(url.into()),
1014 Link::Path { path, .. } => Resource::Path(Arc::from(path)),
1015 };
1016
1017 let element_id = cx.next_id(&image.source_range);
1018 let workspace = cx.workspace.clone();
1019
1020 div()
1021 .id(element_id)
1022 .cursor_pointer()
1023 .child(
1024 img(ImageSource::Resource(image_resource))
1025 .max_w_full()
1026 .with_fallback({
1027 let alt_text = image.alt_text.clone();
1028 move || div().children(alt_text.clone()).into_any_element()
1029 })
1030 .when_some(image.height, |this, height| this.h(height))
1031 .when_some(image.width, |this, width| this.w(width)),
1032 )
1033 .tooltip({
1034 let link = image.link.clone();
1035 let alt_text = image.alt_text.clone();
1036 move |_, cx| {
1037 InteractiveMarkdownElementTooltip::new(
1038 Some(alt_text.clone().unwrap_or(link.to_string().into())),
1039 "open image",
1040 cx,
1041 )
1042 .into()
1043 }
1044 })
1045 .on_click({
1046 let link = image.link.clone();
1047 move |_, window, cx| {
1048 if window.modifiers().secondary() {
1049 match &link {
1050 Link::Web { url } => cx.open_url(url),
1051 Link::Path { path, .. } => {
1052 if let Some(workspace) = &workspace {
1053 _ = workspace.update(cx, |workspace, cx| {
1054 workspace
1055 .open_abs_path(
1056 path.clone(),
1057 OpenOptions {
1058 visible: Some(OpenVisible::None),
1059 ..Default::default()
1060 },
1061 window,
1062 cx,
1063 )
1064 .detach();
1065 });
1066 }
1067 }
1068 }
1069 }
1070 }
1071 })
1072 .into_any()
1073}
1074
1075struct InteractiveMarkdownElementTooltip {
1076 tooltip_text: Option<SharedString>,
1077 action_text: SharedString,
1078}
1079
1080impl InteractiveMarkdownElementTooltip {
1081 pub fn new(
1082 tooltip_text: Option<SharedString>,
1083 action_text: impl Into<SharedString>,
1084 cx: &mut App,
1085 ) -> Entity<Self> {
1086 let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
1087
1088 cx.new(|_cx| Self {
1089 tooltip_text,
1090 action_text: action_text.into(),
1091 })
1092 }
1093}
1094
1095impl Render for InteractiveMarkdownElementTooltip {
1096 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1097 tooltip_container(cx, |el, _| {
1098 let secondary_modifier = Keystroke {
1099 modifiers: Modifiers::secondary_key(),
1100 ..Default::default()
1101 };
1102
1103 el.child(
1104 v_flex()
1105 .gap_1()
1106 .when_some(self.tooltip_text.clone(), |this, text| {
1107 this.child(Label::new(text).size(LabelSize::Small))
1108 })
1109 .child(
1110 Label::new(format!(
1111 "{}-click to {}",
1112 secondary_modifier, self.action_text
1113 ))
1114 .size(LabelSize::Small)
1115 .color(Color::Muted),
1116 ),
1117 )
1118 })
1119 }
1120}
1121
1122/// Returns the prefix for a list item.
1123fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
1124 let ix = order.saturating_sub(1);
1125 const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1126 const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
1127 const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
1128
1129 if ordered {
1130 match depth {
1131 0 => format!("{}. ", order),
1132 1 => format!(
1133 "{}. ",
1134 NUMBERED_PREFIXES_1
1135 .chars()
1136 .nth(ix % NUMBERED_PREFIXES_1.len())
1137 .unwrap()
1138 ),
1139 _ => format!(
1140 "{}. ",
1141 NUMBERED_PREFIXES_2
1142 .chars()
1143 .nth(ix % NUMBERED_PREFIXES_2.len())
1144 .unwrap()
1145 ),
1146 }
1147 } else {
1148 let depth = depth.min(BULLETS.len() - 1);
1149 let bullet = BULLETS[depth];
1150 return format!("{} ", bullet);
1151 }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
1158 use crate::markdown_elements::ParsedMarkdownTableColumn;
1159 use crate::markdown_elements::ParsedMarkdownText;
1160
1161 fn text(text: &str) -> MarkdownParagraphChunk {
1162 MarkdownParagraphChunk::Text(ParsedMarkdownText {
1163 source_range: 0..text.len(),
1164 contents: SharedString::new(text),
1165 highlights: Default::default(),
1166 regions: Default::default(),
1167 })
1168 }
1169
1170 fn column(
1171 col_span: usize,
1172 row_span: usize,
1173 children: Vec<MarkdownParagraphChunk>,
1174 ) -> ParsedMarkdownTableColumn {
1175 ParsedMarkdownTableColumn {
1176 col_span,
1177 row_span,
1178 is_header: false,
1179 children,
1180 alignment: ParsedMarkdownTableAlignment::None,
1181 }
1182 }
1183
1184 fn column_with_row_span(
1185 col_span: usize,
1186 row_span: usize,
1187 children: Vec<MarkdownParagraphChunk>,
1188 ) -> ParsedMarkdownTableColumn {
1189 ParsedMarkdownTableColumn {
1190 col_span,
1191 row_span,
1192 is_header: false,
1193 children,
1194 alignment: ParsedMarkdownTableAlignment::None,
1195 }
1196 }
1197
1198 #[test]
1199 fn test_calculate_table_columns_count() {
1200 assert_eq!(0, calculate_table_columns_count(&vec![]));
1201
1202 assert_eq!(
1203 1,
1204 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1205 column(1, 1, vec![text("column1")])
1206 ])])
1207 );
1208
1209 assert_eq!(
1210 2,
1211 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1212 column(1, 1, vec![text("column1")]),
1213 column(1, 1, vec![text("column2")]),
1214 ])])
1215 );
1216
1217 assert_eq!(
1218 2,
1219 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1220 column(2, 1, vec![text("column1")])
1221 ])])
1222 );
1223
1224 assert_eq!(
1225 3,
1226 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1227 column(1, 1, vec![text("column1")]),
1228 column(2, 1, vec![text("column2")]),
1229 ])])
1230 );
1231
1232 assert_eq!(
1233 2,
1234 calculate_table_columns_count(&vec![
1235 ParsedMarkdownTableRow::with_columns(vec![
1236 column(1, 1, vec![text("column1")]),
1237 column(1, 1, vec![text("column2")]),
1238 ]),
1239 ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
1240 ])
1241 );
1242
1243 assert_eq!(
1244 3,
1245 calculate_table_columns_count(&vec![
1246 ParsedMarkdownTableRow::with_columns(vec![
1247 column(1, 1, vec![text("column1")]),
1248 column(1, 1, vec![text("column2")]),
1249 ]),
1250 ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
1251 ])
1252 );
1253 }
1254
1255 #[test]
1256 fn test_row_span_support() {
1257 assert_eq!(
1258 3,
1259 calculate_table_columns_count(&vec![
1260 ParsedMarkdownTableRow::with_columns(vec![
1261 column_with_row_span(1, 2, vec![text("spans 2 rows")]),
1262 column(1, 1, vec![text("column2")]),
1263 column(1, 1, vec![text("column3")]),
1264 ]),
1265 ParsedMarkdownTableRow::with_columns(vec![
1266 // First column is covered by row span from above
1267 column(1, 1, vec![text("column2 row2")]),
1268 column(1, 1, vec![text("column3 row2")]),
1269 ])
1270 ])
1271 );
1272
1273 assert_eq!(
1274 4,
1275 calculate_table_columns_count(&vec![
1276 ParsedMarkdownTableRow::with_columns(vec![
1277 column_with_row_span(1, 3, vec![text("spans 3 rows")]),
1278 column_with_row_span(2, 1, vec![text("spans 2 cols")]),
1279 column(1, 1, vec![text("column4")]),
1280 ]),
1281 ParsedMarkdownTableRow::with_columns(vec![
1282 // First column covered by row span
1283 column(1, 1, vec![text("column2")]),
1284 column(1, 1, vec![text("column3")]),
1285 column(1, 1, vec![text("column4")]),
1286 ]),
1287 ParsedMarkdownTableRow::with_columns(vec![
1288 // First column still covered by row span
1289 column(3, 1, vec![text("spans 3 cols")]),
1290 ])
1291 ])
1292 );
1293 }
1294
1295 #[test]
1296 fn test_list_item_prefix() {
1297 assert_eq!(list_item_prefix(1, true, 0), "1. ");
1298 assert_eq!(list_item_prefix(2, true, 0), "2. ");
1299 assert_eq!(list_item_prefix(3, true, 0), "3. ");
1300 assert_eq!(list_item_prefix(11, true, 0), "11. ");
1301 assert_eq!(list_item_prefix(1, true, 1), "A. ");
1302 assert_eq!(list_item_prefix(2, true, 1), "B. ");
1303 assert_eq!(list_item_prefix(3, true, 1), "C. ");
1304 assert_eq!(list_item_prefix(1, true, 2), "a. ");
1305 assert_eq!(list_item_prefix(2, true, 2), "b. ");
1306 assert_eq!(list_item_prefix(7, true, 2), "g. ");
1307 assert_eq!(list_item_prefix(1, true, 1), "A. ");
1308 assert_eq!(list_item_prefix(1, true, 2), "a. ");
1309 assert_eq!(list_item_prefix(1, false, 0), "• ");
1310 assert_eq!(list_item_prefix(1, false, 1), "◦ ");
1311 assert_eq!(list_item_prefix(1, false, 2), "▪ ");
1312 assert_eq!(list_item_prefix(1, false, 3), "‣ ");
1313 assert_eq!(list_item_prefix(1, false, 4), "⁃ ");
1314 }
1315
1316 fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
1317 ParsedMarkdownMermaidDiagramContents {
1318 contents: SharedString::from(s.to_string()),
1319 scale: 1,
1320 }
1321 }
1322
1323 fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
1324 diagrams
1325 .iter()
1326 .map(|diagram| mermaid_contents(diagram))
1327 .collect()
1328 }
1329
1330 fn mermaid_fallback(
1331 new_diagram: &str,
1332 new_full_order: &[ParsedMarkdownMermaidDiagramContents],
1333 old_full_order: &[ParsedMarkdownMermaidDiagramContents],
1334 cache: &MermaidDiagramCache,
1335 ) -> Option<Arc<RenderImage>> {
1336 let new_content = mermaid_contents(new_diagram);
1337 let idx = new_full_order
1338 .iter()
1339 .position(|content| content == &new_content)?;
1340 MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
1341 }
1342
1343 fn mock_render_image() -> Arc<RenderImage> {
1344 Arc::new(RenderImage::new(Vec::new()))
1345 }
1346
1347 #[test]
1348 fn test_mermaid_fallback_on_edit() {
1349 let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
1350 let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
1351
1352 let svg_b = mock_render_image();
1353 let mut cache: MermaidDiagramCache = HashMap::default();
1354 cache.insert(
1355 mermaid_contents("graph A"),
1356 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1357 );
1358 cache.insert(
1359 mermaid_contents("graph B"),
1360 CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
1361 );
1362 cache.insert(
1363 mermaid_contents("graph C"),
1364 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1365 );
1366
1367 let fallback =
1368 mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
1369
1370 assert!(
1371 fallback.is_some(),
1372 "Should use old diagram as fallback when editing"
1373 );
1374 assert!(
1375 Arc::ptr_eq(&fallback.unwrap(), &svg_b),
1376 "Fallback should be the old diagram's SVG"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_mermaid_no_fallback_on_add_in_middle() {
1382 let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
1383 let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
1384
1385 let mut cache: MermaidDiagramCache = HashMap::default();
1386 cache.insert(
1387 mermaid_contents("graph A"),
1388 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1389 );
1390 cache.insert(
1391 mermaid_contents("graph C"),
1392 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1393 );
1394
1395 let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
1396
1397 assert!(
1398 fallback.is_none(),
1399 "Should NOT use fallback when adding new diagram"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_mermaid_fallback_chains_on_rapid_edits() {
1405 let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
1406 let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
1407
1408 let original_svg = mock_render_image();
1409 let mut cache: MermaidDiagramCache = HashMap::default();
1410 cache.insert(
1411 mermaid_contents("graph A"),
1412 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1413 );
1414 cache.insert(
1415 mermaid_contents("graph B modified"),
1416 // Still rendering, but has fallback from original "graph B"
1417 CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
1418 );
1419 cache.insert(
1420 mermaid_contents("graph C"),
1421 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1422 );
1423
1424 let fallback = mermaid_fallback(
1425 "graph B modified again",
1426 &new_full_order,
1427 &old_full_order,
1428 &cache,
1429 );
1430
1431 assert!(
1432 fallback.is_some(),
1433 "Should chain fallback when previous render not complete"
1434 );
1435 assert!(
1436 Arc::ptr_eq(&fallback.unwrap(), &original_svg),
1437 "Fallback should chain through to the original SVG"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
1443 let old_full_order = mermaid_sequence(&["graph A"]);
1444 let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
1445
1446 let mut cache: MermaidDiagramCache = HashMap::default();
1447 cache.insert(
1448 mermaid_contents("graph A"),
1449 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1450 );
1451
1452 let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
1453
1454 assert!(
1455 fallback.is_none(),
1456 "Should NOT have fallback when adding diagram at end"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
1462 let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
1463 let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
1464
1465 let svg_a = mock_render_image();
1466 let mut cache: MermaidDiagramCache = HashMap::default();
1467 cache.insert(
1468 mermaid_contents("graph A"),
1469 CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
1470 );
1471 cache.insert(
1472 mermaid_contents("graph B"),
1473 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1474 );
1475
1476 let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
1477
1478 assert!(
1479 fallback.is_some(),
1480 "Should use old diagram as fallback when editing one of duplicate blocks"
1481 );
1482 assert!(
1483 Arc::ptr_eq(&fallback.unwrap(), &svg_a),
1484 "Fallback should be the old duplicate diagram's image"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
1490 let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
1491 let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
1492
1493 let svg_a = mock_render_image();
1494 let mut cache: MermaidDiagramCache = HashMap::default();
1495 cache.insert(
1496 mermaid_contents("graph A"),
1497 CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
1498 );
1499 cache.insert(
1500 mermaid_contents("graph B"),
1501 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1502 );
1503
1504 let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
1505
1506 assert!(
1507 fallback.is_some(),
1508 "Should use old diagram as fallback when editing the second duplicate block"
1509 );
1510 assert!(
1511 Arc::ptr_eq(&fallback.unwrap(), &svg_a),
1512 "Fallback should be the old duplicate diagram's image"
1513 );
1514 }
1515}