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