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