Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:39:50.556Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

markdown_preview_view.rs

2870 lines · 103.2 KB · rust
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": "![image](image.png)\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
Served at tenant.openagents/omega Member data and write actions are omitted.