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