markdown_preview_view.rs

  1use std::cmp::min;
  2use std::sync::Arc;
  3use std::time::Duration;
  4use std::{ops::Range, path::PathBuf};
  5
  6use anyhow::Result;
  7use editor::scroll::Autoscroll;
  8use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
  9use gpui::{
 10    App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
 11    IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled,
 12    Subscription, Task, WeakEntity, Window, list,
 13};
 14use language::LanguageRegistry;
 15use settings::Settings;
 16use theme::ThemeSettings;
 17use ui::{WithScrollbar, prelude::*};
 18use workspace::item::{Item, ItemHandle};
 19use workspace::{Pane, Workspace};
 20
 21use crate::markdown_elements::ParsedMarkdownElement;
 22use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
 23use crate::{
 24    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
 25    markdown_elements::ParsedMarkdown,
 26    markdown_parser::parse_markdown,
 27    markdown_renderer::{RenderContext, render_markdown_block},
 28};
 29use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
 30
 31const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
 32
 33pub struct MarkdownPreviewView {
 34    workspace: WeakEntity<Workspace>,
 35    image_cache: Entity<RetainAllImageCache>,
 36    active_editor: Option<EditorState>,
 37    focus_handle: FocusHandle,
 38    contents: Option<ParsedMarkdown>,
 39    selected_block: usize,
 40    list_state: ListState,
 41    language_registry: Arc<LanguageRegistry>,
 42    mermaid_state: MermaidState,
 43    parsing_markdown_task: Option<Task<Result<()>>>,
 44    mode: MarkdownPreviewMode,
 45}
 46
 47#[derive(Clone, Copy, Debug, PartialEq)]
 48pub enum MarkdownPreviewMode {
 49    /// The preview will always show the contents of the provided editor.
 50    Default,
 51    /// The preview will "follow" the currently active editor.
 52    Follow,
 53}
 54
 55struct EditorState {
 56    editor: Entity<Editor>,
 57    _subscription: Subscription,
 58}
 59
 60impl MarkdownPreviewView {
 61    pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
 62        workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
 63            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
 64                let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
 65                workspace.active_pane().update(cx, |pane, cx| {
 66                    if let Some(existing_view_idx) =
 67                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
 68                    {
 69                        pane.activate_item(existing_view_idx, true, true, window, cx);
 70                    } else {
 71                        pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
 72                    }
 73                });
 74                cx.notify();
 75            }
 76        });
 77
 78        workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
 79            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
 80                let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
 81                let pane = workspace
 82                    .find_pane_in_direction(workspace::SplitDirection::Right, cx)
 83                    .unwrap_or_else(|| {
 84                        workspace.split_pane(
 85                            workspace.active_pane().clone(),
 86                            workspace::SplitDirection::Right,
 87                            window,
 88                            cx,
 89                        )
 90                    });
 91                pane.update(cx, |pane, cx| {
 92                    if let Some(existing_view_idx) =
 93                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
 94                    {
 95                        pane.activate_item(existing_view_idx, true, true, window, cx);
 96                    } else {
 97                        pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
 98                    }
 99                });
