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