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