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_settings::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}