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}