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