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