markdown_preview_view.rs

  1use std::cmp::min;
  2use std::path::{Path, PathBuf};
  3use std::sync::Arc;
  4use std::time::Duration;
  5
  6use anyhow::Result;
  7use editor::scroll::Autoscroll;
  8use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
  9use gpui::{
 10    App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement,
 11    IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString,
 12    SharedUri, Subscription, Task, WeakEntity, Window, point,
 13};
 14use language::LanguageRegistry;
 15use markdown::{
 16    CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle,
 17};
 18use settings::Settings;
 19use theme::ThemeSettings;
 20use ui::{WithScrollbar, prelude::*};
 21use util::normalize_path;
 22use workspace::item::{Item, ItemHandle};
 23use workspace::{OpenOptions, OpenVisible, Pane, Workspace};
 24
 25use crate::{
 26    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem,
 27};
 28use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
 29
 30const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
 31
 32pub struct MarkdownPreviewView {
 33    workspace: WeakEntity<Workspace>,
 34    active_editor: Option<EditorState>,
 35    focus_handle: FocusHandle,
 36    markdown: Entity<Markdown>,
 37    _markdown_subscription: Subscription,
 38    active_source_index: Option<usize>,
 39    scroll_handle: ScrollHandle,
 40    image_cache: Entity<RetainAllImageCache>,
 41    base_directory: Option<PathBuf>,
 42    pending_update_task: Option<Task<Result<()>>>,
 43    mode: MarkdownPreviewMode,
 44}
 45
 46#[derive(Clone, Copy, Debug, PartialEq)]
 47pub enum MarkdownPreviewMode {
 48    /// The preview will always show the contents of the provided editor.
 49    Default,
 50    /// The preview will "follow" the currently active editor.
 51    Follow,
 52}
 53
 54struct EditorState {
 55    editor: Entity<Editor>,
 56    _subscription: Subscription,
 57}
 58
 59impl MarkdownPreviewView {
 60    pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
 61        workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
 62            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
 63                let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
 64                workspace.active_pane().update(cx, |pane, cx| {
 65                    if let Some(existing_view_idx) =
 66                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
 67                    {
 68                        pane.activate_item(existing_view_idx, true, true, window, cx);
 69                    } else {
 70                        pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
 71                    }
 72                });
 73                cx.notify();
 74            }
 75        });
 76
 77        workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
 78            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
 79                let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
 80                let pane = workspace
 81                    .find_pane_in_direction(workspace::SplitDirection::Right, cx)
 82                    .unwrap_or_else(|| {
 83                        workspace.split_pane(
 84                            workspace.active_pane().clone(),
 85                            workspace::SplitDirection::Right,
 86                            window,
 87                            cx,
 88                        )
 89                    });
 90                pane.update(cx, |pane, cx| {
 91                    if let Some(existing_view_idx) =
 92                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
 93                    {
 94                        pane.activate_item(existing_view_idx, true, true, window, cx);
 95                    } else {
 96                        pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
 97                    }
 98                });
 99                editor.focus_handle(cx).focus(window, cx);
