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 fs::normalize_path;
13use gpui::{
14 AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
15 Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
16 Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
17 StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
18};
19use settings::Settings;
20use std::{
21 ops::{Mul, Range},
22 sync::{Arc, OnceLock},
23 time::Duration,
24 vec,
25};
26use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
27use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
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 .border_1()
702 .border_color(cx.border_color)
703 .rounded_sm()
704 .overflow_hidden()
705 .child(
706 div()
707 .min_w_0()
708 .w_full()
709 .grid()
710 .grid_cols(max_column_count as u16)
711 .children(cells),
712 )
713 .into_any()
714}
715
716fn render_markdown_block_quote(
717 parsed: &ParsedMarkdownBlockQuote,
718 cx: &mut RenderContext,
719) -> AnyElement {
720 cx.indent += 1;
721
722 let children: Vec<AnyElement> = parsed
723 .children
724 .iter()
725 .enumerate()
726 .map(|(ix, child)| {
727 cx.with_last_child(ix + 1 == parsed.children.len(), |cx| {
728 render_markdown_block(child, cx)
729 })
730 })
731 .collect();
732
733 cx.indent -= 1;
734
735 cx.with_common_p(div())
736 .child(
737 div()
738 .border_l_4()
739 .border_color(cx.border_color)
740 .pl_3()
741 .children(children),
742 )
743 .into_any()
744}
745
746fn render_markdown_code_block(
747 parsed: &ParsedMarkdownCodeBlock,
748 cx: &mut RenderContext,
749) -> AnyElement {
750 let body = if let Some(highlights) = parsed.highlights.as_ref() {
751 StyledText::new(parsed.contents.clone()).with_default_highlights(
752 &cx.buffer_text_style,
753 highlights.iter().filter_map(|(range, highlight_id)| {
754 highlight_id
755 .style(cx.syntax_theme.as_ref())
756 .map(|style| (range.clone(), style))
757 }),
758 )
759 } else {
760 StyledText::new(parsed.contents.clone())
761 };
762
763 let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone())
764 .tooltip_label("Copy Codeblock")
765 .visible_on_hover("markdown-block");
766
767 let font = gpui::Font {
768 family: cx.buffer_font_family.clone(),
769 features: cx.buffer_text_style.font_features.clone(),
770 ..Default::default()
771 };
772
773 cx.with_common_p(div())
774 .font(font)
775 .px_3()
776 .py_3()
777 .bg(cx.code_block_background_color)
778 .rounded_sm()
779 .child(body)
780 .child(
781 div()
782 .h_flex()
783 .absolute()
784 .right_1()
785 .top_1()
786 .child(copy_block_button),
787 )
788 .into_any()
789}
790
791fn render_mermaid_diagram(
792 parsed: &ParsedMarkdownMermaidDiagram,
793 cx: &mut RenderContext,
794) -> AnyElement {
795 let cached = cx.mermaid_state.cache.get(&parsed.contents);
796
797 if let Some(result) = cached.and_then(|c| c.render_image.get()) {
798 match result {
799 Ok(render_image) => cx
800 .with_common_p(div())
801 .px_3()
802 .py_3()
803 .bg(cx.code_block_background_color)
804 .rounded_sm()
805 .child(
806 div().w_full().child(
807 img(ImageSource::Render(render_image.clone()))
808 .max_w_full()
809 .with_fallback(|| {
810 div()
811 .child(Label::new("Failed to load mermaid diagram"))
812 .into_any_element()
813 }),
814 ),
815 )
816 .into_any(),
817 Err(_) => cx
818 .with_common_p(div())
819 .px_3()
820 .py_3()
821 .bg(cx.code_block_background_color)
822 .rounded_sm()
823 .child(StyledText::new(parsed.contents.contents.clone()))
824 .into_any(),
825 }
826 } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
827 cx.with_common_p(div())
828 .px_3()
829 .py_3()
830 .bg(cx.code_block_background_color)
831 .rounded_sm()
832 .child(
833 div()
834 .w_full()
835 .child(
836 img(ImageSource::Render(fallback.clone()))
837 .max_w_full()
838 .with_fallback(|| {
839 div()
840 .child(Label::new("Failed to load mermaid diagram"))
841 .into_any_element()
842 }),
843 )
844 .with_animation(
845 "mermaid-fallback-pulse",
846 Animation::new(Duration::from_secs(2))
847 .repeat()
848 .with_easing(pulsating_between(0.6, 1.0)),
849 |el, delta| el.opacity(delta),
850 ),
851 )
852 .into_any()
853 } else {
854 cx.with_common_p(div())
855 .px_3()
856 .py_3()
857 .bg(cx.code_block_background_color)
858 .rounded_sm()
859 .child(
860 Label::new("Rendering mermaid diagram...")
861 .color(Color::Muted)
862 .with_animation(
863 "mermaid-loading-pulse",
864 Animation::new(Duration::from_secs(2))
865 .repeat()
866 .with_easing(pulsating_between(0.4, 0.8)),
867 |label, delta| label.alpha(delta),
868 ),
869 )
870 .into_any()
871 }
872}
873
874fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
875 cx.with_common_p(div())
876 .children(render_markdown_text(parsed, cx))
877 .flex()
878 .flex_col()
879 .into_any_element()
880}
881
882fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
883 let mut any_element = Vec::with_capacity(parsed_new.len());
884 // these values are cloned in-order satisfy borrow checker
885 let syntax_theme = cx.syntax_theme.clone();
886 let workspace_clone = cx.workspace.clone();
887 let code_span_bg_color = cx.code_span_background_color;
888 let text_style = cx.text_style.clone();
889 let link_color = cx.link_color;
890
891 for parsed_region in parsed_new {
892 match parsed_region {
893 MarkdownParagraphChunk::Text(parsed) => {
894 let element_id = cx.next_id(&parsed.source_range);
895
896 let highlights = gpui::combine_highlights(
897 parsed.highlights.iter().filter_map(|(range, highlight)| {
898 highlight
899 .to_highlight_style(&syntax_theme)
900 .map(|style| (range.clone(), style))
901 }),
902 parsed.regions.iter().filter_map(|(range, region)| {
903 if region.code {
904 Some((
905 range.clone(),
906 HighlightStyle {
907 background_color: Some(code_span_bg_color),
908 ..Default::default()
909 },
910 ))
911 } else if region.link.is_some() {
912 Some((
913 range.clone(),
914 HighlightStyle {
915 color: Some(link_color),
916 ..Default::default()
917 },
918 ))
919 } else {
920 None
921 }
922 }),
923 );
924 let mut links = Vec::new();
925 let mut link_ranges = Vec::new();
926 for (range, region) in parsed.regions.iter() {
927 if let Some(link) = region.link.clone() {
928 links.push(link);
929 link_ranges.push(range.clone());
930 }
931 }
932 let workspace = workspace_clone.clone();
933 let element = div()
934 .child(
935 InteractiveText::new(
936 element_id,
937 StyledText::new(parsed.contents.clone())
938 .with_default_highlights(&text_style, highlights),
939 )
940 .tooltip({
941 let links = links.clone();
942 let link_ranges = link_ranges.clone();
943 move |idx, _, cx| {
944 for (ix, range) in link_ranges.iter().enumerate() {
945 if range.contains(&idx) {
946 return Some(LinkPreview::new(&links[ix].to_string(), cx));
947 }
948 }
949 None
950 }
951 })
952 .on_click(
953 link_ranges,
954 move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
955 Link::Web { url } => cx.open_url(url),
956 Link::Path { path, .. } => {
957 if let Some(workspace) = &workspace {
958 _ = workspace.update(cx, |workspace, cx| {
959 workspace
960 .open_abs_path(
961 normalize_path(path.clone().as_path()),
962 OpenOptions {
963 visible: Some(OpenVisible::None),
964 ..Default::default()
965 },
966 window,
967 cx,
968 )
969 .detach();
970 });
971 }
972 }
973 },
974 ),
975 )
976 .into_any();
977 any_element.push(element);
978 }
979
980 MarkdownParagraphChunk::Image(image) => {
981 any_element.push(render_markdown_image(image, cx));
982 }
983 }
984 }
985
986 any_element
987}
988
989fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
990 let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
991 div().py(cx.scaled_rems(0.5)).child(rule).into_any()
992}
993
994fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
995 let image_resource = match image.link.clone() {
996 Link::Web { url } => Resource::Uri(url.into()),
997 Link::Path { path, .. } => Resource::Path(Arc::from(path)),
998 };
999
1000 let element_id = cx.next_id(&image.source_range);
1001 let workspace = cx.workspace.clone();
1002
1003 div()
1004 .id(element_id)
1005 .cursor_pointer()
1006 .child(
1007 img(ImageSource::Resource(image_resource))
1008 .max_w_full()
1009 .with_fallback({
1010 let alt_text = image.alt_text.clone();
1011 move || div().children(alt_text.clone()).into_any_element()
1012 })
1013 .when_some(image.height, |this, height| this.h(height))
1014 .when_some(image.width, |this, width| this.w(width)),
1015 )
1016 .tooltip({
1017 let link = image.link.clone();
1018 let alt_text = image.alt_text.clone();
1019 move |_, cx| {
1020 InteractiveMarkdownElementTooltip::new(
1021 Some(alt_text.clone().unwrap_or(link.to_string().into())),
1022 "open image",
1023 cx,
1024 )
1025 .into()
1026 }
1027 })
1028 .on_click({
1029 let link = image.link.clone();
1030 move |_, window, cx| {
1031 if window.modifiers().secondary() {
1032 match &link {
1033 Link::Web { url } => cx.open_url(url),
1034 Link::Path { path, .. } => {
1035 if let Some(workspace) = &workspace {
1036 _ = workspace.update(cx, |workspace, cx| {
1037 workspace
1038 .open_abs_path(
1039 path.clone(),
1040 OpenOptions {
1041 visible: Some(OpenVisible::None),
1042 ..Default::default()
1043 },
1044 window,
1045 cx,
1046 )
1047 .detach();
1048 });
1049 }
1050 }
1051 }
1052 }
1053 }
1054 })
1055 .into_any()
1056}
1057
1058struct InteractiveMarkdownElementTooltip {
1059 tooltip_text: Option<SharedString>,
1060 action_text: SharedString,
1061}
1062
1063impl InteractiveMarkdownElementTooltip {
1064 pub fn new(
1065 tooltip_text: Option<SharedString>,
1066 action_text: impl Into<SharedString>,
1067 cx: &mut App,
1068 ) -> Entity<Self> {
1069 let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
1070
1071 cx.new(|_cx| Self {
1072 tooltip_text,
1073 action_text: action_text.into(),
1074 })
1075 }
1076}
1077
1078impl Render for InteractiveMarkdownElementTooltip {
1079 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1080 tooltip_container(cx, |el, _| {
1081 let secondary_modifier = Keystroke {
1082 modifiers: Modifiers::secondary_key(),
1083 ..Default::default()
1084 };
1085
1086 el.child(
1087 v_flex()
1088 .gap_1()
1089 .when_some(self.tooltip_text.clone(), |this, text| {
1090 this.child(Label::new(text).size(LabelSize::Small))
1091 })
1092 .child(
1093 Label::new(format!(
1094 "{}-click to {}",
1095 secondary_modifier, self.action_text
1096 ))
1097 .size(LabelSize::Small)
1098 .color(Color::Muted),
1099 ),
1100 )
1101 })
1102 }
1103}
1104
1105/// Returns the prefix for a list item.
1106fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
1107 let ix = order.saturating_sub(1);
1108 const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
1109 const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
1110 const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
1111
1112 if ordered {
1113 match depth {
1114 0 => format!("{}. ", order),
1115 1 => format!(
1116 "{}. ",
1117 NUMBERED_PREFIXES_1
1118 .chars()
1119 .nth(ix % NUMBERED_PREFIXES_1.len())
1120 .unwrap()
1121 ),
1122 _ => format!(
1123 "{}. ",
1124 NUMBERED_PREFIXES_2
1125 .chars()
1126 .nth(ix % NUMBERED_PREFIXES_2.len())
1127 .unwrap()
1128 ),
1129 }
1130 } else {
1131 let depth = depth.min(BULLETS.len() - 1);
1132 let bullet = BULLETS[depth];
1133 return format!("{} ", bullet);
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
1141 use crate::markdown_elements::ParsedMarkdownTableColumn;
1142 use crate::markdown_elements::ParsedMarkdownText;
1143
1144 fn text(text: &str) -> MarkdownParagraphChunk {
1145 MarkdownParagraphChunk::Text(ParsedMarkdownText {
1146 source_range: 0..text.len(),
1147 contents: SharedString::new(text),
1148 highlights: Default::default(),
1149 regions: Default::default(),
1150 })
1151 }
1152
1153 fn column(
1154 col_span: usize,
1155 row_span: usize,
1156 children: Vec<MarkdownParagraphChunk>,
1157 ) -> ParsedMarkdownTableColumn {
1158 ParsedMarkdownTableColumn {
1159 col_span,
1160 row_span,
1161 is_header: false,
1162 children,
1163 alignment: ParsedMarkdownTableAlignment::None,
1164 }
1165 }
1166
1167 fn column_with_row_span(
1168 col_span: usize,
1169 row_span: usize,
1170 children: Vec<MarkdownParagraphChunk>,
1171 ) -> ParsedMarkdownTableColumn {
1172 ParsedMarkdownTableColumn {
1173 col_span,
1174 row_span,
1175 is_header: false,
1176 children,
1177 alignment: ParsedMarkdownTableAlignment::None,
1178 }
1179 }
1180
1181 #[test]
1182 fn test_calculate_table_columns_count() {
1183 assert_eq!(0, calculate_table_columns_count(&vec![]));
1184
1185 assert_eq!(
1186 1,
1187 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1188 column(1, 1, vec![text("column1")])
1189 ])])
1190 );
1191
1192 assert_eq!(
1193 2,
1194 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1195 column(1, 1, vec![text("column1")]),
1196 column(1, 1, vec![text("column2")]),
1197 ])])
1198 );
1199
1200 assert_eq!(
1201 2,
1202 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1203 column(2, 1, vec![text("column1")])
1204 ])])
1205 );
1206
1207 assert_eq!(
1208 3,
1209 calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
1210 column(1, 1, vec![text("column1")]),
1211 column(2, 1, vec![text("column2")]),
1212 ])])
1213 );
1214
1215 assert_eq!(
1216 2,
1217 calculate_table_columns_count(&vec![
1218 ParsedMarkdownTableRow::with_columns(vec![
1219 column(1, 1, vec![text("column1")]),
1220 column(1, 1, vec![text("column2")]),
1221 ]),
1222 ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
1223 ])
1224 );
1225
1226 assert_eq!(
1227 3,
1228 calculate_table_columns_count(&vec![
1229 ParsedMarkdownTableRow::with_columns(vec![
1230 column(1, 1, vec![text("column1")]),
1231 column(1, 1, vec![text("column2")]),
1232 ]),
1233 ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
1234 ])
1235 );
1236 }
1237
1238 #[test]
1239 fn test_row_span_support() {
1240 assert_eq!(
1241 3,
1242 calculate_table_columns_count(&vec![
1243 ParsedMarkdownTableRow::with_columns(vec![
1244 column_with_row_span(1, 2, vec![text("spans 2 rows")]),
1245 column(1, 1, vec![text("column2")]),
1246 column(1, 1, vec![text("column3")]),
1247 ]),
1248 ParsedMarkdownTableRow::with_columns(vec![
1249 // First column is covered by row span from above
1250 column(1, 1, vec![text("column2 row2")]),
1251 column(1, 1, vec![text("column3 row2")]),
1252 ])
1253 ])
1254 );
1255
1256 assert_eq!(
1257 4,
1258 calculate_table_columns_count(&vec![
1259 ParsedMarkdownTableRow::with_columns(vec![
1260 column_with_row_span(1, 3, vec![text("spans 3 rows")]),
1261 column_with_row_span(2, 1, vec![text("spans 2 cols")]),
1262 column(1, 1, vec![text("column4")]),
1263 ]),
1264 ParsedMarkdownTableRow::with_columns(vec![
1265 // First column covered by row span
1266 column(1, 1, vec![text("column2")]),
1267 column(1, 1, vec![text("column3")]),
1268 column(1, 1, vec![text("column4")]),
1269 ]),
1270 ParsedMarkdownTableRow::with_columns(vec![
1271 // First column still covered by row span
1272 column(3, 1, vec![text("spans 3 cols")]),
1273 ])
1274 ])
1275 );
1276 }
1277
1278 #[test]
1279 fn test_list_item_prefix() {
1280 assert_eq!(list_item_prefix(1, true, 0), "1. ");
1281 assert_eq!(list_item_prefix(2, true, 0), "2. ");
1282 assert_eq!(list_item_prefix(3, true, 0), "3. ");
1283 assert_eq!(list_item_prefix(11, true, 0), "11. ");
1284 assert_eq!(list_item_prefix(1, true, 1), "A. ");
1285 assert_eq!(list_item_prefix(2, true, 1), "B. ");
1286 assert_eq!(list_item_prefix(3, true, 1), "C. ");
1287 assert_eq!(list_item_prefix(1, true, 2), "a. ");
1288 assert_eq!(list_item_prefix(2, true, 2), "b. ");
1289 assert_eq!(list_item_prefix(7, true, 2), "g. ");
1290 assert_eq!(list_item_prefix(1, true, 1), "A. ");
1291 assert_eq!(list_item_prefix(1, true, 2), "a. ");
1292 assert_eq!(list_item_prefix(1, false, 0), "• ");
1293 assert_eq!(list_item_prefix(1, false, 1), "◦ ");
1294 assert_eq!(list_item_prefix(1, false, 2), "▪ ");
1295 assert_eq!(list_item_prefix(1, false, 3), "‣ ");
1296 assert_eq!(list_item_prefix(1, false, 4), "⁃ ");
1297 }
1298
1299 fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
1300 ParsedMarkdownMermaidDiagramContents {
1301 contents: SharedString::from(s.to_string()),
1302 scale: 1,
1303 }
1304 }
1305
1306 fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
1307 diagrams
1308 .iter()
1309 .map(|diagram| mermaid_contents(diagram))
1310 .collect()
1311 }
1312
1313 fn mermaid_fallback(
1314 new_diagram: &str,
1315 new_full_order: &[ParsedMarkdownMermaidDiagramContents],
1316 old_full_order: &[ParsedMarkdownMermaidDiagramContents],
1317 cache: &MermaidDiagramCache,
1318 ) -> Option<Arc<RenderImage>> {
1319 let new_content = mermaid_contents(new_diagram);
1320 let idx = new_full_order
1321 .iter()
1322 .position(|content| content == &new_content)?;
1323 MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
1324 }
1325
1326 fn mock_render_image() -> Arc<RenderImage> {
1327 Arc::new(RenderImage::new(Vec::new()))
1328 }
1329
1330 #[test]
1331 fn test_mermaid_fallback_on_edit() {
1332 let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
1333 let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
1334
1335 let svg_b = mock_render_image();
1336 let mut cache: MermaidDiagramCache = HashMap::default();
1337 cache.insert(
1338 mermaid_contents("graph A"),
1339 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1340 );
1341 cache.insert(
1342 mermaid_contents("graph B"),
1343 CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
1344 );
1345 cache.insert(
1346 mermaid_contents("graph C"),
1347 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1348 );
1349
1350 let fallback =
1351 mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
1352
1353 assert!(
1354 fallback.is_some(),
1355 "Should use old diagram as fallback when editing"
1356 );
1357 assert!(
1358 Arc::ptr_eq(&fallback.unwrap(), &svg_b),
1359 "Fallback should be the old diagram's SVG"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_mermaid_no_fallback_on_add_in_middle() {
1365 let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
1366 let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
1367
1368 let mut cache: MermaidDiagramCache = HashMap::default();
1369 cache.insert(
1370 mermaid_contents("graph A"),
1371 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1372 );
1373 cache.insert(
1374 mermaid_contents("graph C"),
1375 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1376 );
1377
1378 let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
1379
1380 assert!(
1381 fallback.is_none(),
1382 "Should NOT use fallback when adding new diagram"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_mermaid_fallback_chains_on_rapid_edits() {
1388 let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
1389 let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
1390
1391 let original_svg = mock_render_image();
1392 let mut cache: MermaidDiagramCache = HashMap::default();
1393 cache.insert(
1394 mermaid_contents("graph A"),
1395 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1396 );
1397 cache.insert(
1398 mermaid_contents("graph B modified"),
1399 // Still rendering, but has fallback from original "graph B"
1400 CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
1401 );
1402 cache.insert(
1403 mermaid_contents("graph C"),
1404 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1405 );
1406
1407 let fallback = mermaid_fallback(
1408 "graph B modified again",
1409 &new_full_order,
1410 &old_full_order,
1411 &cache,
1412 );
1413
1414 assert!(
1415 fallback.is_some(),
1416 "Should chain fallback when previous render not complete"
1417 );
1418 assert!(
1419 Arc::ptr_eq(&fallback.unwrap(), &original_svg),
1420 "Fallback should chain through to the original SVG"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
1426 let old_full_order = mermaid_sequence(&["graph A"]);
1427 let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
1428
1429 let mut cache: MermaidDiagramCache = HashMap::default();
1430 cache.insert(
1431 mermaid_contents("graph A"),
1432 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1433 );
1434
1435 let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
1436
1437 assert!(
1438 fallback.is_none(),
1439 "Should NOT have fallback when adding diagram at end"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
1445 let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
1446 let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
1447
1448 let svg_a = mock_render_image();
1449 let mut cache: MermaidDiagramCache = HashMap::default();
1450 cache.insert(
1451 mermaid_contents("graph A"),
1452 CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
1453 );
1454 cache.insert(
1455 mermaid_contents("graph B"),
1456 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1457 );
1458
1459 let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
1460
1461 assert!(
1462 fallback.is_some(),
1463 "Should use old diagram as fallback when editing one of duplicate blocks"
1464 );
1465 assert!(
1466 Arc::ptr_eq(&fallback.unwrap(), &svg_a),
1467 "Fallback should be the old duplicate diagram's image"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
1473 let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
1474 let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
1475
1476 let svg_a = mock_render_image();
1477 let mut cache: MermaidDiagramCache = HashMap::default();
1478 cache.insert(
1479 mermaid_contents("graph A"),
1480 CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
1481 );
1482 cache.insert(
1483 mermaid_contents("graph B"),
1484 CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
1485 );
1486
1487 let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
1488
1489 assert!(
1490 fallback.is_some(),
1491 "Should use old diagram as fallback when editing the second duplicate block"
1492 );
1493 assert!(
1494 Arc::ptr_eq(&fallback.unwrap(), &svg_a),
1495 "Fallback should be the old duplicate diagram's image"
1496 );
1497 }
1498}