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