100                cx.notify();
101            }
102        });
103
104        workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
105            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
106                // Check if there's already a following preview
107                let existing_follow_view_idx = {
108                    let active_pane = workspace.active_pane().read(cx);
109                    active_pane
110                        .items_of_type::<MarkdownPreviewView>()
111                        .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow)
112                        .and_then(|view| active_pane.index_for_item(&view))
113                };
114
115                if let Some(existing_follow_view_idx) = existing_follow_view_idx {
116                    workspace.active_pane().update(cx, |pane, cx| {
117                        pane.activate_item(existing_follow_view_idx, true, true, window, cx);
118                    });
119                } else {
120                    let view = Self::create_following_markdown_view(workspace, editor, window, cx);
121                    workspace.active_pane().update(cx, |pane, cx| {
122                        pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
123                    });
124                }
125                cx.notify();
126            }
127        });
128    }
129
130    fn find_existing_independent_preview_item_idx(
131        pane: &Pane,
132        editor: &Entity<Editor>,
133        cx: &App,
134    ) -> Option<usize> {
135        pane.items_of_type::<MarkdownPreviewView>()
136            .find(|view| {
137                let view_read = view.read(cx);
138                // Only look for independent (Default mode) previews, not Follow previews
139                view_read.mode == MarkdownPreviewMode::Default
140                    && view_read
141                        .active_editor
142                        .as_ref()
143                        .is_some_and(|active_editor| active_editor.editor == *editor)
144            })
145            .and_then(|view| pane.index_for_item(&view))
146    }
147
148    pub fn resolve_active_item_as_markdown_editor(
149        workspace: &Workspace,
150        cx: &mut Context<Workspace>,
151    ) -> Option<Entity<Editor>> {
152        if let Some(editor) = workspace
153            .active_item(cx)
154            .and_then(|item| item.act_as::<Editor>(cx))
155            && Self::is_markdown_file(&editor, cx)
156        {
157            return Some(editor);
158        }
159        None
160    }
161
162    fn create_markdown_view(
163        workspace: &mut Workspace,
164        editor: Entity<Editor>,
165        window: &mut Window,
166        cx: &mut Context<Workspace>,
167    ) -> Entity<MarkdownPreviewView> {
168        let language_registry = workspace.project().read(cx).languages().clone();
169        let workspace_handle = workspace.weak_handle();
170        MarkdownPreviewView::new(
171            MarkdownPreviewMode::Default,
172            editor,
173            workspace_handle,
174            language_registry,
175            window,
176            cx,
177        )
178    }
179
180    fn create_following_markdown_view(
181        workspace: &mut Workspace,
182        editor: Entity<Editor>,
183        window: &mut Window,
184        cx: &mut Context<Workspace>,
185    ) -> Entity<MarkdownPreviewView> {
186        let language_registry = workspace.project().read(cx).languages().clone();
187        let workspace_handle = workspace.weak_handle();
188        MarkdownPreviewView::new(
189            MarkdownPreviewMode::Follow,
190            editor,
191            workspace_handle,
192            language_registry,
193            window,
194            cx,
195        )
196    }
197
198    pub fn new(
199        mode: MarkdownPreviewMode,
200        active_editor: Entity<Editor>,
201        workspace: WeakEntity<Workspace>,
202        language_registry: Arc<LanguageRegistry>,
203        window: &mut Window,
204        cx: &mut Context<Workspace>,
205    ) -> Entity<Self> {
206        cx.new(|cx| {
207            let markdown = cx.new(|cx| {
208                Markdown::new_with_options(
209                    SharedString::default(),
210                    Some(language_registry),
211                    None,
212                    MarkdownOptions {
213                        parse_html: true,
214                        render_mermaid_diagrams: true,
215                        ..Default::default()
216                    },
217                    cx,
218                )
219            });
220            let mut this = Self {
221                active_editor: None,
222                focus_handle: cx.focus_handle(),
223                workspace: workspace.clone(),
224                _markdown_subscription: cx.observe(
225                    &markdown,
226                    |this: &mut Self, _: Entity<Markdown>, cx| {
227                        this.sync_active_root_block(cx);
228                    },
229                ),
230                markdown,
231                active_source_index: None,
232                scroll_handle: ScrollHandle::new(),
233                image_cache: RetainAllImageCache::new(cx),
234                base_directory: None,
235                pending_update_task: None,
236                mode,
237            };
238
239            this.set_editor(active_editor, window, cx);
240
241            if mode == MarkdownPreviewMode::Follow {
242                if let Some(workspace) = &workspace.upgrade() {
243                    cx.observe_in(workspace, window, |this, workspace, window, cx| {
244                        let item = workspace.read(cx).active_item(cx);
245                        this.workspace_updated(item, window, cx);
246                    })
247                    .detach();
248                } else {
249                    log::error!("Failed to listen to workspace updates");
250                }
251            }
252
253            this
254        })
255    }
256
257    fn workspace_updated(
258        &mut self,
259        active_item: Option<Box<dyn ItemHandle>>,
260        window: &mut Window,
261        cx: &mut Context<Self>,
262    ) {
263        if let Some(item) = active_item
264            && item.item_id() != cx.entity_id()
265            && let Some(editor) = item.act_as::<Editor>(cx)
266            && Self::is_markdown_file(&editor, cx)
267        {
268            self.set_editor(editor, window, cx);
269        }
270    }
271
272    pub fn is_markdown_file<V>(editor: &Entity<Editor>, cx: &mut Context<V>) -> bool {
273        let buffer = editor.read(cx).buffer().read(cx);
274        if let Some(buffer) = buffer.as_singleton()
275            && let Some(language) = buffer.read(cx).language()
276        {
277            return language.name() == "Markdown";
278        }
279        false
280    }
281
282    fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
283        if let Some(active) = &self.active_editor
284            && active.editor == editor
285        {
286            return;
287        }
288
289        let subscription = cx.subscribe_in(
290            &editor,
291            window,
292            |this, editor, event: &EditorEvent, window, cx| {
293                match event {
294                    EditorEvent::Edited { .. }
295                    | EditorEvent::BufferEdited { .. }
296                    | EditorEvent::DirtyChanged
297                    | EditorEvent::ExcerptsEdited { .. } => {
298                        this.update_markdown_from_active_editor(true, false, window, cx);
299                    }
300                    EditorEvent::SelectionsChanged { .. } => {
301                        let (selection_start, editor_is_focused) =
302                            editor.update(cx, |editor, cx| {
303                                let index = Self::selected_source_index(editor, cx);
304                                let focused = editor.focus_handle(cx).is_focused(window);
305                                (index, focused)
306                            });
307                        this.sync_preview_to_source_index(selection_start, editor_is_focused, cx);
308                        cx.notify();
309                    }
310                    _ => {}
311                };
312            },
313        );
314
315        self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx);
316        self.active_editor = Some(EditorState {
317            editor,
318            _subscription: subscription,
319        });
320
321        self.update_markdown_from_active_editor(false, true, window, cx);
322    }
323
324    fn update_markdown_from_active_editor(
325        &mut self,
326        wait_for_debounce: bool,
327        should_reveal: bool,
328        window: &mut Window,
329        cx: &mut Context<Self>,
330    ) {
331        if let Some(state) = &self.active_editor {
332            // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
333            if wait_for_debounce && self.pending_update_task.is_some() {
334                return;
335            }
336            self.pending_update_task = Some(self.schedule_markdown_update(
337                wait_for_debounce,
338                should_reveal,
339                state.editor.clone(),
340                window,
341                cx,
342            ));
343        }
344    }
345
346    fn schedule_markdown_update(
347        &mut self,
348        wait_for_debounce: bool,
349        should_reveal_selection: bool,
350        editor: Entity<Editor>,
351        window: &mut Window,
352        cx: &mut Context<Self>,
353    ) -> Task<Result<()>> {
354        cx.spawn_in(window, async move |view, cx| {
355            if wait_for_debounce {
356                // Wait for the user to stop typing
357                cx.background_executor().timer(REPARSE_DEBOUNCE).await;
358            }
359
360            let editor_clone = editor.clone();
361            let update = view.update(cx, |view, cx| {
362                let is_active_editor = view
363                    .active_editor
364                    .as_ref()
365                    .is_some_and(|active_editor| active_editor.editor == editor_clone);
366                if !is_active_editor {
367                    return None;
368                }
369
370                let (contents, selection_start) = editor_clone.update(cx, |editor, cx| {
371                    let contents = editor.buffer().read(cx).snapshot(cx).text();
372                    let selection_start = Self::selected_source_index(editor, cx);
373                    (contents, selection_start)
374                });
375                Some((SharedString::from(contents), selection_start))
376            })?;
377
378            view.update(cx, move |view, cx| {
379                if let Some((contents, selection_start)) = update {
380                    view.markdown.update(cx, |markdown, cx| {
381                        markdown.reset(contents, cx);
382                    });
383                    view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx);
384                }
385                view.pending_update_task = None;
386                cx.notify();
387            })
388        })
389    }
390
391    fn selected_source_index(editor: &Editor, cx: &mut App) -> usize {
392        editor
393            .selections
394            .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
395            .range()
396            .start
397            .0
398    }
399
400    fn sync_preview_to_source_index(
401        &mut self,
402        source_index: usize,
403        reveal: bool,
404        cx: &mut Context<Self>,
405    ) {
406        self.active_source_index = Some(source_index);
407        self.sync_active_root_block(cx);
408        self.markdown.update(cx, |markdown, cx| {
409            if reveal {
410                markdown.request_autoscroll_to_source_index(source_index, cx);
411            }
412        });
413    }
414
415    fn sync_active_root_block(&mut self, cx: &mut Context<Self>) {
416        self.markdown.update(cx, |markdown, cx| {
417            markdown.set_active_root_for_source_index(self.active_source_index, cx);
418        });
419    }
420
421    fn move_cursor_to_source_index(
422        editor: &Entity<Editor>,
423        source_index: usize,
424        window: &mut Window,
425        cx: &mut App,
426    ) {
427        editor.update(cx, |editor, cx| {
428            let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index);
429            editor.change_selections(
430                SelectionEffects::scroll(Autoscroll::center()),
431                window,
432                cx,
433                |selections| selections.select_ranges(vec![selection]),
434            );
435            window.focus(&editor.focus_handle(cx), cx);
436        });
437    }
438
439    /// The absolute path of the file that is currently being previewed.
440    fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option<PathBuf> {
441        if let Some(file) = editor.file_at(MultiBufferOffset(0), cx) {
442            if let Some(file) = file.as_local() {
443                file.abs_path(cx).parent().map(|p| p.to_path_buf())
444            } else {
445                None
446            }
447        } else {
448            None
449        }
450    }
451
452    fn line_scroll_amount(&self, cx: &App) -> Pixels {
453        let settings = ThemeSettings::get_global(cx);
454        settings.buffer_font_size(cx) * settings.buffer_line_height.value()
455    }
456
457    fn scroll_by_amount(&self, distance: Pixels) {
458        let offset = self.scroll_handle.offset();
459        self.scroll_handle
460            .set_offset(point(offset.x, offset.y - distance));
461    }
462
463    fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
464        let viewport_height = self.scroll_handle.bounds().size.height;
465        if viewport_height.is_zero() {
466            return;
467        }
468
469        self.scroll_by_amount(-viewport_height);
470        cx.notify();
471    }
472
473    fn scroll_page_down(
474        &mut self,
475        _: &ScrollPageDown,
476        _window: &mut Window,
477        cx: &mut Context<Self>,
478    ) {
479        let viewport_height = self.scroll_handle.bounds().size.height;
480        if viewport_height.is_zero() {
481            return;
482        }
483
484        self.scroll_by_amount(viewport_height);
485        cx.notify();
486    }
487
488    fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
489        if let Some(bounds) = self
490            .scroll_handle
491            .bounds_for_item(self.scroll_handle.top_item())
492        {
493            let item_height = bounds.size.height;
494            // Scroll no more than the rough equivalent of a large headline
495            let max_height = window.rem_size() * 2;
496            let scroll_height = min(item_height, max_height);
497            self.scroll_by_amount(-scroll_height);
498        } else {
499            let scroll_height = self.line_scroll_amount(cx);
500            if !scroll_height.is_zero() {
501                self.scroll_by_amount(-scroll_height);
502            }
503        }
504        cx.notify();
505    }
506
507    fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
508        if let Some(bounds) = self
509            .scroll_handle
510            .bounds_for_item(self.scroll_handle.top_item())
511        {
512            let item_height = bounds.size.height;
513            // Scroll no more than the rough equivalent of a large headline
514            let max_height = window.rem_size() * 2;
515            let scroll_height = min(item_height, max_height);
516            self.scroll_by_amount(scroll_height);
517        } else {
518            let scroll_height = self.line_scroll_amount(cx);
519            if !scroll_height.is_zero() {
520                self.scroll_by_amount(scroll_height);
521            }
522        }
523        cx.notify();
524    }
525
526    fn scroll_up_by_item(
527        &mut self,
528        _: &ScrollUpByItem,
529        _window: &mut Window,
530        cx: &mut Context<Self>,
531    ) {
532        if let Some(bounds) = self
533            .scroll_handle
534            .bounds_for_item(self.scroll_handle.top_item())
535        {
536            self.scroll_by_amount(-bounds.size.height);
537        }
538        cx.notify();
539    }
540
541    fn scroll_down_by_item(
542        &mut self,
543        _: &ScrollDownByItem,
544        _window: &mut Window,
545        cx: &mut Context<Self>,
546    ) {
547        if let Some(bounds) = self
548            .scroll_handle
549            .bounds_for_item(self.scroll_handle.top_item())
550        {
551            self.scroll_by_amount(bounds.size.height);
552        }
553        cx.notify();
554    }
555
556    fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
557        self.scroll_handle.scroll_to_item(0);
558        cx.notify();
559    }
560
561    fn scroll_to_bottom(
562        &mut self,
563        _: &ScrollToBottom,
564        _window: &mut Window,
565        cx: &mut Context<Self>,
566    ) {
567        self.scroll_handle.scroll_to_bottom();
568        cx.notify();
569    }
570
571    fn render_markdown_element(
572        &self,
573        window: &mut Window,
574        cx: &mut Context<Self>,
575    ) -> MarkdownElement {
576        let workspace = self.workspace.clone();
577        let base_directory = self.base_directory.clone();
578        let active_editor = self
579            .active_editor
580            .as_ref()
581            .map(|state| state.editor.clone());
582
583        let mut markdown_element = MarkdownElement::new(
584            self.markdown.clone(),
585            MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
586        )
587        .code_block_renderer(CodeBlockRenderer::Default {
588            copy_button: false,
589            copy_button_on_hover: true,
590            border: false,
591        })
592        .scroll_handle(self.scroll_handle.clone())
593        .show_root_block_markers()
594        .image_resolver({
595            let base_directory = self.base_directory.clone();
596            move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref())
597        })
598        .on_url_click(move |url, window, cx| {
599            open_preview_url(url, base_directory.clone(), &workspace, window, cx);
600        });
601
602        if let Some(active_editor) = active_editor {
603            let editor_for_checkbox = active_editor.clone();
604            let view_handle = cx.entity().downgrade();
605            markdown_element = markdown_element
606                .on_source_click(move |source_index, click_count, window, cx| {
607                    if click_count == 2 {
608                        Self::move_cursor_to_source_index(&active_editor, source_index, window, cx);
609                        true
610                    } else {
611                        false
612                    }
613                })
614                .on_checkbox_toggle(move |source_range, new_checked, window, cx| {
615                    let task_marker = if new_checked { "[x]" } else { "[ ]" };
616                    editor_for_checkbox.update(cx, |editor, cx| {
617                        editor.edit(
618                            [(
619                                MultiBufferOffset(source_range.start)
620                                    ..MultiBufferOffset(source_range.end),
621                                task_marker,
622                            )],
623                            cx,
624                        );
625                    });
626                    if let Some(view) = view_handle.upgrade() {
627                        cx.update_entity(&view, |this, cx| {
628                            this.update_markdown_from_active_editor(false, false, window, cx);
629                        });
630                    }
631                });
632        }
633
634        markdown_element
635    }
636}
637
638fn open_preview_url(
639    url: SharedString,
640    base_directory: Option<PathBuf>,
641    workspace: &WeakEntity<Workspace>,
642    window: &mut Window,
643    cx: &mut App,
644) {
645    if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref())
646        && let Some(workspace) = workspace.upgrade()
647    {
648        let _ = workspace.update(cx, |workspace, cx| {
649            workspace
650                .open_abs_path(
651                    normalize_path(path.as_path()),
652                    OpenOptions {
653                        visible: Some(OpenVisible::None),
654                        ..Default::default()
655                    },
656                    window,
657                    cx,
658                )
659                .detach();
660        });
661        return;
662    }
663
664    cx.open_url(url.as_ref());
665}
666
667fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option<PathBuf> {
668    if url.starts_with("http://") || url.starts_with("https://") {
669        return None;
670    }
671
672    let decoded_url = urlencoding::decode(url)
673        .map(|decoded| decoded.into_owned())
674        .unwrap_or_else(|_| url.to_string());
675    let candidate = PathBuf::from(&decoded_url);
676
677    if candidate.is_absolute() && candidate.exists() {
678        return Some(candidate);
679    }
680
681    let base_directory = base_directory?;
682    let resolved = base_directory.join(decoded_url);
683    if resolved.exists() {
684        Some(resolved)
685    } else {
686        None
687    }
688}
689
690fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option<ImageSource> {
691    if dest_url.starts_with("data:") {
692        return None;
693    }
694
695    if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
696        return Some(ImageSource::Resource(Resource::Uri(SharedUri::from(
697            dest_url.to_string(),
698        ))));
699    }
700
701    let decoded = urlencoding::decode(dest_url)
702        .map(|decoded| decoded.into_owned())
703        .unwrap_or_else(|_| dest_url.to_string());
704
705    let path = if Path::new(&decoded).is_absolute() {
706        PathBuf::from(decoded)
707    } else {
708        base_directory?.join(decoded)
709    };
710
711    Some(ImageSource::Resource(Resource::Path(Arc::from(
712        path.as_path(),
713    ))))
714}
715
716impl Focusable for MarkdownPreviewView {
717    fn focus_handle(&self, _: &App) -> FocusHandle {
718        self.focus_handle.clone()
719    }
720}
721
722impl EventEmitter<()> for MarkdownPreviewView {}
723
724impl Item for MarkdownPreviewView {
725    type Event = ();
726
727    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
728        Some(Icon::new(IconName::FileDoc))
729    }
730
731    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
732        self.active_editor
733            .as_ref()
734            .map(|editor_state| {
735                let buffer = editor_state.editor.read(cx).buffer().read(cx);
736                let title = buffer.title(cx);
737                format!("Preview {}", title).into()
738            })
739            .unwrap_or_else(|| SharedString::from("Markdown Preview"))
740    }
741
742    fn telemetry_event_text(&self) -> Option<&'static str> {
743        Some("Markdown Preview Opened")
744    }
745
746    fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {}
747}
748
749impl Render for MarkdownPreviewView {
750    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
751        div()
752            .image_cache(self.image_cache.clone())
753            .id("MarkdownPreview")
754            .key_context("MarkdownPreview")
755            .track_focus(&self.focus_handle(cx))
756            .on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
757            .on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
758            .on_action(cx.listener(MarkdownPreviewView::scroll_up))
759            .on_action(cx.listener(MarkdownPreviewView::scroll_down))
760            .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item))
761            .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
762            .on_action(cx.listener(MarkdownPreviewView::scroll_to_top))
763            .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
764            .size_full()
765            .bg(cx.theme().colors().editor_background)
766            .child(
767                div()
768                    .id("markdown-preview-scroll-container")
769                    .size_full()
770                    .overflow_y_scroll()
771                    .track_scroll(&self.scroll_handle)
772                    .p_4()
773                    .child(self.render_markdown_element(window, cx)),
774            )
775            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use anyhow::Result;
782    use std::fs;
783    use tempfile::TempDir;
784
785    use super::resolve_preview_path;
786
787    #[test]
788    fn resolves_relative_preview_paths() -> Result<()> {
789        let temp_dir = TempDir::new()?;
790        let base_directory = temp_dir.path();
791        let file = base_directory.join("notes.md");
792        fs::write(&file, "# Notes")?;
793
794        assert_eq!(
795            resolve_preview_path("notes.md", Some(base_directory)),
796            Some(file)
797        );
798        assert_eq!(
799            resolve_preview_path("nonexistent.md", Some(base_directory)),
800            None
801        );
802        assert_eq!(resolve_preview_path("notes.md", None), None);
803
804        Ok(())
805    }
806
807    #[test]
808    fn resolves_urlencoded_preview_paths() -> Result<()> {
809        let temp_dir = TempDir::new()?;
810        let base_directory = temp_dir.path();
811        let file = base_directory.join("release notes.md");
812        fs::write(&file, "# Release Notes")?;
813
814        assert_eq!(
815            resolve_preview_path("release%20notes.md", Some(base_directory)),
816            Some(file)
817        );
818
819        Ok(())
820    }
821
822    #[test]
823    fn does_not_treat_web_links_as_preview_paths() {
824        assert_eq!(resolve_preview_path("https://zed.dev", None), None);
825        assert_eq!(resolve_preview_path("http://example.com", None), None);
826    }
827}