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