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