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