100                editor.focus_handle(cx).focus(window, cx);
101                cx.notify();
102            }
103        });
104
105        workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
106            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
107                // Check if there's already a following preview
108                let existing_follow_view_idx = {
109                    let active_pane = workspace.active_pane().read(cx);
110                    active_pane
111                        .items_of_type::<MarkdownPreviewView>()
112                        .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow)
113                        .and_then(|view| active_pane.index_for_item(&view))
114                };
115
116                if let Some(existing_follow_view_idx) = existing_follow_view_idx {
117                    workspace.active_pane().update(cx, |pane, cx| {
118                        pane.activate_item(existing_follow_view_idx, true, true, window, cx);
119                    });
120                } else {
121                    let view = Self::create_following_markdown_view(workspace, editor, window, cx);
122                    workspace.active_pane().update(cx, |pane, cx| {
123                        pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
124                    });
125                }
126                cx.notify();
127            }
128        });
129    }
130
131    fn find_existing_independent_preview_item_idx(
132        pane: &Pane,
133        editor: &Entity<Editor>,
134        cx: &App,
135    ) -> Option<usize> {
136        pane.items_of_type::<MarkdownPreviewView>()
137            .find(|view| {
138                let view_read = view.read(cx);
139                // Only look for independent (Default mode) previews, not Follow previews
140                view_read.mode == MarkdownPreviewMode::Default
141                    && view_read
142                        .active_editor
143                        .as_ref()
144                        .is_some_and(|active_editor| active_editor.editor == *editor)
145            })
146            .and_then(|view| pane.index_for_item(&view))
147    }
148
149    pub fn resolve_active_item_as_markdown_editor(
150        workspace: &Workspace,
151        cx: &mut Context<Workspace>,
152    ) -> Option<Entity<Editor>> {
153        if let Some(editor) = workspace
154            .active_item(cx)
155            .and_then(|item| item.act_as::<Editor>(cx))
156            && Self::is_markdown_file(&editor, cx)
157        {
158            return Some(editor);
159        }
160        None
161    }
162
163    fn create_markdown_view(
164        workspace: &mut Workspace,
165        editor: Entity<Editor>,
166        window: &mut Window,
167        cx: &mut Context<Workspace>,
168    ) -> Entity<MarkdownPreviewView> {
169        let language_registry = workspace.project().read(cx).languages().clone();
170        let workspace_handle = workspace.weak_handle();
171        MarkdownPreviewView::new(
172            MarkdownPreviewMode::Default,
173            editor,
174            workspace_handle,
175            language_registry,
176            window,
177            cx,
178        )
179    }
180
181    fn create_following_markdown_view(
182        workspace: &mut Workspace,
183        editor: Entity<Editor>,
184        window: &mut Window,
185        cx: &mut Context<Workspace>,
186    ) -> Entity<MarkdownPreviewView> {
187        let language_registry = workspace.project().read(cx).languages().clone();
188        let workspace_handle = workspace.weak_handle();
189        MarkdownPreviewView::new(
190            MarkdownPreviewMode::Follow,
191            editor,
192            workspace_handle,
193            language_registry,
194            window,
195            cx,
196        )
197    }
198
199    pub fn new(
200        mode: MarkdownPreviewMode,
201        active_editor: Entity<Editor>,
202        workspace: WeakEntity<Workspace>,
203        language_registry: Arc<LanguageRegistry>,
204        window: &mut Window,
205        cx: &mut Context<Workspace>,
206    ) -> Entity<Self> {
207        cx.new(|cx| {
208            let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
209
210            let mut this = Self {
211                selected_block: 0,
212                active_editor: None,
213                focus_handle: cx.focus_handle(),
214                workspace: workspace.clone(),
215                contents: None,
216                list_state,
217                language_registry,
218                mermaid_state: Default::default(),
219                parsing_markdown_task: None,
220                image_cache: RetainAllImageCache::new(cx),
221                mode,
222            };
223
224            this.set_editor(active_editor, window, cx);
225
226            if mode == MarkdownPreviewMode::Follow {
227                if let Some(workspace) = &workspace.upgrade() {
228                    cx.observe_in(workspace, window, |this, workspace, window, cx| {
229                        let item = workspace.read(cx).active_item(cx);
230                        this.workspace_updated(item, window, cx);
231                    })
232                    .detach();
233                } else {
234                    log::error!("Failed to listen to workspace updates");
235                }
236            }
237
238            this
239        })
240    }
241
242    fn workspace_updated(
243        &mut self,
244        active_item: Option<Box<dyn ItemHandle>>,
245        window: &mut Window,
246        cx: &mut Context<Self>,
247    ) {
248        if let Some(item) = active_item
249            && item.item_id() != cx.entity_id()
250            && let Some(editor) = item.act_as::<Editor>(cx)
251            && Self::is_markdown_file(&editor, cx)
252        {
253            self.set_editor(editor, window, cx);
254        }
255    }
256
257    pub fn is_markdown_file<V>(editor: &Entity<Editor>, cx: &mut Context<V>) -> bool {
258        let buffer = editor.read(cx).buffer().read(cx);
259        if let Some(buffer) = buffer.as_singleton()
260            && let Some(language) = buffer.read(cx).language()
261        {
262            return language.name() == "Markdown";
263        }
264        false
265    }
266
267    fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
268        if let Some(active) = &self.active_editor
269            && active.editor == editor
270        {
271            return;
272        }
273
274        let subscription = cx.subscribe_in(
275            &editor,
276            window,
277            |this, editor, event: &EditorEvent, window, cx| {
278                match event {
279                    EditorEvent::Edited { .. }
280                    | EditorEvent::BufferEdited { .. }
281                    | EditorEvent::DirtyChanged
282                    | EditorEvent::ExcerptsEdited { .. } => {
283                        this.parse_markdown_from_active_editor(true, window, cx);
284                    }
285                    EditorEvent::SelectionsChanged { .. } => {
286                        let selection_range = editor.update(cx, |editor, cx| {
287                            editor
288                                .selections
289                                .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
290                                .range()
291                        });
292                        this.selected_block = this.get_block_index_under_cursor(selection_range);
293                        this.list_state.scroll_to_reveal_item(this.selected_block);
294                        cx.notify();
295                    }
296                    _ => {}
297                };
298            },
299        );
300
301        self.active_editor = Some(EditorState {
302            editor,
303            _subscription: subscription,
304        });
305
306        self.parse_markdown_from_active_editor(false, window, cx);
307    }
308
309    fn parse_markdown_from_active_editor(
310        &mut self,
311        wait_for_debounce: bool,
312        window: &mut Window,
313        cx: &mut Context<Self>,
314    ) {
315        if let Some(state) = &self.active_editor {
316            // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
317            if wait_for_debounce && self.parsing_markdown_task.is_some() {
318                return;
319            }
320            self.parsing_markdown_task = Some(self.parse_markdown_in_background(
321                wait_for_debounce,
322                state.editor.clone(),
323                window,
324                cx,
325            ));
326        }
327    }
328
329    fn parse_markdown_in_background(
330        &mut self,
331        wait_for_debounce: bool,
332        editor: Entity<Editor>,
333        window: &mut Window,
334        cx: &mut Context<Self>,
335    ) -> Task<Result<()>> {
336        let language_registry = self.language_registry.clone();
337
338        cx.spawn_in(window, async move |view, cx| {
339            if wait_for_debounce {
340                // Wait for the user to stop typing
341                cx.background_executor().timer(REPARSE_DEBOUNCE).await;
342            }
343
344            let (contents, file_location) = view.update(cx, |_, cx| {
345                let editor = editor.read(cx);
346                let contents = editor.buffer().read(cx).snapshot(cx).text();
347                let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
348                (contents, file_location)
349            })?;
350
351            let parsing_task = cx.background_spawn(async move {
352                parse_markdown(&contents, file_location, Some(language_registry)).await
353            });
354            let contents = parsing_task.await;
355
356            view.update(cx, move |view, cx| {
357                view.mermaid_state.update(&contents, cx);
358                let markdown_blocks_count = contents.children.len();
359                view.contents = Some(contents);
360                let scroll_top = view.list_state.logical_scroll_top();
361                view.list_state.reset(markdown_blocks_count);
362                view.list_state.scroll_to(scroll_top);
363                view.parsing_markdown_task = None;
364                cx.notify();
365            })
366        })
367    }
368
369    fn move_cursor_to_block(
370        &self,
371        window: &mut Window,
372        cx: &mut Context<Self>,
373        selection: Range<MultiBufferOffset>,
374    ) {
375        if let Some(state) = &self.active_editor {
376            state.editor.update(cx, |editor, cx| {
377                editor.change_selections(
378                    SelectionEffects::scroll(Autoscroll::center()),
379                    window,
380                    cx,
381                    |selections| selections.select_ranges(vec![selection]),
382                );
383                window.focus(&editor.focus_handle(cx), cx);
384            });
385        }
386    }
387
388    /// The absolute path of the file that is currently being previewed.
389    fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option<PathBuf> {
390        if let Some(file) = editor.file_at(MultiBufferOffset(0), cx) {
391            if let Some(file) = file.as_local() {
392                file.abs_path(cx).parent().map(|p| p.to_path_buf())
393            } else {
394                None
395            }
396        } else {
397            None
398        }
399    }
400
401    fn get_block_index_under_cursor(&self, selection_range: Range<MultiBufferOffset>) -> usize {
402        let mut block_index = None;
403        let cursor = selection_range.start.0;
404
405        let mut last_end = 0;
406        if let Some(content) = &self.contents {
407            for (i, block) in content.children.iter().enumerate() {
408                let Some(Range { start, end }) = block.source_range() else {
409                    continue;
410                };
411
412                // Check if the cursor is between the last block and the current block
413                if last_end <= cursor && cursor < start {
414                    block_index = Some(i.saturating_sub(1));
415                    break;
416                }
417
418                if start <= cursor && end >= cursor {
419                    block_index = Some(i);
420                    break;
421                }
422                last_end = end;
423            }
424
425            if block_index.is_none() && last_end < cursor {
426                block_index = Some(content.children.len().saturating_sub(1));
427            }
428        }
429
430        block_index.unwrap_or_default()
431    }
432
433    fn should_apply_padding_between(
434        current_block: &ParsedMarkdownElement,
435        next_block: Option<&ParsedMarkdownElement>,
436    ) -> bool {
437        !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
438    }
439
440    fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
441        let viewport_height = self.list_state.viewport_bounds().size.height;
442        if viewport_height.is_zero() {
443            return;
444        }
445
446        self.list_state.scroll_by(-viewport_height);
447        cx.notify();
448    }
449
450    fn scroll_page_down(
451        &mut self,
452        _: &ScrollPageDown,
453        _window: &mut Window,
454        cx: &mut Context<Self>,
455    ) {
456        let viewport_height = self.list_state.viewport_bounds().size.height;
457        if viewport_height.is_zero() {
458            return;
459        }
460
461        self.list_state.scroll_by(viewport_height);
462        cx.notify();
463    }
464
465    fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
466        let scroll_top = self.list_state.logical_scroll_top();
467        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
468            let item_height = bounds.size.height;
469            // Scroll no more than the rough equivalent of a large headline
470            let max_height = window.rem_size() * 2;
471            let scroll_height = min(item_height, max_height);
472            self.list_state.scroll_by(-scroll_height);
473        }
474        cx.notify();
475    }
476
477    fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
478        let scroll_top = self.list_state.logical_scroll_top();
479        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
480            let item_height = bounds.size.height;
481            // Scroll no more than the rough equivalent of a large headline
482            let max_height = window.rem_size() * 2;
483            let scroll_height = min(item_height, max_height);
484            self.list_state.scroll_by(scroll_height);
485        }
486        cx.notify();
487    }
488
489    fn scroll_up_by_item(
490        &mut self,
491        _: &ScrollUpByItem,
492        _window: &mut Window,
493        cx: &mut Context<Self>,
494    ) {
495        let scroll_top = self.list_state.logical_scroll_top();
496        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
497            self.list_state.scroll_by(-bounds.size.height);
498        }
499        cx.notify();
500    }
501
502    fn scroll_down_by_item(
503        &mut self,
504        _: &ScrollDownByItem,
505        _window: &mut Window,
506        cx: &mut Context<Self>,
507    ) {
508        let scroll_top = self.list_state.logical_scroll_top();
509        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
510            self.list_state.scroll_by(bounds.size.height);
511        }
512        cx.notify();
513    }
514
515    fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
516        self.list_state.scroll_to(ListOffset {
517            item_ix: 0,
518            offset_in_item: px(0.),
519        });
520        cx.notify();
521    }
522
523    fn scroll_to_bottom(
524        &mut self,
525        _: &ScrollToBottom,
526        _window: &mut Window,
527        cx: &mut Context<Self>,
528    ) {
529        let count = self.list_state.item_count();
530        if count > 0 {
531            self.list_state.scroll_to(ListOffset {
532                item_ix: count - 1,
533                offset_in_item: px(0.),
534            });
535        }
536        cx.notify();
537    }
538}
539
540impl Focusable for MarkdownPreviewView {
541    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
542        self.focus_handle.clone()
543    }
544}
545
546impl EventEmitter<()> for MarkdownPreviewView {}
547
548impl Item for MarkdownPreviewView {
549    type Event = ();
550
551    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
552        Some(Icon::new(IconName::FileDoc))
553    }
554
555    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
556        self.active_editor
557            .as_ref()
558            .map(|editor_state| {
559                let buffer = editor_state.editor.read(cx).buffer().read(cx);
560                let title = buffer.title(cx);
561                format!("Preview {}", title).into()
562            })
563            .unwrap_or_else(|| SharedString::from("Markdown Preview"))
564    }
565
566    fn telemetry_event_text(&self) -> Option<&'static str> {
567        Some("Markdown Preview Opened")
568    }
569
570    fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {}
571}
572
573impl Render for MarkdownPreviewView {
574    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
575        let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
576        let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height;
577
578        v_flex()
579            .image_cache(self.image_cache.clone())
580            .id("MarkdownPreview")
581            .key_context("MarkdownPreview")
582            .track_focus(&self.focus_handle(cx))
583            .on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
584            .on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
585            .on_action(cx.listener(MarkdownPreviewView::scroll_up))
586            .on_action(cx.listener(MarkdownPreviewView::scroll_down))
587            .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item))
588            .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
589            .on_action(cx.listener(MarkdownPreviewView::scroll_to_top))
590            .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
591            .size_full()
592            .bg(cx.theme().colors().editor_background)
593            .p_4()
594            .text_size(buffer_size)
595            .line_height(relative(buffer_line_height.value()))
596            .child(div().flex_grow().map(|this| {
597                this.child(
598                    list(
599                        self.list_state.clone(),
600                        cx.processor(|this, ix, window, cx| {
601                            let Some(contents) = &this.contents else {
602                                return div().into_any();
603                            };
604
605                            let mut render_cx = RenderContext::new(
606                                Some(this.workspace.clone()),
607                                &this.mermaid_state,
608                                window,
609                                cx,
610                            )
611                            .with_checkbox_clicked_callback(cx.listener(
612                                move |this, e: &CheckboxClickedEvent, window, cx| {
613                                    if let Some(editor) =
614                                        this.active_editor.as_ref().map(|s| s.editor.clone())
615                                    {
616                                        editor.update(cx, |editor, cx| {
617                                            let task_marker =
618                                                if e.checked() { "[x]" } else { "[ ]" };
619
620                                            editor.edit(
621                                                [(
622                                                    MultiBufferOffset(e.source_range().start)
623                                                        ..MultiBufferOffset(e.source_range().end),
624                                                    task_marker,
625                                                )],
626                                                cx,
627                                            );
628                                        });
629                                        this.parse_markdown_from_active_editor(false, window, cx);
630                                        cx.notify();
631                                    }
632                                },
633                            ));
634
635                            let block = contents.children.get(ix).unwrap();
636                            let rendered_block = render_markdown_block(block, &mut render_cx);
637
638                            let should_apply_padding = Self::should_apply_padding_between(
639                                block,
640                                contents.children.get(ix + 1),
641                            );
642
643                            let selected_block = this.selected_block;
644                            let scaled_rems = render_cx.scaled_rems(1.0);
645                            div()
646                                .id(ix)
647                                .when(should_apply_padding, |this| {
648                                    this.pb(render_cx.scaled_rems(0.75))
649                                })
650                                .group("markdown-block")
651                                .on_click(cx.listener(
652                                    move |this, event: &ClickEvent, window, cx| {
653                                        if event.click_count() == 2
654                                            && let Some(source_range) = this
655                                                .contents
656                                                .as_ref()
657                                                .and_then(|c| c.children.get(ix))
658                                                .and_then(|block: &ParsedMarkdownElement| {
659                                                    block.source_range()
660                                                })
661                                        {
662                                            this.move_cursor_to_block(
663                                                window,
664                                                cx,
665                                                MultiBufferOffset(source_range.start)
666                                                    ..MultiBufferOffset(source_range.start),
667                                            );
668                                        }
669                                    },
670                                ))
671                                .map(move |container| {
672                                    let indicator = div()
673                                        .h_full()
674                                        .w(px(4.0))
675                                        .when(ix == selected_block, |this| {
676                                            this.bg(cx.theme().colors().border)
677                                        })
678                                        .group_hover("markdown-block", |s| {
679                                            if ix == selected_block {
680                                                s
681                                            } else {
682                                                s.bg(cx.theme().colors().border_variant)
683                                            }
684                                        })
685                                        .rounded_xs();
686
687                                    container.child(
688                                        div()
689                                            .relative()
690                                            .child(div().pl(scaled_rems).child(rendered_block))
691                                            .child(indicator.absolute().left_0().top_0()),
692                                    )
693                                })
694                                .into_any()
695                        }),
696                    )
697                    .size_full(),
698                )
699            }))
700            .vertical_scrollbar_for(&self.list_state, window, cx)
701    }
702}