markdown_preview_view.rs

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