Skip to repository content2870 lines · 103.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:39:38.559Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
markdown_preview_view.rs
1use std::any::TypeId;
2use std::borrow::Cow;
3use std::cmp::min;
4use std::ops::Range;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::{Context as _, Result};
10use editor::scroll::Autoscroll;
11use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
12use gpui::{
13 App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource,
14 InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache,
15 ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px,
16};
17use language::{LanguageRegistry, Point};
18use markdown::{
19 CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont,
20 MarkdownOptions, MarkdownStyle,
21};
22use project::search::SearchQuery;
23use project::{Project, ProjectPath};
24use settings::{SeedQuerySetting, Settings, update_settings_file};
25use theme::{SystemAppearance, Theme, ThemeRegistry};
26use theme_settings::ThemeSettings;
27use ui::utils::WithRemSize;
28use ui::{ContextMenu, LinkPreview, WithScrollbar, prelude::*, right_click_menu};
29use util::{
30 ResultExt,
31 markdown::{source_position_from_fragment, split_local_url_fragment},
32};
33use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem};
34use workspace::notifications::NotifyResultExt;
35use workspace::searchable::{
36 Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
37};
38use workspace::{ItemId, Pane, SaveIntent, Workspace, WorkspaceId, delete_unloaded_items};
39use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
40
41use crate::markdown_preview_settings::MarkdownPreviewSettings;
42use crate::{
43 CloseAndReturnToEditor, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown,
44 ScrollDownByItem,
45};
46use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
47
48const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
49
50pub struct MarkdownPreviewView {
51 workspace: WeakEntity<Workspace>,
52 active_editor: Option<EditorState>,
53 focus_handle: FocusHandle,
54 markdown: Entity<Markdown>,
55 _markdown_subscription: Subscription,
56 active_source_index: Option<usize>,
57 scroll_handle: ScrollHandle,
58 image_cache: Entity<RetainAllImageCache>,
59 base_directory: Option<PathBuf>,
60 pending_update_task: Option<Task<Result<()>>>,
61 hovered_url: Option<SharedString>,
62 mode: MarkdownPreviewMode,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub enum MarkdownPreviewMode {
67 /// The preview will always show the contents of the provided editor.
68 Default,
69 /// The preview will "follow" the currently active editor.
70 Follow,
71}
72
73impl MarkdownPreviewMode {
74 fn to_db(self) -> i64 {
75 match self {
76 Self::Default => 0,
77 Self::Follow => 1,
78 }
79 }
80
81 fn from_db(value: i64) -> Self {
82 match value {
83 1 => Self::Follow,
84 _ => Self::Default,
85 }
86 }
87}
88
89struct EditorState {
90 editor: Entity<Editor>,
91 _subscription: Subscription,
92}
93
94#[derive(Clone, Copy, Debug)]
95pub enum MarkdownPreviewEvent {
96 SourceEditorChanged,
97 SourceFileHandleChanged,
98}
99
100impl MarkdownPreviewView {
101 pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
102 workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
103 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
104 let pane = workspace.active_pane().clone();
105 Self::open_preview_in_pane(workspace, editor, pane, window, cx);
106 }
107 });
108
109 workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
110 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
111 let pane = workspace.active_pane().clone();
112 Self::open_preview_to_the_side_of_pane(workspace, editor, pane, window, cx);
113 }
114 });
115
116 workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
117 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
118 // Check if there's already a following preview
119 let existing_follow_view_idx = {
120 let active_pane = workspace.active_pane().read(cx);
121 active_pane
122 .items_of_type::<MarkdownPreviewView>()
123 .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow)
124 .and_then(|view| active_pane.index_for_item(&view))
125 };
126
127 if let Some(existing_follow_view_idx) = existing_follow_view_idx {
128 workspace.active_pane().update(cx, |pane, cx| {
129 pane.activate_item(existing_follow_view_idx, true, true, window, cx);
130 });
131 } else {
132 let view = Self::create_following_markdown_view(workspace, editor, window, cx);
133 workspace.active_pane().update(cx, |pane, cx| {
134 pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
135 });
136 }
137 cx.notify();
138 }
139 });
140 }
141
142 pub fn open_preview_in_pane(
143 workspace: &mut Workspace,
144 editor: Entity<Editor>,
145 pane: Entity<Pane>,
146 window: &mut Window,
147 cx: &mut Context<Workspace>,
148 ) {
149 Self::activate_or_add_preview(workspace, editor, pane, true, window, cx);
150 }
151
152 pub fn open_preview_to_the_side_of_pane(
153 workspace: &mut Workspace,
154 editor: Entity<Editor>,
155 origin_pane: Entity<Pane>,
156 window: &mut Window,
157 cx: &mut Context<Workspace>,
158 ) {
159 let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx);
160 Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx);
161 editor.focus_handle(cx).focus(window, cx);
162 }
163
164 fn activate_or_add_preview(
165 workspace: &mut Workspace,
166 editor: Entity<Editor>,
167 pane: Entity<Pane>,
168 focus: bool,
169 window: &mut Window,
170 cx: &mut Context<Workspace>,
171 ) {
172 let existing_view_idx =
173 Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx);
174 if let Some(existing_view_idx) = existing_view_idx {
175 pane.update(cx, |pane, cx| {
176 pane.activate_item(existing_view_idx, focus, focus, window, cx);
177 });
178 } else {
179 let view = Self::create_markdown_view(workspace, editor, window, cx);
180 pane.update(cx, |pane, cx| {
181 pane.add_item(Box::new(view), focus, focus, None, window, cx)
182 });
183 }
184 cx.notify();
185 }
186
187 fn find_existing_independent_preview_item_idx(
188 pane: &Pane,
189 editor: &Entity<Editor>,
190 cx: &App,
191 ) -> Option<usize> {
192 let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?;
193 pane.items_of_type::<MarkdownPreviewView>()
194 .find(|view| {
195 let view_read = view.read(cx);
196 // Only look for independent (Default mode) previews, not Follow previews.
197 // Match by buffer entity rather than editor entity so the lookup survives
198 // workspace restoration, where the preview's bound editor may differ from
199 // the editor the user is currently invoking the action on even though both
200 // wrap the same source buffer.
201 view_read.mode == MarkdownPreviewMode::Default
202 && view_read
203 .active_editor
204 .as_ref()
205 .is_some_and(|active_editor| {
206 active_editor
207 .editor
208 .read(cx)
209 .buffer()
210 .read(cx)
211 .as_singleton()
212 .as_ref()
213 == Some(&target_buffer)
214 })
215 })
216 .and_then(|view| pane.index_for_item(&view))
217 }
218
219 pub fn resolve_active_item_as_markdown_editor(
220 workspace: &Workspace,
221 cx: &mut Context<Workspace>,
222 ) -> Option<Entity<Editor>> {
223 if let Some(editor) = workspace
224 .active_item(cx)
225 .and_then(|item| item.act_as::<Editor>(cx))
226 && Self::is_markdown_file(&editor, cx)
227 {
228 return Some(editor);
229 }
230 None
231 }
232
233 pub fn create_markdown_view(
234 workspace: &mut Workspace,
235 editor: Entity<Editor>,
236 window: &mut Window,
237 cx: &mut Context<Workspace>,
238 ) -> Entity<MarkdownPreviewView> {
239 let language_registry = workspace.project().read(cx).languages().clone();
240 let workspace_handle = workspace.weak_handle();
241 MarkdownPreviewView::new(
242 MarkdownPreviewMode::Default,
243 editor,
244 workspace_handle,
245 language_registry,
246 window,
247 cx,
248 )
249 }
250
251 fn create_following_markdown_view(
252 workspace: &mut Workspace,
253 editor: Entity<Editor>,
254 window: &mut Window,
255 cx: &mut Context<Workspace>,
256 ) -> Entity<MarkdownPreviewView> {
257 let language_registry = workspace.project().read(cx).languages().clone();
258 let workspace_handle = workspace.weak_handle();
259 MarkdownPreviewView::new(
260 MarkdownPreviewMode::Follow,
261 editor,
262 workspace_handle,
263 language_registry,
264 window,
265 cx,
266 )
267 }
268
269 pub fn new(
270 mode: MarkdownPreviewMode,
271 active_editor: Entity<Editor>,
272 workspace: WeakEntity<Workspace>,
273 language_registry: Arc<LanguageRegistry>,
274 window: &mut Window,
275 cx: &mut App,
276 ) -> Entity<Self> {
277 cx.new(|cx| {
278 let markdown = cx.new(|cx| {
279 Markdown::new_with_options(
280 SharedString::default(),
281 Some(language_registry),
282 None,
283 MarkdownOptions {
284 parse_html: true,
285 render_mermaid_diagrams: true,
286 parse_heading_slugs: true,
287 render_metadata_blocks: true,
288 ..Default::default()
289 },
290 cx,
291 )
292 });
293 let mut this = Self {
294 active_editor: None,
295 focus_handle: cx.focus_handle(),
296 workspace: workspace.clone(),
297 _markdown_subscription: cx.observe(
298 &markdown,
299 |this: &mut Self, _: Entity<Markdown>, cx| {
300 this.sync_active_root_block(cx);
301 },
302 ),
303 markdown,
304 active_source_index: None,
305 scroll_handle: ScrollHandle::new(),
306 image_cache: RetainAllImageCache::new(cx),
307 base_directory: None,
308 pending_update_task: None,
309 hovered_url: None,
310 mode,
311 };
312
313 this.set_editor(active_editor, window, cx);
314
315 match mode {
316 MarkdownPreviewMode::Follow => {
317 if let Some(workspace) = &workspace.upgrade() {
318 cx.observe_in(workspace, window, |this, workspace, window, cx| {
319 let item = workspace.read(cx).active_item(cx);
320 this.workspace_updated(item, window, cx);
321 })
322 .detach();
323 } else {
324 log::error!("Failed to listen to workspace updates");
325 }
326 }
327 MarkdownPreviewMode::Default => {
328 // After workspace restoration the bound editor may be an orphan that
329 // wraps the right buffer but isn't the canonical Editor instance in
330 // any pane. Re-binding to the workspace's editor for our buffer is
331 // what restores cursor-driven scroll sync — `SelectionsChanged` only
332 // fires from the editor the user actually interacts with.
333 //
334 // Subscribing to `workspace::Event` (rather than `observe`) keeps the
335 // rebind check off the cursor-move hot path; `observe` would fire on
336 // every workspace `cx.notify`.
337 if let Some(workspace) = &workspace.upgrade() {
338 cx.subscribe_in(workspace, window, Self::on_workspace_event)
339 .detach();
340 }
341 }
342 }
343
344 this
345 })
346 }
347
348 fn workspace_updated(
349 &mut self,
350 active_item: Option<Box<dyn ItemHandle>>,
351 window: &mut Window,
352 cx: &mut Context<Self>,
353 ) {
354 if let Some(item) = active_item
355 && item.item_id() != cx.entity_id()
356 && let Some(editor) = item.act_as::<Editor>(cx)
357 && Self::is_markdown_file(&editor, cx)
358 {
359 self.set_editor(editor, window, cx);
360 }
361 }
362
363 pub fn is_markdown_path(path: impl AsRef<Path>) -> bool {
364 path.as_ref().extension().is_some_and(|ext| {
365 ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown")
366 })
367 }
368
369 pub fn open_for_project_path(
370 project_path: ProjectPath,
371 workspace: &mut Workspace,
372 window: &mut Window,
373 cx: &mut Context<Workspace>,
374 ) {
375 let open_buffer = workspace
376 .project()
377 .update(cx, |project, cx| project.open_buffer(project_path, cx));
378
379 cx.spawn_in(window, async move |workspace, mut cx| {
380 let Some(buffer) = open_buffer
381 .await
382 .notify_workspace_async_err(workspace.clone(), &mut cx)
383 else {
384 return;
385 };
386 workspace
387 .update_in(cx, |workspace, window, cx| {
388 let project = workspace.project().clone();
389 let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
390 let preview = Self::create_markdown_view(workspace, editor, window, cx);
391 workspace.active_pane().update(cx, |pane, cx| {
392 pane.add_item(Box::new(preview), true, true, None, window, cx);
393 });
394 })
395 .ok();
396 })
397 .detach();
398 }
399
400 pub fn is_markdown_file(editor: &Entity<Editor>, cx: &App) -> bool {
401 let buffer = editor.read(cx).buffer().read(cx);
402 if let Some(buffer) = buffer.as_singleton()
403 && let Some(language) = buffer.read(cx).language()
404 {
405 return language.name() == "Markdown";
406 }
407 false
408 }
409
410 fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
411 if let Some(active) = &self.active_editor
412 && active.editor == editor
413 {
414 return;
415 }
416
417 let had_active_editor = self.active_editor.is_some();
418 let subscription = cx.subscribe_in(
419 &editor,
420 window,
421 |this, editor, event: &EditorEvent, window, cx| {
422 match event {
423 EditorEvent::Edited { .. }
424 | EditorEvent::BufferEdited { .. }
425 | EditorEvent::DirtyChanged
426 | EditorEvent::BuffersEdited { .. } => {
427 this.update_markdown_from_active_editor(true, false, window, cx);
428 }
429 EditorEvent::FileHandleChanged => {
430 this.base_directory =
431 Self::get_folder_for_active_editor(editor.read(cx), cx);
432 this.update_markdown_from_active_editor(false, false, window, cx);
433 cx.emit(MarkdownPreviewEvent::SourceFileHandleChanged);
434 }
435 EditorEvent::SelectionsChanged { .. } => {
436 let (selection_start, editor_is_focused) =
437 editor.update(cx, |editor, cx| {
438 let index = Self::selected_source_index(editor, cx);
439 let focused = editor.focus_handle(cx).is_focused(window);
440 (index, focused)
441 });
442 if let Some(selection_start) = selection_start {
443 this.sync_preview_to_source_index(
444 selection_start,
445 editor_is_focused,
446 cx,
447 );
448 cx.notify();
449 }
450 }
451 _ => {}
452 };
453 },
454 );
455
456 self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx);
457 self.hovered_url = None;
458 self.active_editor = Some(EditorState {
459 editor,
460 _subscription: subscription,
461 });
462 self.update_markdown_from_active_editor(false, true, window, cx);
463 if had_active_editor {
464 cx.emit(MarkdownPreviewEvent::SourceEditorChanged);
465 }
466 }
467
468 fn on_workspace_event(
469 &mut self,
470 workspace: &Entity<Workspace>,
471 event: &workspace::Event,
472 window: &mut Window,
473 cx: &mut Context<Self>,
474 ) {
475 if !matches!(
476 event,
477 workspace::Event::ItemAdded { .. } | workspace::Event::ItemRemoved { .. }
478 ) {
479 return;
480 }
481 let candidate = self.find_canonical_editor(workspace.read(cx), cx);
482 if let Some(editor) = candidate
483 && self
484 .active_editor
485 .as_ref()
486 .is_none_or(|s| s.editor != editor)
487 {
488 self.set_editor(editor, window, cx);
489 }
490 }
491
492 fn find_canonical_editor(&self, workspace: &Workspace, cx: &App) -> Option<Entity<Editor>> {
493 let current = self.active_editor.as_ref()?.editor.clone();
494 let our_buffer = current.read(cx).buffer().read(cx).as_singleton()?;
495 let mut fallback = None;
496 for editor in workspace.items_of_type::<Editor>(cx) {
497 if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&our_buffer) {
498 continue;
499 }
500 if editor == current {
501 return Some(current);
502 }
503 fallback.get_or_insert(editor);
504 }
505 fallback
506 }
507
508 fn update_markdown_from_active_editor(
509 &mut self,
510 wait_for_debounce: bool,
511 should_reveal: bool,
512 window: &mut Window,
513 cx: &mut Context<Self>,
514 ) {
515 if let Some(state) = &self.active_editor {
516 // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
517 if wait_for_debounce && self.pending_update_task.is_some() {
518 return;
519 }
520 self.pending_update_task = Some(self.schedule_markdown_update(
521 wait_for_debounce,
522 should_reveal,
523 state.editor.clone(),
524 window,
525 cx,
526 ));
527 }
528 }
529
530 fn schedule_markdown_update(
531 &mut self,
532 wait_for_debounce: bool,
533 should_reveal_selection: bool,
534 editor: Entity<Editor>,
535 window: &mut Window,
536 cx: &mut Context<Self>,
537 ) -> Task<Result<()>> {
538 cx.spawn_in(window, async move |view, cx| {
539 if wait_for_debounce {
540 // Wait for the user to stop typing
541 cx.background_executor().timer(REPARSE_DEBOUNCE).await;
542 }
543
544 let update = view.update(cx, |view, cx| {
545 let is_active_editor = view
546 .active_editor
547 .as_ref()
548 .is_some_and(|active_editor| active_editor.editor == editor);
549 if !is_active_editor {
550 return None;
551 }
552
553 editor.update(cx, |editor, cx| {
554 let contents = editor
555 .buffer()
556 .read(cx)
557 .as_singleton()?
558 .read(cx)
559 .as_rope()
560 .to_string()
561 .into();
562 let selection_start = Self::selected_source_index(editor, cx)?;
563 Some((contents, selection_start))
564 })
565 })?;
566
567 view.update(cx, move |view, cx| {
568 if let Some((contents, selection_start)) = update {
569 view.hovered_url = None;
570 view.markdown.update(cx, |markdown, cx| {
571 markdown.reset(contents, cx);
572 });
573 view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx);
574 cx.emit(SearchEvent::MatchesInvalidated);
575 }
576 view.pending_update_task = None;
577 cx.notify();
578 })
579 })
580 }
581
582 fn selected_source_index(editor: &Editor, cx: &mut App) -> Option<usize> {
583 let display_snapshot = editor.display_snapshot(cx);
584 let source_offset = editor
585 .selections
586 .last::<MultiBufferOffset>(&display_snapshot)
587 .range()
588 .start;
589 let buffer = editor.buffer().read(cx).as_singleton()?;
590 let buffer_id = buffer.read(cx).remote_id();
591 let (buffer_snapshot, buffer_offset) = display_snapshot
592 .buffer_snapshot()
593 .point_to_buffer_offset(source_offset)?;
594
595 if buffer_snapshot.remote_id() == buffer_id {
596 Some(buffer_offset.0)
597 } else {
598 None
599 }
600 }
601
602 fn sync_preview_to_source_index(
603 &mut self,
604 source_index: usize,
605 reveal: bool,
606 cx: &mut Context<Self>,
607 ) {
608 self.active_source_index = Some(source_index);
609 self.sync_active_root_block(cx);
610 self.markdown.update(cx, |markdown, cx| {
611 if reveal {
612 markdown.request_autoscroll_to_source_index(source_index, cx);
613 }
614 });
615 }
616
617 fn sync_active_root_block(&mut self, cx: &mut Context<Self>) {
618 self.markdown.update(cx, |markdown, cx| {
619 markdown.set_active_root_for_source_index(self.active_source_index, cx);
620 });
621 }
622
623 fn change_selection_to_source_index(
624 editor: &Entity<Editor>,
625 source_index: usize,
626 move_focus: bool,
627 window: &mut Window,
628 cx: &mut App,
629 ) {
630 editor.update(cx, |editor, cx| {
631 let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index);
632 editor.change_selections(
633 SelectionEffects::scroll(Autoscroll::center()),
634 window,
635 cx,
636 |selections| selections.select_ranges(vec![selection]),
637 );
638 if move_focus {
639 window.focus(&editor.focus_handle(cx), cx);
640 }
641 });
642 }
643
644 /// The absolute path of the file that is currently being previewed.
645 fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option<PathBuf> {
646 let file = editor.file_at(MultiBufferOffset(0), cx)?;
647 let absolute_path = editor
648 .project()
649 .and_then(|project| {
650 project.read(cx).absolute_path(
651 &ProjectPath {
652 worktree_id: file.worktree_id(cx),
653 path: file.path().clone(),
654 },
655 cx,
656 )
657 })
658 .or_else(|| file.as_local().map(|file| file.abs_path(cx)))?;
659 absolute_path.parent().map(Path::to_path_buf)
660 }
661
662 fn line_scroll_amount(&self, cx: &App) -> Pixels {
663 let settings = ThemeSettings::get_global(cx);
664 settings.markdown_preview_font_size(cx) * settings.buffer_line_height.value()
665 }
666
667 fn increase_font_size(
668 &mut self,
669 action: &IncreaseBufferFontSize,
670 _: &mut Window,
671 cx: &mut Context<Self>,
672 ) {
673 self.adjust_font_size(action.persist, px(1.0), cx);
674 }
675
676 fn decrease_font_size(
677 &mut self,
678 action: &DecreaseBufferFontSize,
679 _: &mut Window,
680 cx: &mut Context<Self>,
681 ) {
682 self.adjust_font_size(action.persist, px(-1.0), cx);
683 }
684
685 fn adjust_font_size(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
686 if persist {
687 let Ok(fs) = self
688 .workspace
689 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
690 else {
691 return;
692 };
693 update_settings_file(fs, cx, move |settings, cx| {
694 let size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx) + delta;
695 settings.theme.markdown_preview_font_size =
696 Some(f32::from(theme_settings::clamp_font_size(size)).into());
697 });
698 } else {
699 theme_settings::adjust_markdown_preview_font_size(cx, |size| size + delta);
700 }
701 }
702
703 fn reset_font_size(
704 &mut self,
705 action: &ResetBufferFontSize,
706 _: &mut Window,
707 cx: &mut Context<Self>,
708 ) {
709 if action.persist {
710 let Ok(fs) = self
711 .workspace
712 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
713 else {
714 return;
715 };
716 update_settings_file(fs, cx, move |settings, _| {
717 settings.theme.markdown_preview_font_size = None;
718 });
719 } else {
720 theme_settings::reset_markdown_preview_font_size(cx);
721 }
722 }
723
724 fn scroll_by_amount(&self, distance: Pixels) {
725 let offset = self.scroll_handle.offset();
726 self.scroll_handle
727 .set_offset(point(offset.x, offset.y - distance));
728 }
729
730 fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
731 let viewport_height = self.scroll_handle.bounds().size.height;
732 if viewport_height.is_zero() {
733 return;
734 }
735
736 self.scroll_by_amount(-viewport_height);
737 cx.notify();
738 }
739
740 fn scroll_page_down(
741 &mut self,
742 _: &ScrollPageDown,
743 _window: &mut Window,
744 cx: &mut Context<Self>,
745 ) {
746 let viewport_height = self.scroll_handle.bounds().size.height;
747 if viewport_height.is_zero() {
748 return;
749 }
750
751 self.scroll_by_amount(viewport_height);
752 cx.notify();
753 }
754
755 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
756 if let Some(bounds) = self
757 .scroll_handle
758 .bounds_for_item(self.scroll_handle.top_item())
759 {
760 let item_height = bounds.size.height;
761 // Scroll no more than the rough equivalent of a large headline
762 let max_height = window.rem_size() * 2;
763 let scroll_height = min(item_height, max_height);
764 self.scroll_by_amount(-scroll_height);
765 } else {
766 let scroll_height = self.line_scroll_amount(cx);
767 if !scroll_height.is_zero() {
768 self.scroll_by_amount(-scroll_height);
769 }
770 }
771 cx.notify();
772 }
773
774 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
775 if let Some(bounds) = self
776 .scroll_handle
777 .bounds_for_item(self.scroll_handle.top_item())
778 {
779 let item_height = bounds.size.height;
780 // Scroll no more than the rough equivalent of a large headline
781 let max_height = window.rem_size() * 2;
782 let scroll_height = min(item_height, max_height);
783 self.scroll_by_amount(scroll_height);
784 } else {
785 let scroll_height = self.line_scroll_amount(cx);
786 if !scroll_height.is_zero() {
787 self.scroll_by_amount(scroll_height);
788 }
789 }
790 cx.notify();
791 }
792
793 fn scroll_up_by_item(
794 &mut self,
795 _: &ScrollUpByItem,
796 _window: &mut Window,
797 cx: &mut Context<Self>,
798 ) {
799 if let Some(bounds) = self
800 .scroll_handle
801 .bounds_for_item(self.scroll_handle.top_item())
802 {
803 self.scroll_by_amount(-bounds.size.height);
804 }
805 cx.notify();
806 }
807
808 fn scroll_down_by_item(
809 &mut self,
810 _: &ScrollDownByItem,
811 _window: &mut Window,
812 cx: &mut Context<Self>,
813 ) {
814 if let Some(bounds) = self
815 .scroll_handle
816 .bounds_for_item(self.scroll_handle.top_item())
817 {
818 self.scroll_by_amount(bounds.size.height);
819 }
820 cx.notify();
821 }
822
823 fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
824 self.scroll_handle.scroll_to_item(0);
825 cx.notify();
826 }
827
828 fn scroll_to_bottom(
829 &mut self,
830 _: &ScrollToBottom,
831 _window: &mut Window,
832 cx: &mut Context<Self>,
833 ) {
834 self.scroll_handle.scroll_to_bottom();
835 cx.notify();
836 }
837
838 fn close_and_return_to_editor(
839 &mut self,
840 _: &CloseAndReturnToEditor,
841 window: &mut Window,
842 cx: &mut Context<Self>,
843 ) {
844 let Some(editor) = self
845 .active_editor
846 .as_ref()
847 .map(|state| state.editor.clone())
848 else {
849 return;
850 };
851 let Some(workspace) = self.workspace.upgrade() else {
852 return;
853 };
854 let preview_id = cx.entity_id();
855
856 window.defer(cx, move |window, cx| {
857 workspace.update(cx, |workspace, cx| {
858 if !workspace.activate_item(&editor, true, true, window, cx) {
859 workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
860 }
861
862 if let Some(pane) = workspace.pane_for_item_id(preview_id) {
863 pane.update(cx, |pane, cx| {
864 pane.close_item_by_id(preview_id, SaveIntent::Skip, window, cx)
865 })
866 .detach_and_log_err(cx);
867 }
868 });
869 });
870 }
871
872 /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the
873 /// user hasn't set one or it can't be resolved.
874 fn resolve_preview_theme(&self, cx: &App) -> Option<Arc<Theme>> {
875 let theme_settings = ThemeSettings::get_global(cx);
876 let theme_selection = theme_settings.markdown_preview_theme.as_ref()?;
877 let theme_name = theme_selection.name(SystemAppearance::global(cx).0);
878 ThemeRegistry::global(cx).get(&theme_name.0).ok()
879 }
880
881 fn render_markdown_element(
882 &self,
883 preview_theme: &Option<Arc<Theme>>,
884 window: &mut Window,
885 cx: &mut Context<Self>,
886 ) -> MarkdownElement {
887 let active_editor = self
888 .active_editor
889 .as_ref()
890 .map(|state| state.editor.clone());
891
892 let mut workspace_directory = None;
893 if let Some(workspace_entity) = self.workspace.upgrade() {
894 let project = workspace_entity.read(cx).project();
895 if let Some(tree) = project.read(cx).worktrees(cx).next() {
896 workspace_directory = Some(tree.read(cx).abs_path().to_path_buf());
897 }
898 }
899
900 let markdown_style = if let Some(theme) = preview_theme {
901 MarkdownStyle::themed_with_overrides(
902 MarkdownFont::Preview,
903 theme.colors(),
904 theme.syntax(),
905 window,
906 cx,
907 )
908 } else {
909 MarkdownStyle::themed(MarkdownFont::Preview, window, cx)
910 };
911
912 let mut markdown_element = MarkdownElement::new(self.markdown.clone(), markdown_style)
913 .code_block_renderer(CodeBlockRenderer::Default {
914 copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
915 wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
916 border: false,
917 })
918 .scroll_handle(self.scroll_handle.clone())
919 .show_root_block_markers()
920 .image_resolver({
921 let base_directory = self.base_directory.clone();
922 move |dest_url| {
923 resolve_preview_image(
924 dest_url,
925 base_directory.as_deref(),
926 workspace_directory.as_deref(),
927 )
928 }
929 })
930 .on_url_click({
931 let view_handle = cx.entity().downgrade();
932 let workspace = self.workspace.clone();
933 let base_directory = self.base_directory.clone();
934 move |url, window, cx| {
935 handle_url_click(
936 url,
937 &view_handle,
938 base_directory.clone(),
939 &workspace,
940 window,
941 cx,
942 );
943 }
944 })
945 .on_url_hover({
946 let view_handle = cx.entity().downgrade();
947 move |hovered_url, _window, cx| {
948 view_handle
949 .update(cx, |view, cx| {
950 if view.hovered_url != hovered_url {
951 view.hovered_url = hovered_url;
952 cx.notify();
953 }
954 })
955 .log_err();
956 }
957 });
958
959 if let Some(active_editor) = active_editor {
960 let editor_for_checkbox = active_editor.clone();
961 let view_handle = cx.entity().downgrade();
962 markdown_element = markdown_element
963 .on_source_click(move |source_index, click_count, window, cx| {
964 if click_count == 1 {
965 Self::change_selection_to_source_index(
966 &active_editor,
967 source_index,
968 false,
969 window,
970 cx,
971 );
972 }
973 false
974 })
975 .on_checkbox_toggle(move |source_range, new_checked, window, cx| {
976 Self::apply_checkbox_toggle_to_editor(
977 &editor_for_checkbox,
978 source_range,
979 new_checked,
980 cx,
981 );
982 Self::refresh_preview(view_handle.clone(), window, cx);
983 });
984 }
985
986 markdown_element
987 }
988
989 fn apply_checkbox_toggle_to_editor(
990 editor: &Entity<Editor>,
991 source_range: std::ops::Range<usize>,
992 new_checked: bool,
993 cx: &mut App,
994 ) {
995 let task_marker = if new_checked { "[x]" } else { "[ ]" };
996 let expected_existing_marker = if new_checked { "[ ]" } else { "[x]" };
997
998 editor.update(cx, |editor, cx| {
999 let existing_marker: String = editor
1000 .buffer()
1001 .read(cx)
1002 .snapshot(cx)
1003 .text_for_range(
1004 MultiBufferOffset(source_range.start)..MultiBufferOffset(source_range.end),
1005 )
1006 .collect();
1007
1008 debug_assert_eq!(existing_marker, expected_existing_marker);
1009
1010 editor.edit(
1011 [(
1012 MultiBufferOffset(source_range.start)..MultiBufferOffset(source_range.end),
1013 task_marker,
1014 )],
1015 cx,
1016 );
1017 });
1018 }
1019
1020 fn refresh_preview(view_handle: WeakEntity<Self>, window: &mut Window, cx: &mut App) {
1021 if let Some(view) = view_handle.upgrade() {
1022 let preview_is_focused = view.read(cx).focus_handle.contains_focused(window, cx);
1023 if !preview_is_focused {
1024 return;
1025 }
1026
1027 cx.update_entity(&view, |this, cx| {
1028 this.update_markdown_from_active_editor(false, false, window, cx);
1029 });
1030 }
1031 }
1032}
1033
1034fn handle_url_click(
1035 url: SharedString,
1036 view: &WeakEntity<MarkdownPreviewView>,
1037 base_directory: Option<PathBuf>,
1038 workspace: &WeakEntity<Workspace>,
1039 window: &mut Window,
1040 cx: &mut App,
1041) {
1042 let (path_part, fragment) = split_local_url_fragment(url.as_ref());
1043
1044 if path_part.is_empty() {
1045 if let Some(fragment) = fragment {
1046 let view = view.clone();
1047 let slug = SharedString::from(fragment.to_string());
1048 window.defer(cx, move |window, cx| {
1049 if let Some(view) = view.upgrade() {
1050 let markdown = view.read(cx).markdown.clone();
1051 let active_editor = view
1052 .read(cx)
1053 .active_editor
1054 .as_ref()
1055 .map(|state| state.editor.clone());
1056
1057 let source_index =
1058 markdown.update(cx, |markdown, cx| markdown.scroll_to_heading(&slug, cx));
1059
1060 if let Some(source_index) = source_index {
1061 if let Some(editor) = active_editor {
1062 MarkdownPreviewView::change_selection_to_source_index(
1063 &editor,
1064 source_index,
1065 true,
1066 window,
1067 cx,
1068 );
1069 }
1070 }
1071 }
1072 });
1073 }
1074 } else {
1075 open_preview_url(
1076 SharedString::from(path_part.to_string()),
1077 fragment.map(|fragment| SharedString::from(fragment.to_string())),
1078 base_directory,
1079 workspace,
1080 window,
1081 cx,
1082 );
1083 }
1084}
1085
1086fn open_preview_url(
1087 url: SharedString,
1088 fragment: Option<SharedString>,
1089 base_directory: Option<PathBuf>,
1090 workspace: &WeakEntity<Workspace>,
1091 window: &mut Window,
1092 cx: &mut App,
1093) {
1094 let decoded_path = urlencoding::decode(&url).unwrap_or_else(|_| Cow::Borrowed(&url));
1095
1096 if let Some(row) = fragment
1097 .as_deref()
1098 .and_then(|fragment| fragment.strip_prefix('L'))
1099 .and_then(source_position_from_fragment)
1100 .map(|(row, _)| row)
1101 && open_preview_path_at_line(
1102 decoded_path.to_string(),
1103 base_directory.clone(),
1104 workspace,
1105 row,
1106 window,
1107 cx,
1108 )
1109 {
1110 return;
1111 }
1112
1113 if let Some(workspace) = workspace.upgrade() {
1114 workspace.update(cx, |workspace, cx| {
1115 workspace.open_url_or_file(&decoded_path, base_directory.as_deref(), window, cx);
1116 });
1117 } else {
1118 cx.open_url(url.as_ref());
1119 }
1120}
1121
1122enum PreviewPathTarget {
1123 Project(ProjectPath),
1124 Absolute(PathBuf),
1125}
1126
1127fn open_preview_path_at_line(
1128 path: String,
1129 base_directory: Option<PathBuf>,
1130 workspace: &WeakEntity<Workspace>,
1131 row: u32,
1132 window: &mut Window,
1133 cx: &mut App,
1134) -> bool {
1135 let Some(workspace) = workspace.upgrade() else {
1136 return false;
1137 };
1138 let project = workspace.read(cx).project().clone();
1139 let path_style = project.read(cx).path_style(cx);
1140 let target = if path_style.is_absolute(&path) {
1141 Some(PreviewPathTarget::Absolute(PathBuf::from(path)))
1142 } else if project.read(cx).is_local()
1143 && let Some(resolved) = base_directory
1144 .as_deref()
1145 .map(|base_directory| base_directory.join(&path))
1146 && resolved.exists()
1147 {
1148 Some(PreviewPathTarget::Absolute(resolved))
1149 } else {
1150 project
1151 .update(cx, |project, cx| project.find_project_path(&path, cx))
1152 .map(PreviewPathTarget::Project)
1153 };
1154 let Some(target) = target else {
1155 return false;
1156 };
1157
1158 let task = workspace.update(cx, |workspace, cx| match target {
1159 PreviewPathTarget::Project(project_path) => {
1160 workspace.open_path(project_path, None, true, window, cx)
1161 }
1162 PreviewPathTarget::Absolute(abs_path) => workspace.open_abs_path(
1163 abs_path,
1164 workspace::OpenOptions {
1165 focus: Some(true),
1166 ..Default::default()
1167 },
1168 window,
1169 cx,
1170 ),
1171 });
1172 window
1173 .spawn(cx, async move |cx| {
1174 let item = task.await?;
1175 let Some(editor) = item.downcast::<Editor>() else {
1176 return anyhow::Ok(());
1177 };
1178 editor.update_in(cx, |editor, window, cx| {
1179 let point = Point::new(row, 0);
1180 editor.change_selections(
1181 SelectionEffects::scroll(Autoscroll::center()),
1182 window,
1183 cx,
1184 |selections| selections.select_ranges([point..point]),
1185 );
1186 })?;
1187 anyhow::Ok(())
1188 })
1189 .detach_and_log_err(cx);
1190 true
1191}
1192
1193fn resolve_preview_image(
1194 dest_url: &str,
1195 base_directory: Option<&Path>,
1196 workspace_directory: Option<&Path>,
1197) -> Option<ImageSource> {
1198 if dest_url.starts_with("data:") {
1199 return None;
1200 }
1201
1202 if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
1203 return Some(ImageSource::Resource(Resource::Uri(SharedUri::from(
1204 dest_url.to_string(),
1205 ))));
1206 }
1207
1208 let decoded = urlencoding::decode(dest_url)
1209 .map(|decoded| decoded.into_owned())
1210 .unwrap_or_else(|_| dest_url.to_string());
1211
1212 if let Some(stripped) = ['/', '\\']
1213 .iter()
1214 .find_map(|prefix| decoded.strip_prefix(*prefix))
1215 {
1216 if let Some(root) = workspace_directory {
1217 let absolute_path = root.join(stripped);
1218 if absolute_path.exists() {
1219 return Some(ImageSource::Resource(Resource::Path(Arc::from(
1220 absolute_path.as_path(),
1221 ))));
1222 } else {
1223 return None;
1224 }
1225 }
1226 }
1227
1228 let path = if Path::new(&decoded).is_absolute() {
1229 PathBuf::from(decoded)
1230 } else {
1231 base_directory?.join(decoded)
1232 };
1233
1234 path.exists()
1235 .then(|| ImageSource::Resource(Resource::Path(Arc::from(path.as_path()))))
1236}
1237
1238impl Focusable for MarkdownPreviewView {
1239 fn focus_handle(&self, _: &App) -> FocusHandle {
1240 self.focus_handle.clone()
1241 }
1242}
1243
1244impl EventEmitter<MarkdownPreviewEvent> for MarkdownPreviewView {}
1245impl EventEmitter<SearchEvent> for MarkdownPreviewView {}
1246
1247impl Item for MarkdownPreviewView {
1248 type Event = MarkdownPreviewEvent;
1249
1250 fn act_as_type<'a>(
1251 &'a self,
1252 type_id: TypeId,
1253 self_handle: &'a Entity<Self>,
1254 _: &'a App,
1255 ) -> Option<gpui::AnyEntity> {
1256 if type_id == TypeId::of::<Self>() {
1257 Some(self_handle.clone().into())
1258 } else if type_id == TypeId::of::<Editor>() {
1259 self.active_editor
1260 .as_ref()
1261 .map(|state| state.editor.clone().into())
1262 } else {
1263 None
1264 }
1265 }
1266
1267 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
1268 Some(Icon::new(IconName::FileDoc))
1269 }
1270
1271 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
1272 self.active_editor
1273 .as_ref()
1274 .map(|editor_state| {
1275 let buffer = editor_state.editor.read(cx).buffer().read(cx);
1276 let title = buffer.title(cx);
1277 format!("Preview {}", title).into()
1278 })
1279 .unwrap_or_else(|| SharedString::from("Markdown Preview"))
1280 }
1281
1282 fn telemetry_event_text(&self) -> Option<&'static str> {
1283 Some("Markdown Preview Opened")
1284 }
1285
1286 fn added_to_workspace(
1287 &mut self,
1288 workspace: &mut Workspace,
1289 window: &mut Window,
1290 cx: &mut Context<Self>,
1291 ) {
1292 if self.mode != MarkdownPreviewMode::Default {
1293 return;
1294 }
1295 if let Some(editor) = self.find_canonical_editor(workspace, cx)
1296 && self
1297 .active_editor
1298 .as_ref()
1299 .is_none_or(|s| s.editor != editor)
1300 {
1301 self.set_editor(editor, window, cx);
1302 }
1303 }
1304
1305 fn can_save(&self, cx: &App) -> bool {
1306 self.active_editor
1307 .as_ref()
1308 .is_some_and(|editor_state| editor_state.editor.read(cx).can_save(cx))
1309 }
1310
1311 fn can_save_as(&self, cx: &App) -> bool {
1312 self.active_editor
1313 .as_ref()
1314 .is_some_and(|editor_state| editor_state.editor.read(cx).can_save_as(cx))
1315 }
1316
1317 fn save(
1318 &mut self,
1319 options: SaveOptions,
1320 project: Entity<Project>,
1321 window: &mut Window,
1322 cx: &mut Context<Self>,
1323 ) -> Task<Result<()>> {
1324 self.active_editor
1325 .as_ref()
1326 .map(|editor_state| {
1327 editor_state
1328 .editor
1329 .update(cx, |editor, cx| editor.save(options, project, window, cx))
1330 })
1331 .unwrap_or_else(|| Task::ready(Ok(())))
1332 }
1333
1334 fn save_as(
1335 &mut self,
1336 project: Entity<Project>,
1337 path: project::ProjectPath,
1338 window: &mut Window,
1339 cx: &mut Context<Self>,
1340 ) -> Task<Result<()>> {
1341 self.active_editor
1342 .as_ref()
1343 .map(|editor_state| {
1344 editor_state
1345 .editor
1346 .update(cx, |editor, cx| editor.save_as(project, path, window, cx))
1347 })
1348 .unwrap_or_else(|| Task::ready(Ok(())))
1349 }
1350
1351 fn reload(
1352 &mut self,
1353 _project: Entity<Project>,
1354 _window: &mut Window,
1355 _cx: &mut Context<Self>,
1356 ) -> Task<Result<()>> {
1357 // The preview is not the owner of the source editor's buffer, so force-closing it should not discard editor changes.
1358 Task::ready(Ok(()))
1359 }
1360
1361 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
1362 match event {
1363 MarkdownPreviewEvent::SourceEditorChanged
1364 | MarkdownPreviewEvent::SourceFileHandleChanged => {
1365 f(workspace::item::ItemEvent::UpdateTab);
1366 f(workspace::item::ItemEvent::UpdateBreadcrumbs);
1367 }
1368 }
1369 }
1370
1371 fn buffer_kind(&self, _cx: &App) -> ItemBufferKind {
1372 ItemBufferKind::Singleton
1373 }
1374
1375 fn as_searchable(
1376 &self,
1377 handle: &Entity<Self>,
1378 _: &App,
1379 ) -> Option<Box<dyn SearchableItemHandle>> {
1380 Some(Box::new(handle.clone()))
1381 }
1382}
1383
1384impl Render for MarkdownPreviewView {
1385 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1386 let preview_theme = self.resolve_preview_theme(cx);
1387 let bg_color = preview_theme
1388 .as_ref()
1389 .map(|theme| theme.colors().editor_background)
1390 .unwrap_or_else(|| cx.theme().colors().editor_background);
1391 let preview_font_size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx);
1392 let hovered_url = self.hovered_url.clone();
1393 div()
1394 .image_cache(self.image_cache.clone())
1395 .id("MarkdownPreview")
1396 .key_context("MarkdownPreview")
1397 .track_focus(&self.focus_handle(cx))
1398 .on_hover(cx.listener(|view, hovered, _window, cx| {
1399 if !hovered && view.hovered_url.take().is_some() {
1400 cx.notify();
1401 }
1402 }))
1403 .on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
1404 .on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
1405 .on_action(cx.listener(MarkdownPreviewView::scroll_up))
1406 .on_action(cx.listener(MarkdownPreviewView::scroll_down))
1407 .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item))
1408 .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
1409 .on_action(cx.listener(MarkdownPreviewView::scroll_to_top))
1410 .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
1411 .on_action(cx.listener(MarkdownPreviewView::close_and_return_to_editor))
1412 .on_action(cx.listener(MarkdownPreviewView::increase_font_size))
1413 .on_action(cx.listener(MarkdownPreviewView::decrease_font_size))
1414 .on_action(cx.listener(MarkdownPreviewView::reset_font_size))
1415 .w_full()
1416 .flex_1()
1417 .min_h_0()
1418 .relative()
1419 .bg(bg_color)
1420 .child(
1421 WithRemSize::new(preview_font_size).size_full().child(
1422 div()
1423 .id("markdown-preview-scroll-container")
1424 .size_full()
1425 .overflow_y_scroll()
1426 .track_scroll(&self.scroll_handle)
1427 .p_4()
1428 .child({
1429 let markdown_element =
1430 self.render_markdown_element(&preview_theme, window, cx);
1431 let markdown = self.markdown.clone();
1432 let max_width = MarkdownPreviewSettings::get_global(cx).max_width;
1433 let content = right_click_menu("markdown-preview-context-menu")
1434 .trigger(move |_, _, _| markdown_element)
1435 .maybe_menu(move |window, cx| {
1436 let focus = window.focused(cx);
1437 let markdown = markdown.read(cx);
1438 let context_menu_link = markdown.context_menu_link().cloned();
1439 let selected_text =
1440 markdown.context_menu_selected_text().cloned();
1441 let selected_markdown =
1442 markdown.context_menu_selected_markdown().cloned();
1443 if context_menu_link.is_none()
1444 && selected_text.is_none()
1445 && selected_markdown.is_none()
1446 {
1447 return None;
1448 }
1449 Some(ContextMenu::build(window, cx, move |menu, _, _cx| {
1450 menu.when_some(focus, |menu, focus| menu.context(focus))
1451 .when_some(selected_text, |menu, text| {
1452 menu.entry(
1453 "Copy",
1454 Some(Box::new(markdown::Copy)),
1455 move |_, cx| {
1456 cx.write_to_clipboard(
1457 ClipboardItem::new_string(
1458 text.to_string(),
1459 ),
1460 );
1461 },
1462 )
1463 })
1464 .when_some(selected_markdown, |menu, text| {
1465 menu.entry(
1466 "Copy as Markdown",
1467 Some(Box::new(markdown::CopyAsMarkdown)),
1468 move |_, cx| {
1469 cx.write_to_clipboard(
1470 ClipboardItem::new_string(
1471 text.to_string(),
1472 ),
1473 );
1474 },
1475 )
1476 })
1477 .when_some(context_menu_link, |menu, url| {
1478 menu.entry("Copy Link", None, move |_, cx| {
1479 cx.write_to_clipboard(
1480 ClipboardItem::new_string(url.to_string()),
1481 );
1482 })
1483 })
1484 }))
1485 });
1486 div()
1487 .w_full()
1488 .when_some(max_width, |this, max_width| {
1489 this.max_w(max_width).mx_auto()
1490 })
1491 .child(content)
1492 }),
1493 ),
1494 )
1495 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
1496 .when_some(hovered_url, |this, hovered_url| {
1497 this.child(
1498 div()
1499 .absolute()
1500 .bottom_2()
1501 .left_0()
1502 .max_w_full()
1503 .overflow_hidden()
1504 .child(LinkPreview::new(hovered_url.as_ref(), cx)),
1505 )
1506 })
1507 }
1508}
1509
1510impl SearchableItem for MarkdownPreviewView {
1511 type Match = Range<usize>;
1512
1513 fn supported_options(&self) -> SearchOptions {
1514 SearchOptions {
1515 case: true,
1516 word: true,
1517 regex: true,
1518 replacement: false,
1519 selection: false,
1520 select_all: false,
1521 find_in_results: false,
1522 }
1523 }
1524
1525 fn get_matches(&self, _window: &mut Window, cx: &mut App) -> (Vec<Self::Match>, SearchToken) {
1526 (
1527 self.markdown.read(cx).search_highlights().to_vec(),
1528 SearchToken::default(),
1529 )
1530 }
1531
1532 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1533 let had_highlights = !self.markdown.read(cx).search_highlights().is_empty();
1534 self.markdown.update(cx, |markdown, cx| {
1535 markdown.clear_search_highlights(cx);
1536 });
1537 if had_highlights {
1538 cx.emit(SearchEvent::MatchesInvalidated);
1539 }
1540 }
1541
1542 fn update_matches(
1543 &mut self,
1544 matches: &[Self::Match],
1545 active_match_index: Option<usize>,
1546 _token: SearchToken,
1547 _window: &mut Window,
1548 cx: &mut Context<Self>,
1549 ) {
1550 debug_assert!(
1551 matches
1552 .windows(2)
1553 .all(|ranges| (ranges[0].start, ranges[0].end) <= (ranges[1].start, ranges[1].end))
1554 );
1555 let old_highlights = self.markdown.read(cx).search_highlights();
1556 let changed = old_highlights != matches;
1557 self.markdown.update(cx, |markdown, cx| {
1558 markdown.set_search_highlights(matches.to_vec(), active_match_index, cx);
1559 });
1560 if changed {
1561 cx.emit(SearchEvent::MatchesInvalidated);
1562 }
1563 }
1564
1565 fn query_suggestion(
1566 &mut self,
1567 _seed_query_override: Option<SeedQuerySetting>,
1568 _window: &mut Window,
1569 cx: &mut Context<Self>,
1570 ) -> String {
1571 self.markdown
1572 .read(cx)
1573 .selected_source()
1574 .unwrap_or_default()
1575 .to_string()
1576 }
1577
1578 fn activate_match(
1579 &mut self,
1580 index: usize,
1581 matches: &[Self::Match],
1582 _token: SearchToken,
1583 _window: &mut Window,
1584 cx: &mut Context<Self>,
1585 ) {
1586 if let Some(match_range) = matches.get(index) {
1587 let start = match_range.start;
1588 self.markdown.update(cx, |markdown, cx| {
1589 markdown.set_active_search_highlight(Some(index), cx);
1590 markdown.request_autoscroll_to_source_index(start, cx);
1591 });
1592 }
1593 }
1594
1595 fn select_matches(
1596 &mut self,
1597 _matches: &[Self::Match],
1598 _token: SearchToken,
1599 _window: &mut Window,
1600 _cx: &mut Context<Self>,
1601 ) {
1602 }
1603
1604 fn replace(
1605 &mut self,
1606 _: &Self::Match,
1607 _: &SearchQuery,
1608 _token: SearchToken,
1609 _window: &mut Window,
1610 _: &mut Context<Self>,
1611 ) {
1612 }
1613
1614 fn find_matches(
1615 &mut self,
1616 query: Arc<SearchQuery>,
1617 _window: &mut Window,
1618 cx: &mut Context<Self>,
1619 ) -> Task<Vec<Self::Match>> {
1620 let source = self.markdown.read(cx).source().to_string();
1621 cx.background_spawn(async move { query.search_str(&source) })
1622 }
1623
1624 fn active_match_index(
1625 &mut self,
1626 direction: Direction,
1627 matches: &[Self::Match],
1628 _token: SearchToken,
1629 _window: &mut Window,
1630 cx: &mut Context<Self>,
1631 ) -> Option<usize> {
1632 if matches.is_empty() {
1633 return None;
1634 }
1635
1636 let markdown = self.markdown.read(cx);
1637 let current_source_index = markdown
1638 .active_search_highlight()
1639 .and_then(|i| markdown.search_highlights().get(i))
1640 .map(|m| m.start)
1641 .or(self.active_source_index)
1642 .unwrap_or(0);
1643
1644 match direction {
1645 Direction::Next => matches
1646 .iter()
1647 .position(|m| m.start >= current_source_index)
1648 .or(Some(0)),
1649 Direction::Prev => matches
1650 .iter()
1651 .rposition(|m| m.start <= current_source_index)
1652 .or(Some(matches.len().saturating_sub(1))),
1653 }
1654 }
1655}
1656
1657impl SerializableItem for MarkdownPreviewView {
1658 fn serialized_item_kind() -> &'static str {
1659 "MarkdownPreviewView"
1660 }
1661
1662 fn deserialize(
1663 project: Entity<Project>,
1664 workspace: WeakEntity<Workspace>,
1665 workspace_id: WorkspaceId,
1666 item_id: ItemId,
1667 window: &mut Window,
1668 cx: &mut App,
1669 ) -> Task<Result<Entity<Self>>> {
1670 let db = persistence::MarkdownPreviewDb::global(cx);
1671 window.spawn(cx, async move |cx| {
1672 let (abs_path, mode_value) = db
1673 .get_preview(item_id, workspace_id)?
1674 .context("No markdown preview entry found")?;
1675 let mode = MarkdownPreviewMode::from_db(mode_value);
1676
1677 let (worktree, relative_path) = project
1678 .update(cx, |project, cx| {
1679 project.find_or_create_worktree(abs_path.clone(), false, cx)
1680 })
1681 .await
1682 .context("Path not found")?;
1683 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1684
1685 let project_path = ProjectPath {
1686 worktree_id,
1687 path: relative_path,
1688 };
1689
1690 let buffer = project
1691 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1692 .await?;
1693
1694 cx.update(|window, cx| {
1695 let language_registry = project.read(cx).languages().clone();
1696 let editor =
1697 cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx));
1698 MarkdownPreviewView::new(mode, editor, workspace, language_registry, window, cx)
1699 })
1700 })
1701 }
1702
1703 fn cleanup(
1704 workspace_id: WorkspaceId,
1705 alive_items: Vec<ItemId>,
1706 _window: &mut Window,
1707 cx: &mut App,
1708 ) -> Task<Result<()>> {
1709 let db = persistence::MarkdownPreviewDb::global(cx);
1710 delete_unloaded_items(alive_items, workspace_id, "markdown_previews", &db, cx)
1711 }
1712
1713 fn serialize(
1714 &mut self,
1715 workspace: &mut Workspace,
1716 item_id: ItemId,
1717 _closing: bool,
1718 _window: &mut Window,
1719 cx: &mut Context<Self>,
1720 ) -> Option<Task<Result<()>>> {
1721 let workspace_id = workspace.database_id()?;
1722 let editor = self.active_editor.as_ref()?.editor.clone();
1723 let buffer = editor.read(cx).buffer().read(cx).as_singleton()?;
1724 let file = buffer.read(cx).file()?;
1725 let worktree_id = file.worktree_id(cx);
1726 let abs_path = workspace
1727 .project()
1728 .read(cx)
1729 .worktree_for_id(worktree_id, cx)?
1730 .read(cx)
1731 .absolutize(file.path());
1732 let mode = self.mode.to_db();
1733 let db = persistence::MarkdownPreviewDb::global(cx);
1734 Some(cx.background_spawn(async move {
1735 db.save_preview(item_id, workspace_id, abs_path, mode).await
1736 }))
1737 }
1738
1739 fn should_serialize(&self, event: &Self::Event) -> bool {
1740 matches!(
1741 event,
1742 MarkdownPreviewEvent::SourceEditorChanged
1743 | MarkdownPreviewEvent::SourceFileHandleChanged
1744 )
1745 }
1746}
1747
1748mod persistence {
1749 use std::path::PathBuf;
1750
1751 use db::{
1752 query,
1753 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1754 sqlez_macros::sql,
1755 };
1756 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
1757
1758 pub struct MarkdownPreviewDb(ThreadSafeConnection);
1759
1760 impl Domain for MarkdownPreviewDb {
1761 const NAME: &str = stringify!(MarkdownPreviewDb);
1762
1763 const MIGRATIONS: &[&str] = &[sql!(
1764 CREATE TABLE markdown_previews (
1765 workspace_id INTEGER,
1766 item_id INTEGER,
1767 abs_path BLOB,
1768 mode INTEGER NOT NULL DEFAULT 0,
1769
1770 PRIMARY KEY(workspace_id, item_id),
1771 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1772 ON DELETE CASCADE
1773 ) STRICT;
1774 )];
1775 }
1776
1777 db::static_connection!(MarkdownPreviewDb, [WorkspaceDb]);
1778
1779 impl MarkdownPreviewDb {
1780 query! {
1781 pub async fn save_preview(
1782 item_id: ItemId,
1783 workspace_id: WorkspaceId,
1784 abs_path: PathBuf,
1785 mode: i64
1786 ) -> Result<()> {
1787 INSERT OR REPLACE INTO markdown_previews(item_id, workspace_id, abs_path, mode)
1788 VALUES (?, ?, ?, ?)
1789 }
1790 }
1791
1792 query! {
1793 pub fn get_preview(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(PathBuf, i64)>> {
1794 SELECT abs_path, mode
1795 FROM markdown_previews
1796 WHERE item_id = ? AND workspace_id = ?
1797 }
1798 }
1799 }
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804 use crate::CloseAndReturnToEditor;
1805 use crate::markdown_preview_view::ImageSource;
1806 use crate::markdown_preview_view::Resource;
1807 use crate::markdown_preview_view::resolve_preview_image;
1808 use buffer_diff::BufferDiff;
1809 use editor::Editor;
1810 use fs::FakeFs;
1811 use gpui::{AppContext as _, Entity, Focusable as _, TestAppContext, WindowHandle};
1812 use language::{Buffer, DiskState, Point};
1813 use project::Project;
1814 use serde_json::json;
1815 use std::path::PathBuf;
1816 use std::sync::Arc;
1817 use std::time::Duration;
1818 use util::path;
1819 use util::rel_path::{RelPath, rel_path};
1820 use util::test::TempTree;
1821 use workspace::item::SerializableItem;
1822 use workspace::{
1823 AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths,
1824 };
1825
1826 use super::{MarkdownPreviewView, open_preview_url};
1827
1828 #[test]
1829 fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() {
1830 let tree = TempTree::new(json!({
1831 "docs": {},
1832 "test_image.png": "mock data"
1833 }));
1834 let workspace_directory = tree.path();
1835 let base_directory = markdown_fixture_directory(&tree);
1836 let image_file = workspace_directory.join("test_image.png");
1837
1838 for workspace_root_relative_path in ["/test_image.png", "\\test_image.png"] {
1839 let resolved = resolve_preview_image(
1840 workspace_root_relative_path,
1841 Some(&base_directory),
1842 Some(workspace_directory),
1843 );
1844 assert_resolved_preview_image_path(resolved, image_file.as_path());
1845 }
1846
1847 let missing = resolve_preview_image(
1848 "/missing_image.png",
1849 Some(&base_directory),
1850 Some(workspace_directory),
1851 );
1852 assert!(missing.is_none());
1853 }
1854
1855 #[gpui::test]
1856 async fn opens_preview_file_link_at_line(cx: &mut TestAppContext) {
1857 init_test(cx);
1858
1859 let fs = FakeFs::new(cx.executor());
1860 fs.insert_tree(
1861 path!("/project"),
1862 json!({"src": {"main.rs": "first\nsecond\nthird\n"}}),
1863 )
1864 .await;
1865 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
1866 let (multi_workspace, cx) =
1867 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1868 let workspace =
1869 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
1870 let workspace_weak = workspace.downgrade();
1871
1872 multi_workspace.update_in(cx, |_, window, cx| {
1873 open_preview_url(
1874 "src/main.rs".into(),
1875 Some("L2".into()),
1876 None,
1877 &workspace_weak,
1878 window,
1879 cx,
1880 );
1881 });
1882 cx.run_until_parked();
1883
1884 let editor = workspace.read_with(cx, |workspace, cx| {
1885 workspace
1886 .active_item(cx)
1887 .and_then(|item| item.downcast::<Editor>())
1888 .expect("file should be open in an editor")
1889 });
1890 editor.update_in(cx, |editor, window, cx| {
1891 let snapshot = editor.snapshot(window, cx);
1892 assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
1893 });
1894 }
1895
1896 #[gpui::test]
1897 async fn toggles_task_checkbox_and_saves_when_preview_is_active(cx: &mut TestAppContext) {
1898 let app_state = init_test(cx);
1899 app_state
1900 .fs
1901 .as_fake()
1902 .insert_tree(
1903 path!("/dir"),
1904 json!({
1905 "todo.md": "- [ ] Finish work\n"
1906 }),
1907 )
1908 .await;
1909
1910 cx.update(|cx| {
1911 open_paths(
1912 &[PathBuf::from(path!("/dir/todo.md"))],
1913 app_state.clone(),
1914 workspace::OpenOptions::default(),
1915 cx,
1916 )
1917 })
1918 .await
1919 .unwrap();
1920
1921 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1922 let preview = multi_workspace
1923 .update(cx, |multi_workspace, window, cx| {
1924 let workspace = multi_workspace.workspace().clone();
1925 let editor: Entity<Editor> = workspace
1926 .read(cx)
1927 .active_item(cx)
1928 .and_then(|item| item.act_as::<Editor>(cx))
1929 .unwrap();
1930
1931 workspace.update(cx, |workspace, cx| {
1932 let preview = MarkdownPreviewView::create_markdown_view(
1933 workspace,
1934 editor.clone(),
1935 window,
1936 cx,
1937 );
1938 workspace.active_pane().update(cx, |pane, cx| {
1939 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
1940 });
1941 preview
1942 })
1943 })
1944 .unwrap();
1945 cx.run_until_parked();
1946
1947 let save_task = multi_workspace
1948 .update(cx, |multi_workspace, window, cx| {
1949 let workspace: Entity<Workspace> = multi_workspace.workspace().clone();
1950 let view_handle = preview.downgrade();
1951 assert!(preview.read(cx).focus_handle.contains_focused(window, cx));
1952 preview.update(cx, |preview, cx| {
1953 let editor = preview.active_editor.as_ref().unwrap().editor.clone();
1954 MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx);
1955 });
1956 MarkdownPreviewView::refresh_preview(view_handle, window, cx);
1957
1958 workspace.update(cx, |workspace: &mut Workspace, cx| {
1959 workspace.save_active_item(SaveIntent::Save, window, cx)
1960 })
1961 })
1962 .unwrap();
1963
1964 save_task.await.unwrap();
1965 cx.run_until_parked();
1966
1967 assert_eq!(
1968 app_state
1969 .fs
1970 .load(path!("/dir/todo.md").as_ref())
1971 .await
1972 .unwrap(),
1973 "- [x] Finish work\n"
1974 );
1975 }
1976
1977 #[gpui::test]
1978 async fn preview_uses_buffer_contents_instead_of_diff_contents(cx: &mut TestAppContext) {
1979 let app_state = init_test(cx);
1980 app_state
1981 .fs
1982 .as_fake()
1983 .insert_tree(
1984 path!("/dir"),
1985 json!({
1986 "note.md": "new\n"
1987 }),
1988 )
1989 .await;
1990
1991 cx.update(|cx| {
1992 open_paths(
1993 &[PathBuf::from(path!("/dir/note.md"))],
1994 app_state.clone(),
1995 workspace::OpenOptions::default(),
1996 cx,
1997 )
1998 })
1999 .await
2000 .unwrap();
2001
2002 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2003 let preview = multi_workspace
2004 .update(cx, |multi_workspace, window, cx| {
2005 let workspace = multi_workspace.workspace().clone();
2006 workspace.update(cx, |workspace, cx| {
2007 let editor: Entity<Editor> = workspace
2008 .active_item(cx)
2009 .and_then(|item| item.act_as::<Editor>(cx))
2010 .unwrap();
2011 let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap();
2012 let diff = cx.new(|cx| {
2013 BufferDiff::new_with_base_text(
2014 "old\n",
2015 &buffer.read(cx).text_snapshot(),
2016 cx,
2017 )
2018 });
2019 let multibuffer = editor.read(cx).buffer().clone();
2020 multibuffer.update(cx, |multibuffer, cx| {
2021 multibuffer.add_diff(diff, cx);
2022 multibuffer.set_all_diff_hunks_expanded(cx);
2023 });
2024
2025 let diff_text = multibuffer.read(cx).snapshot(cx).text();
2026 assert!(diff_text.contains("old"));
2027 assert!(diff_text.contains("new"));
2028
2029 let preview =
2030 MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx);
2031 workspace.active_pane().update(cx, |pane, cx| {
2032 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2033 });
2034 preview
2035 })
2036 })
2037 .unwrap();
2038 cx.run_until_parked();
2039
2040 assert_eq!(
2041 preview.read_with(cx, |preview, cx| preview
2042 .markdown
2043 .read(cx)
2044 .source()
2045 .to_string()),
2046 "new\n"
2047 );
2048 }
2049
2050 #[gpui::test]
2051 async fn force_closing_preview_preserves_source_editor_changes(cx: &mut TestAppContext) {
2052 let app_state = init_test(cx);
2053 app_state
2054 .fs
2055 .as_fake()
2056 .insert_tree(
2057 path!("/dir"),
2058 json!({
2059 "todo.md": "- [ ] Finish work\n"
2060 }),
2061 )
2062 .await;
2063
2064 cx.update(|cx| {
2065 open_paths(
2066 &[PathBuf::from(path!("/dir/todo.md"))],
2067 app_state.clone(),
2068 workspace::OpenOptions::default(),
2069 cx,
2070 )
2071 })
2072 .await
2073 .unwrap();
2074
2075 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2076 let (preview, editor) = multi_workspace
2077 .update(cx, |multi_workspace, window, cx| {
2078 let workspace = multi_workspace.workspace().clone();
2079 let editor: Entity<Editor> = workspace
2080 .read(cx)
2081 .active_item(cx)
2082 .and_then(|item| item.act_as::<Editor>(cx))
2083 .unwrap();
2084
2085 let preview = workspace.update(cx, |workspace, cx| {
2086 let preview = MarkdownPreviewView::create_markdown_view(
2087 workspace,
2088 editor.clone(),
2089 window,
2090 cx,
2091 );
2092 workspace.active_pane().update(cx, |pane, cx| {
2093 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2094 });
2095 preview
2096 });
2097
2098 (preview, editor)
2099 })
2100 .unwrap();
2101 cx.run_until_parked();
2102
2103 multi_workspace
2104 .update(cx, |_, window, cx| {
2105 let view_handle = preview.downgrade();
2106 assert!(preview.read(cx).focus_handle.contains_focused(window, cx));
2107 MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx);
2108 MarkdownPreviewView::refresh_preview(view_handle, window, cx);
2109 })
2110 .unwrap();
2111
2112 assert_eq!(
2113 editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()),
2114 "- [x] Finish work\n"
2115 );
2116
2117 let close_task = multi_workspace
2118 .update(cx, |multi_workspace, window, cx| {
2119 multi_workspace.workspace().update(cx, |workspace, cx| {
2120 workspace.active_pane().update(cx, |pane, cx| {
2121 pane.close_item_by_id(preview.entity_id(), SaveIntent::Skip, window, cx)
2122 })
2123 })
2124 })
2125 .unwrap();
2126
2127 close_task.await.unwrap();
2128 cx.run_until_parked();
2129
2130 assert_eq!(
2131 editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()),
2132 "- [x] Finish work\n"
2133 );
2134 }
2135
2136 #[gpui::test]
2137 async fn close_and_return_to_editor_closes_preview_and_focuses_source_editor(
2138 cx: &mut TestAppContext,
2139 ) {
2140 let (multi_workspace, editor) =
2141 open_markdown_file(cx, "note.md", "# Note\n\nBody text\n").await;
2142 let preview = open_preview_for_active_editor(cx, &multi_workspace);
2143 cx.run_until_parked();
2144
2145 dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview);
2146 cx.run_until_parked();
2147
2148 assert_editor_is_active_and_focused(cx, &multi_workspace, &editor);
2149 assert_no_markdown_preview_items(cx, &multi_workspace);
2150 }
2151
2152 #[gpui::test]
2153 async fn close_and_return_to_editor_reopens_source_editor_when_editor_tab_was_closed(
2154 cx: &mut TestAppContext,
2155 ) {
2156 let (multi_workspace, editor) = open_markdown_file(cx, "note.md", "# Note\n").await;
2157 let preview = open_preview_for_active_editor(cx, &multi_workspace);
2158 cx.run_until_parked();
2159
2160 let close_editor_task = multi_workspace
2161 .update(cx, |multi_workspace, window, cx| {
2162 multi_workspace.workspace().update(cx, |workspace, cx| {
2163 workspace.active_pane().update(cx, |pane, cx| {
2164 pane.close_item_by_id(editor.entity_id(), SaveIntent::Skip, window, cx)
2165 })
2166 })
2167 })
2168 .unwrap();
2169 close_editor_task.await.unwrap();
2170 cx.run_until_parked();
2171
2172 multi_workspace
2173 .update(cx, |multi_workspace, window, cx| {
2174 let workspace = multi_workspace.workspace().read(cx);
2175 assert!(
2176 preview.read(cx).focus_handle.contains_focused(window, cx),
2177 "preview should remain focused after closing the source editor tab"
2178 );
2179 assert!(
2180 workspace
2181 .items_of_type::<Editor>(cx)
2182 .all(|open_editor| open_editor != editor),
2183 "source editor should no longer be open in any pane"
2184 );
2185 assert_eq!(
2186 workspace.items_of_type::<MarkdownPreviewView>(cx).count(),
2187 1
2188 );
2189 })
2190 .unwrap();
2191
2192 dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview);
2193 cx.run_until_parked();
2194
2195 assert_editor_is_active_and_focused(cx, &multi_workspace, &editor);
2196 assert_no_markdown_preview_items(cx, &multi_workspace);
2197 }
2198
2199 #[gpui::test]
2200 async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) {
2201 let app_state = init_test(cx);
2202 app_state
2203 .fs
2204 .as_fake()
2205 .insert_tree(
2206 path!("/dir"),
2207 json!({
2208 "todo.md": "\n",
2209 "subdir": {},
2210 }),
2211 )
2212 .await;
2213
2214 cx.update(|cx| {
2215 open_paths(
2216 &[PathBuf::from(path!("/dir"))],
2217 app_state.clone(),
2218 workspace::OpenOptions::default(),
2219 cx,
2220 )
2221 })
2222 .await
2223 .unwrap();
2224
2225 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2226 let open_task = multi_workspace
2227 .update(cx, |multi_workspace, window, cx| {
2228 let workspace = multi_workspace.workspace().clone();
2229 workspace.update(cx, |workspace, cx| {
2230 let worktree_id = workspace
2231 .project()
2232 .read(cx)
2233 .worktrees(cx)
2234 .next()
2235 .unwrap()
2236 .read(cx)
2237 .id();
2238 workspace.open_path((worktree_id, rel_path("todo.md")), None, true, window, cx)
2239 })
2240 })
2241 .unwrap();
2242 open_task.await.unwrap();
2243 cx.run_until_parked();
2244
2245 let (preview, project, workspace_id) = multi_workspace
2246 .update(cx, |multi_workspace, window, cx| {
2247 let workspace = multi_workspace.workspace().clone();
2248 workspace.update(cx, |workspace, cx| {
2249 workspace.set_random_database_id();
2250 let workspace_id = workspace.database_id().unwrap();
2251 let project = workspace.project().clone();
2252 let editor: Entity<Editor> = workspace
2253 .active_item(cx)
2254 .and_then(|item| item.act_as::<Editor>(cx))
2255 .unwrap();
2256 let preview =
2257 MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx);
2258 workspace.active_pane().update(cx, |pane, cx| {
2259 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2260 });
2261 (preview, project, workspace_id)
2262 })
2263 })
2264 .unwrap();
2265 let workspace_serialization_tasks = multi_workspace
2266 .update(cx, |multi_workspace, window, cx| {
2267 multi_workspace.flush_all_serialization(window, cx)
2268 })
2269 .unwrap();
2270 for task in workspace_serialization_tasks {
2271 task.await;
2272 }
2273
2274 let serialize_task = multi_workspace
2275 .update(cx, |multi_workspace, window, cx| {
2276 let workspace = multi_workspace.workspace().clone();
2277 workspace.update(cx, |workspace, cx| {
2278 preview
2279 .update(cx, |preview, cx| {
2280 preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx)
2281 })
2282 .unwrap()
2283 })
2284 })
2285 .unwrap();
2286 serialize_task.await.unwrap();
2287
2288 assert_eq!(
2289 saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id),
2290 PathBuf::from(path!("/dir/todo.md"))
2291 );
2292
2293 let (entry_id, worktree_id, destination_path) = preview.read_with(cx, |preview, cx| {
2294 let editor = &preview.active_editor.as_ref().unwrap().editor;
2295 let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap();
2296 let buffer = buffer.read(cx);
2297 let file = buffer.file().unwrap();
2298 let worktree_id = file.worktree_id(cx);
2299 let source_path = file.path();
2300 let mut destination_path = source_path.to_rel_path_buf();
2301 destination_path.pop();
2302 destination_path.push(rel_path("subdir/renamed.md"));
2303 let worktree = project.read(cx).worktree_for_id(worktree_id, cx).unwrap();
2304 let entry_id = worktree.read(cx).entry_for_path(source_path).unwrap().id;
2305 (
2306 entry_id,
2307 worktree_id,
2308 destination_path.as_rel_path().into_arc(),
2309 )
2310 });
2311 project
2312 .update(cx, |project, cx| {
2313 project.rename_entry(entry_id, (worktree_id, destination_path).into(), cx)
2314 })
2315 .await
2316 .unwrap();
2317 wait_for_preview_serialization(cx).await;
2318
2319 assert_eq!(
2320 preview.read_with(cx, |preview, _| preview.base_directory.clone()),
2321 Some(PathBuf::from(path!("/dir/subdir")))
2322 );
2323 assert_eq!(
2324 saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id),
2325 PathBuf::from(path!("/dir/subdir/renamed.md"))
2326 );
2327 }
2328
2329 #[gpui::test]
2330 async fn follow_preview_serialized_path_updates_when_followed_editor_changes(
2331 cx: &mut TestAppContext,
2332 ) {
2333 let app_state = init_test(cx);
2334 app_state
2335 .fs
2336 .as_fake()
2337 .insert_tree(
2338 path!("/dir"),
2339 json!({
2340 "a.md": "# A\n",
2341 "b.md": "# B\n",
2342 }),
2343 )
2344 .await;
2345
2346 cx.update(|cx| {
2347 open_paths(
2348 &[PathBuf::from(path!("/dir"))],
2349 app_state.clone(),
2350 workspace::OpenOptions::default(),
2351 cx,
2352 )
2353 })
2354 .await
2355 .unwrap();
2356
2357 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2358 let worktree_id = multi_workspace
2359 .update(cx, |multi_workspace, _, cx| {
2360 multi_workspace
2361 .workspace()
2362 .read(cx)
2363 .project()
2364 .read(cx)
2365 .worktrees(cx)
2366 .next()
2367 .unwrap()
2368 .read(cx)
2369 .id()
2370 })
2371 .unwrap();
2372
2373 let open_task = multi_workspace
2374 .update(cx, |multi_workspace, window, cx| {
2375 multi_workspace.workspace().update(cx, |workspace, cx| {
2376 workspace.open_path((worktree_id, rel_path("a.md")), None, true, window, cx)
2377 })
2378 })
2379 .unwrap();
2380 let opened_item = open_task.await.unwrap();
2381 cx.run_until_parked();
2382 let editor_a = cx.update(|cx| opened_item.act_as::<Editor>(cx).unwrap());
2383
2384 let open_task = multi_workspace
2385 .update(cx, |multi_workspace, window, cx| {
2386 multi_workspace.workspace().update(cx, |workspace, cx| {
2387 workspace.open_path((worktree_id, rel_path("b.md")), None, true, window, cx)
2388 })
2389 })
2390 .unwrap();
2391 let opened_item = open_task.await.unwrap();
2392 cx.run_until_parked();
2393 let editor_b = cx.update(|cx| opened_item.act_as::<Editor>(cx).unwrap());
2394 let editor_b_path = editor_source_path(cx, &editor_b);
2395 assert_eq!(editor_b_path.as_ref(), rel_path("b.md"));
2396
2397 let (preview, workspace_id) = multi_workspace
2398 .update(cx, |multi_workspace, window, cx| {
2399 multi_workspace.workspace().update(cx, |workspace, cx| {
2400 workspace.set_random_database_id();
2401 let workspace_id = workspace.database_id().unwrap();
2402 let preview = MarkdownPreviewView::create_following_markdown_view(
2403 workspace, editor_a, window, cx,
2404 );
2405 workspace.active_pane().update(cx, |pane, cx| {
2406 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2407 });
2408 (preview, workspace_id)
2409 })
2410 })
2411 .unwrap();
2412 let workspace_serialization_tasks = multi_workspace
2413 .update(cx, |multi_workspace, window, cx| {
2414 multi_workspace.flush_all_serialization(window, cx)
2415 })
2416 .unwrap();
2417 for task in workspace_serialization_tasks {
2418 task.await;
2419 }
2420 wait_for_preview_serialization(cx).await;
2421
2422 let serialize_task = multi_workspace
2423 .update(cx, |multi_workspace, window, cx| {
2424 let workspace = multi_workspace.workspace().clone();
2425 workspace.update(cx, |workspace, cx| {
2426 preview
2427 .update(cx, |preview, cx| {
2428 preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx)
2429 })
2430 .unwrap()
2431 })
2432 })
2433 .unwrap();
2434 serialize_task.await.unwrap();
2435
2436 assert_eq!(
2437 saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id),
2438 PathBuf::from(path!("/dir/a.md"))
2439 );
2440
2441 multi_workspace
2442 .update(cx, |_, window, cx| {
2443 preview.update(cx, |preview, cx| {
2444 preview.set_editor(editor_b, window, cx);
2445 });
2446 })
2447 .unwrap();
2448 wait_for_preview_serialization(cx).await;
2449
2450 let followed_path = preview_source_path(cx, &preview);
2451 assert_eq!(followed_path.as_ref(), rel_path("b.md"));
2452
2453 assert_eq!(
2454 saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id),
2455 PathBuf::from(path!("/dir/b.md")),
2456 "a Follow preview should persist the source editor it most recently followed"
2457 );
2458 }
2459
2460 #[gpui::test]
2461 async fn default_preview_stays_bound_to_invoking_editor_across_splits(cx: &mut TestAppContext) {
2462 let app_state = init_test(cx);
2463 app_state
2464 .fs
2465 .as_fake()
2466 .insert_tree(
2467 path!("/dir"),
2468 json!({
2469 "todo.md": "- [ ] Finish work\n"
2470 }),
2471 )
2472 .await;
2473
2474 cx.update(|cx| {
2475 open_paths(
2476 &[PathBuf::from(path!("/dir/todo.md"))],
2477 app_state.clone(),
2478 workspace::OpenOptions::default(),
2479 cx,
2480 )
2481 })
2482 .await
2483 .unwrap();
2484
2485 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2486 let (preview, second_editor) = multi_workspace
2487 .update(cx, |multi_workspace, window, cx| {
2488 let workspace = multi_workspace.workspace().clone();
2489 workspace.update(cx, |workspace, cx| {
2490 let first_editor: Entity<Editor> = workspace
2491 .active_item(cx)
2492 .and_then(|item| item.act_as::<Editor>(cx))
2493 .unwrap();
2494 let buffer = first_editor
2495 .read(cx)
2496 .buffer()
2497 .read(cx)
2498 .as_singleton()
2499 .unwrap();
2500 let project = workspace.project().clone();
2501
2502 let second_editor =
2503 cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
2504 let new_pane = workspace.split_pane(
2505 workspace.active_pane().clone(),
2506 workspace::SplitDirection::Right,
2507 window,
2508 cx,
2509 );
2510 new_pane.update(cx, |pane, cx| {
2511 pane.add_item(
2512 Box::new(second_editor.clone()),
2513 true,
2514 true,
2515 None,
2516 window,
2517 cx,
2518 )
2519 });
2520
2521 let preview = MarkdownPreviewView::create_markdown_view(
2522 workspace,
2523 second_editor.clone(),
2524 window,
2525 cx,
2526 );
2527 new_pane.update(cx, |pane, cx| {
2528 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2529 });
2530 (preview, second_editor)
2531 })
2532 })
2533 .unwrap();
2534 cx.run_until_parked();
2535
2536 let bound_editor = preview.read_with(cx, |preview, _| {
2537 preview.active_editor.as_ref().unwrap().editor.clone()
2538 });
2539 assert_eq!(
2540 bound_editor, second_editor,
2541 "a Default preview must stay bound to the editor it was opened from, not another \
2542 editor that happens to share the same buffer in a different split"
2543 );
2544 }
2545
2546 async fn open_markdown_file(
2547 cx: &mut TestAppContext,
2548 file_name: &str,
2549 contents: &str,
2550 ) -> (WindowHandle<MultiWorkspace>, Entity<Editor>) {
2551 let app_state = init_test(cx);
2552 let mut entries = serde_json::Map::new();
2553 entries.insert(file_name.to_string(), json!(contents));
2554 app_state
2555 .fs
2556 .as_fake()
2557 .insert_tree(path!("/dir"), serde_json::Value::Object(entries))
2558 .await;
2559
2560 cx.update(|cx| {
2561 open_paths(
2562 &[PathBuf::from(path!("/dir")).join(file_name)],
2563 app_state.clone(),
2564 workspace::OpenOptions::default(),
2565 cx,
2566 )
2567 })
2568 .await
2569 .unwrap();
2570
2571 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2572 let editor = multi_workspace
2573 .update(cx, |multi_workspace, _, cx| {
2574 multi_workspace
2575 .workspace()
2576 .read(cx)
2577 .active_item_as::<Editor>(cx)
2578 .unwrap()
2579 })
2580 .unwrap();
2581 (multi_workspace, editor)
2582 }
2583
2584 fn open_preview_for_active_editor(
2585 cx: &mut TestAppContext,
2586 multi_workspace: &WindowHandle<MultiWorkspace>,
2587 ) -> Entity<MarkdownPreviewView> {
2588 multi_workspace
2589 .update(cx, |multi_workspace, window, cx| {
2590 let workspace = multi_workspace.workspace().clone();
2591 workspace.update(cx, |workspace, cx| {
2592 let editor: Entity<Editor> = workspace.active_item_as(cx).unwrap();
2593 let preview =
2594 MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx);
2595 workspace.active_pane().update(cx, |pane, cx| {
2596 pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
2597 });
2598 preview
2599 })
2600 })
2601 .unwrap()
2602 }
2603
2604 fn dispatch_close_and_return_to_editor(
2605 cx: &mut TestAppContext,
2606 multi_workspace: &WindowHandle<MultiWorkspace>,
2607 preview: &Entity<MarkdownPreviewView>,
2608 ) {
2609 multi_workspace
2610 .update(cx, |_, window, cx| {
2611 assert!(
2612 preview.read(cx).focus_handle.contains_focused(window, cx),
2613 "preview must be focused for the keyboard action to dispatch to it"
2614 );
2615 window.dispatch_action(Box::new(CloseAndReturnToEditor), cx);
2616 })
2617 .unwrap();
2618 }
2619
2620 fn assert_editor_is_active_and_focused(
2621 cx: &mut TestAppContext,
2622 multi_workspace: &WindowHandle<MultiWorkspace>,
2623 expected_editor: &Entity<Editor>,
2624 ) {
2625 multi_workspace
2626 .update(cx, |multi_workspace, window, cx| {
2627 let workspace = multi_workspace.workspace().read(cx);
2628 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2629 assert_eq!(active_editor, *expected_editor);
2630 assert!(
2631 expected_editor
2632 .read(cx)
2633 .focus_handle(cx)
2634 .contains_focused(window, cx),
2635 "source editor should be focused"
2636 );
2637 })
2638 .unwrap();
2639 }
2640
2641 fn assert_no_markdown_preview_items(
2642 cx: &mut TestAppContext,
2643 multi_workspace: &WindowHandle<MultiWorkspace>,
2644 ) {
2645 multi_workspace
2646 .update(cx, |multi_workspace, _, cx| {
2647 assert_eq!(
2648 multi_workspace
2649 .workspace()
2650 .read(cx)
2651 .items_of_type::<MarkdownPreviewView>(cx)
2652 .count(),
2653 0
2654 );
2655 })
2656 .unwrap();
2657 }
2658
2659 #[gpui::test]
2660 async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) {
2661 let app_state = init_test(cx);
2662 app_state
2663 .fs
2664 .as_fake()
2665 .insert_tree(
2666 path!("/dir"),
2667 json!({
2668 "a.md": "# A\n",
2669 "b.md": "# B\n"
2670 }),
2671 )
2672 .await;
2673
2674 cx.update(|cx| {
2675 open_paths(
2676 &[PathBuf::from(path!("/dir/a.md"))],
2677 app_state.clone(),
2678 workspace::OpenOptions::default(),
2679 cx,
2680 )
2681 })
2682 .await
2683 .unwrap();
2684
2685 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2686 let workspace = multi_workspace
2687 .update(cx, |multi_workspace, _, _| {
2688 multi_workspace.workspace().clone()
2689 })
2690 .unwrap();
2691 let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
2692 let b_buffer = project
2693 .update(cx, |project, cx| {
2694 project.open_local_buffer(path!("/dir/b.md"), cx)
2695 })
2696 .await
2697 .unwrap();
2698
2699 let (first_pane, a_editor, second_pane, b_editor) = multi_workspace
2700 .update(cx, |multi_workspace, window, cx| {
2701 let workspace = multi_workspace.workspace().clone();
2702 workspace.update(cx, |workspace, cx| {
2703 let first_pane = workspace.active_pane().clone();
2704 let a_editor: Entity<Editor> = workspace
2705 .active_item(cx)
2706 .and_then(|item| item.act_as::<Editor>(cx))
2707 .unwrap();
2708 let project = workspace.project().clone();
2709 let second_pane = workspace.split_pane(
2710 first_pane.clone(),
2711 workspace::SplitDirection::Right,
2712 window,
2713 cx,
2714 );
2715 let b_editor =
2716 cx.new(|cx| Editor::for_buffer(b_buffer, Some(project), window, cx));
2717 second_pane.update(cx, |pane, cx| {
2718 pane.add_item(Box::new(b_editor.clone()), true, true, None, window, cx)
2719 });
2720 (first_pane, a_editor, second_pane, b_editor)
2721 })
2722 })
2723 .unwrap();
2724 cx.run_until_parked();
2725
2726 // With focus in the second pane (`b.md`), simulate clicking the
2727 // preview button in the first pane's toolbar, which targets that
2728 // pane's own editor (`a.md`).
2729 multi_workspace
2730 .update(cx, |multi_workspace, window, cx| {
2731 let workspace = multi_workspace.workspace().clone();
2732 workspace.update(cx, |workspace, cx| {
2733 assert_eq!(
2734 workspace.active_pane(),
2735 &second_pane,
2736 "test precondition: focus must be in the second pane"
2737 );
2738 MarkdownPreviewView::open_preview_in_pane(
2739 workspace,
2740 a_editor.clone(),
2741 first_pane.clone(),
2742 window,
2743 cx,
2744 );
2745 })
2746 })
2747 .unwrap();
2748 cx.run_until_parked();
2749
2750 cx.update(|cx| {
2751 let preview = first_pane
2752 .read(cx)
2753 .active_item()
2754 .and_then(|item| item.downcast::<MarkdownPreviewView>())
2755 .expect("the preview must open in the pane whose button was clicked");
2756 let bound_editor = preview
2757 .read(cx)
2758 .active_editor
2759 .as_ref()
2760 .unwrap()
2761 .editor
2762 .clone();
2763 assert_eq!(
2764 bound_editor, a_editor,
2765 "the preview must be bound to the clicked pane's editor, not the focused editor"
2766 );
2767 assert_eq!(
2768 second_pane
2769 .read(cx)
2770 .active_item()
2771 .and_then(|item| item.downcast::<Editor>()),
2772 Some(b_editor),
2773 "the focused pane's content must be unaffected"
2774 );
2775 });
2776 }
2777
2778 #[gpui::test]
2779 async fn derives_remote_preview_source_directory(cx: &mut TestAppContext) {
2780 init_test(cx);
2781 let project = Project::test(FakeFs::new(cx.executor()), [], cx).await;
2782 let worktree = project.update(cx, |project, cx| {
2783 project.add_test_remote_worktree("/remote/project", cx)
2784 });
2785 let file: Arc<dyn language::File> = Arc::new(project::File {
2786 worktree,
2787 path: rel_path("docs/readme.md").into(),
2788 disk_state: DiskState::New,
2789 entry_id: None,
2790 is_local: false,
2791 is_private: false,
2792 });
2793 let buffer = cx.new(|cx| {
2794 let mut buffer = Buffer::local("# readme\n", cx);
2795 buffer.file_updated(file, cx);
2796 buffer
2797 });
2798 let (editor, cx) =
2799 cx.add_window_view(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx));
2800
2801 let folder = editor.read_with(cx, |editor, cx| {
2802 MarkdownPreviewView::get_folder_for_active_editor(editor, cx)
2803 });
2804 assert_eq!(folder, Some(PathBuf::from("/remote/project/docs")));
2805 }
2806
2807 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2808 cx.update(|cx| {
2809 let state = AppState::test(cx);
2810 editor::init(cx);
2811 crate::init(cx);
2812 state
2813 })
2814 }
2815
2816 async fn wait_for_preview_serialization(cx: &mut TestAppContext) {
2817 cx.run_until_parked();
2818 cx.executor().advance_clock(Duration::from_millis(250));
2819 cx.run_until_parked();
2820 }
2821
2822 fn saved_preview_path(
2823 cx: &mut TestAppContext,
2824 item_id: ItemId,
2825 workspace_id: WorkspaceId,
2826 ) -> PathBuf {
2827 cx.update(|cx| {
2828 super::persistence::MarkdownPreviewDb::global(cx)
2829 .get_preview(item_id, workspace_id)
2830 .unwrap()
2831 .unwrap()
2832 .0
2833 })
2834 }
2835
2836 fn preview_source_path(
2837 cx: &mut TestAppContext,
2838 preview: &Entity<MarkdownPreviewView>,
2839 ) -> Arc<RelPath> {
2840 let editor = preview.read_with(cx, |preview, _| {
2841 preview.active_editor.as_ref().unwrap().editor.clone()
2842 });
2843 editor_source_path(cx, &editor)
2844 }
2845
2846 fn editor_source_path(cx: &mut TestAppContext, editor: &Entity<Editor>) -> Arc<RelPath> {
2847 editor.read_with(cx, |editor, cx| {
2848 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
2849 buffer.read(cx).file().unwrap().path().clone()
2850 })
2851 }
2852
2853 fn markdown_fixture_directory(tree: &TempTree) -> PathBuf {
2854 tree.path().join("docs")
2855 }
2856
2857 #[track_caller]
2858 fn assert_resolved_preview_image_path(
2859 resolved: Option<ImageSource>,
2860 expected_path: &std::path::Path,
2861 ) {
2862 match resolved {
2863 Some(ImageSource::Resource(Resource::Path(path))) => {
2864 assert_eq!(path.as_ref(), expected_path);
2865 }
2866 _ => panic!("Expected preview image to resolve to a local path"),
2867 }
2868 }
2869}
2870