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