use std::any::TypeId; use std::borrow::Cow; use std::cmp::min; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use anyhow::{Context as _, Result}; use editor::items::open_resolved_target; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, }; use language::{Buffer, LanguageRegistry}; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; use project::search::SearchQuery; use project::{Project, ProjectPath}; use settings::{SeedQuerySetting, Settings, update_settings_file}; use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; use ui::utils::WithRemSize; use ui::{ContextMenu, LinkPreview, WithScrollbar, prelude::*, right_click_menu}; use util::{ ResultExt, markdown::{source_position_from_fragment, split_local_url_fragment}, paths::PathWithPosition, }; use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem}; use workspace::notifications::{NotifyResultExt, NotifyTaskExt}; use workspace::path_link::{PathMatching, resolve_open_target}; use workspace::searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }; use workspace::{ItemId, Pane, SaveIntent, Workspace, WorkspaceId, delete_unloaded_items}; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; use crate::markdown_preview_settings::MarkdownPreviewSettings; use crate::{ CloseAndReturnToEditor, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, }; use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); pub struct MarkdownPreviewView { workspace: WeakEntity, active_editor: Option, focus_handle: FocusHandle, markdown: Entity, _markdown_subscription: Subscription, active_source_index: Option, scroll_handle: ScrollHandle, image_cache: Entity, base_directory: Option, pending_update_task: Option>>, hovered_url: Option, mode: MarkdownPreviewMode, } #[derive(Clone, Copy, Debug, PartialEq)] pub enum MarkdownPreviewMode { /// The preview will always show the contents of the provided editor. Default, /// The preview will "follow" the currently active editor. Follow, } impl MarkdownPreviewMode { fn to_db(self) -> i64 { match self { Self::Default => 0, Self::Follow => 1, } } fn from_db(value: i64) -> Self { match value { 1 => Self::Follow, _ => Self::Default, } } } struct EditorState { editor: Entity, _subscription: Subscription, } #[derive(Clone, Copy, Debug)] pub enum MarkdownPreviewEvent { SourceEditorChanged, SourceFileHandleChanged, } enum PreviewLinkTarget { Heading(SharedString), Position { row: u32, column: u32 }, } impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { let pane = workspace.active_pane().clone(); Self::open_preview_in_pane(workspace, editor, pane, window, cx); } }); workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { let pane = workspace.active_pane().clone(); Self::open_preview_to_the_side_of_pane(workspace, editor, pane, window, cx); } }); workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { // Check if there's already a following preview let existing_follow_view_idx = { let active_pane = workspace.active_pane().read(cx); active_pane .items_of_type::() .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow) .and_then(|view| active_pane.index_for_item(&view)) }; if let Some(existing_follow_view_idx) = existing_follow_view_idx { workspace.active_pane().update(cx, |pane, cx| { pane.activate_item(existing_follow_view_idx, true, true, window, cx); }); } else { let view = Self::create_following_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(view.clone()), true, true, None, window, cx) }); } cx.notify(); } }); } pub fn open_preview_in_pane( workspace: &mut Workspace, editor: Entity, pane: Entity, window: &mut Window, cx: &mut Context, ) { Self::activate_or_add_preview(workspace, editor, pane, true, window, cx); } pub fn open_preview_to_the_side_of_pane( workspace: &mut Workspace, editor: Entity, origin_pane: Entity, window: &mut Window, cx: &mut Context, ) { let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx); editor.focus_handle(cx).focus(window, cx); } fn activate_or_add_preview( workspace: &mut Workspace, editor: Entity, pane: Entity, focus: bool, window: &mut Window, cx: &mut Context, ) { let existing_view_idx = Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx); if let Some(existing_view_idx) = existing_view_idx { pane.update(cx, |pane, cx| { pane.activate_item(existing_view_idx, focus, focus, window, cx); }); } else { let view = Self::create_markdown_view(workspace, editor, window, cx); pane.update(cx, |pane, cx| { pane.add_item(Box::new(view), focus, focus, None, window, cx) }); } cx.notify(); } fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, cx: &App, ) -> Option { let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; pane.items_of_type::() .find(|view| { // Only look for independent (Default mode) previews, not Follow previews. // Match by buffer entity rather than editor entity so the lookup survives // workspace restoration, where the preview's bound editor may differ from // the editor the user is currently invoking the action on even though both // wrap the same source buffer. view.read(cx).mode == MarkdownPreviewMode::Default && view.read(cx).is_previewing(&target_buffer, cx) }) .and_then(|view| pane.index_for_item(&view)) } fn is_previewing(&self, buffer: &Entity, cx: &App) -> bool { self.active_editor.as_ref().is_some_and(|state| { state .editor .read(cx) .buffer() .read(cx) .as_singleton() .as_ref() == Some(buffer) }) } pub fn resolve_active_item_as_markdown_editor( workspace: &Workspace, cx: &mut Context, ) -> Option> { if let Some(editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) && Self::is_markdown_file(&editor, cx) { return Some(editor); } None } pub fn create_markdown_view( workspace: &mut Workspace, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { let language_registry = workspace.project().read(cx).languages().clone(); let workspace_handle = workspace.weak_handle(); MarkdownPreviewView::new( MarkdownPreviewMode::Default, editor, workspace_handle, language_registry, window, cx, ) } fn create_following_markdown_view( workspace: &mut Workspace, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { let language_registry = workspace.project().read(cx).languages().clone(); let workspace_handle = workspace.weak_handle(); MarkdownPreviewView::new( MarkdownPreviewMode::Follow, editor, workspace_handle, language_registry, window, cx, ) } pub fn new( mode: MarkdownPreviewMode, active_editor: Entity, workspace: WeakEntity, language_registry: Arc, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let markdown = cx.new(|cx| { Markdown::new_with_options( SharedString::default(), Some(language_registry), None, MarkdownOptions { parse_html: true, render_mermaid_diagrams: true, parse_heading_slugs: true, render_metadata_blocks: true, ..Default::default() }, cx, ) }); let mut this = Self { active_editor: None, focus_handle: cx.focus_handle(), workspace: workspace.clone(), _markdown_subscription: cx.observe( &markdown, |this: &mut Self, _: Entity, cx| { this.sync_active_root_block(cx); }, ), markdown, active_source_index: None, scroll_handle: ScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), base_directory: None, pending_update_task: None, hovered_url: None, mode, }; this.set_editor(active_editor, window, cx); match mode { MarkdownPreviewMode::Follow => { if let Some(workspace) = &workspace.upgrade() { cx.observe_in(workspace, window, |this, workspace, window, cx| { let item = workspace.read(cx).active_item(cx); this.workspace_updated(item, window, cx); }) .detach(); } else { log::error!("Failed to listen to workspace updates"); } } MarkdownPreviewMode::Default => { // After workspace restoration the bound editor may be an orphan that // wraps the right buffer but isn't the canonical Editor instance in // any pane. Re-binding to the workspace's editor for our buffer is // what restores cursor-driven scroll sync — `SelectionsChanged` only // fires from the editor the user actually interacts with. // // Subscribing to `workspace::Event` (rather than `observe`) keeps the // rebind check off the cursor-move hot path; `observe` would fire on // every workspace `cx.notify`. if let Some(workspace) = &workspace.upgrade() { cx.subscribe_in(workspace, window, Self::on_workspace_event) .detach(); } } } this }) } fn workspace_updated( &mut self, active_item: Option>, window: &mut Window, cx: &mut Context, ) { if let Some(item) = active_item && item.item_id() != cx.entity_id() && let Some(editor) = item.act_as::(cx) && Self::is_markdown_file(&editor, cx) { self.set_editor(editor, window, cx); } } pub fn is_markdown_path(path: impl AsRef, languages: &Arc) -> bool { languages .language_for_file_path(path.as_ref()) .is_some_and(|language_id| { languages .available_language_for_name("Markdown") .is_some_and(|markdown| markdown.id() == language_id) }) } pub fn open_for_project_path( project_path: ProjectPath, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { let open_buffer = workspace .project() .update(cx, |project, cx| project.open_buffer(project_path, cx)); cx.spawn_in(window, async move |workspace, mut cx| { let Some(buffer) = open_buffer .await .notify_workspace_async_err(workspace.clone(), &mut cx) else { return; }; workspace .update_in(cx, |workspace, window, cx| { let project = workspace.project().clone(); let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); let preview = Self::create_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview), true, true, None, window, cx); }); }) .ok(); }) .detach(); } pub fn is_markdown_file(editor: &Entity, cx: &App) -> bool { let buffer = editor.read(cx).buffer().read(cx); if let Some(buffer) = buffer.as_singleton() && let Some(language) = buffer.read(cx).language() { return language.name() == "Markdown"; } false } fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(active) = &self.active_editor && active.editor == editor { return; } let had_active_editor = self.active_editor.is_some(); let subscription = cx.subscribe_in( &editor, window, |this, editor, event: &EditorEvent, window, cx| { match event { EditorEvent::Edited { .. } | EditorEvent::BufferEdited { .. } | EditorEvent::DirtyChanged | EditorEvent::BuffersEdited { .. } => { this.update_markdown_from_active_editor(true, false, window, cx); } EditorEvent::FileHandleChanged => { this.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); this.update_markdown_from_active_editor(false, false, window, cx); cx.emit(MarkdownPreviewEvent::SourceFileHandleChanged); } EditorEvent::SelectionsChanged { .. } => { let (selection_start, editor_is_focused) = editor.update(cx, |editor, cx| { let index = Self::selected_source_index(editor, cx); let focused = editor.focus_handle(cx).is_focused(window); (index, focused) }); if let Some(selection_start) = selection_start { this.sync_preview_to_source_index( selection_start, editor_is_focused, cx, ); cx.notify(); } } _ => {} }; }, ); self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); self.hovered_url = None; self.active_editor = Some(EditorState { editor, _subscription: subscription, }); self.update_markdown_from_active_editor(false, true, window, cx); if had_active_editor { cx.emit(MarkdownPreviewEvent::SourceEditorChanged); } } fn on_workspace_event( &mut self, workspace: &Entity, event: &workspace::Event, window: &mut Window, cx: &mut Context, ) { if !matches!( event, workspace::Event::ItemAdded { .. } | workspace::Event::ItemRemoved { .. } ) { return; } let candidate = self.find_canonical_editor(workspace.read(cx), cx); if let Some(editor) = candidate && self .active_editor .as_ref() .is_none_or(|s| s.editor != editor) { self.set_editor(editor, window, cx); } } fn find_canonical_editor(&self, workspace: &Workspace, cx: &App) -> Option> { let current = self.active_editor.as_ref()?.editor.clone(); let our_buffer = current.read(cx).buffer().read(cx).as_singleton()?; let mut fallback = None; for editor in workspace.items_of_type::(cx) { if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&our_buffer) { continue; } if editor == current { return Some(current); } fallback.get_or_insert(editor); } fallback } fn update_markdown_from_active_editor( &mut self, wait_for_debounce: bool, should_reveal: bool, window: &mut Window, cx: &mut Context, ) { if let Some(state) = &self.active_editor { // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing if wait_for_debounce && self.pending_update_task.is_some() { return; } self.pending_update_task = Some(self.schedule_markdown_update( wait_for_debounce, should_reveal, state.editor.clone(), window, cx, )); } } fn schedule_markdown_update( &mut self, wait_for_debounce: bool, should_reveal_selection: bool, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { cx.spawn_in(window, async move |view, cx| { if wait_for_debounce { // Wait for the user to stop typing cx.background_executor().timer(REPARSE_DEBOUNCE).await; } let update = view.update(cx, |view, cx| { let is_active_editor = view .active_editor .as_ref() .is_some_and(|active_editor| active_editor.editor == editor); if !is_active_editor { return None; } editor.update(cx, |editor, cx| { let contents = editor .buffer() .read(cx) .as_singleton()? .read(cx) .as_rope() .to_string() .into(); let selection_start = Self::selected_source_index(editor, cx)?; Some((contents, selection_start)) }) })?; view.update(cx, move |view, cx| { if let Some((contents, selection_start)) = update { view.hovered_url = None; view.markdown.update(cx, |markdown, cx| { markdown.reset(contents, cx); }); view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx); cx.emit(SearchEvent::MatchesInvalidated); } view.pending_update_task = None; cx.notify(); }) }) } fn selected_source_index(editor: &Editor, cx: &mut App) -> Option { let display_snapshot = editor.display_snapshot(cx); let source_offset = editor .selections .last::(&display_snapshot) .range() .start; let buffer = editor.buffer().read(cx).as_singleton()?; let buffer_id = buffer.read(cx).remote_id(); let (buffer_snapshot, buffer_offset) = display_snapshot .buffer_snapshot() .point_to_buffer_offset(source_offset)?; if buffer_snapshot.remote_id() == buffer_id { Some(buffer_offset.0) } else { None } } fn sync_preview_to_source_index( &mut self, source_index: usize, reveal: bool, cx: &mut Context, ) { self.active_source_index = Some(source_index); self.sync_active_root_block(cx); self.markdown.update(cx, |markdown, cx| { if reveal { markdown.request_autoscroll_to_source_index(source_index, cx); } }); } fn sync_active_root_block(&mut self, cx: &mut Context) { self.markdown.update(cx, |markdown, cx| { markdown.set_active_root_for_source_index(self.active_source_index, cx); }); } fn navigate_to_link_target( &mut self, target: Option, window: &mut Window, cx: &mut Context, ) { match target { None => {} Some(PreviewLinkTarget::Heading(slug)) => { self.markdown.update(cx, |markdown, cx| { markdown.scroll_to_heading_when_parsed(slug, cx); }); } Some(PreviewLinkTarget::Position { row, column }) => { let Some(editor) = self .active_editor .as_ref() .map(|state| state.editor.clone()) else { return; }; let Some(source_index) = editor .read(cx) .buffer() .read(cx) .as_singleton() .map(|buffer| { let snapshot = buffer.read(cx).snapshot(); let point = snapshot.point_from_external_input(row, column); snapshot.point_to_offset(point) }) else { return; }; Self::change_selection_to_source_index(&editor, source_index, false, window, cx); self.sync_preview_to_source_index(source_index, true, cx); } } } fn change_selection_to_source_index( editor: &Entity, source_index: usize, move_focus: bool, window: &mut Window, cx: &mut App, ) { editor.update(cx, |editor, cx| { let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index); editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, |selections| selections.select_ranges(vec![selection]), ); if move_focus { window.focus(&editor.focus_handle(cx), cx); } }); } /// The absolute path of the file that is currently being previewed. fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option { let file = editor.file_at(MultiBufferOffset(0), cx)?; let absolute_path = editor .project() .and_then(|project| { project.read(cx).absolute_path( &ProjectPath { worktree_id: file.worktree_id(cx), path: file.path().clone(), }, cx, ) }) .or_else(|| file.as_local().map(|file| file.abs_path(cx)))?; absolute_path.parent().map(Path::to_path_buf) } fn line_scroll_amount(&self, cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); settings.markdown_preview_font_size(cx) * settings.buffer_line_height.value() } fn increase_font_size( &mut self, action: &IncreaseBufferFontSize, _: &mut Window, cx: &mut Context, ) { self.adjust_font_size(action.persist, px(1.0), cx); } fn decrease_font_size( &mut self, action: &DecreaseBufferFontSize, _: &mut Window, cx: &mut Context, ) { self.adjust_font_size(action.persist, px(-1.0), cx); } fn adjust_font_size(&mut self, persist: bool, delta: Pixels, cx: &mut Context) { if persist { let Ok(fs) = self .workspace .read_with(cx, |workspace, _| workspace.app_state().fs.clone()) else { return; }; update_settings_file(fs, cx, move |settings, cx| { let size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx) + delta; settings.theme.markdown_preview_font_size = Some(f32::from(theme_settings::clamp_font_size(size)).into()); }); } else { theme_settings::adjust_markdown_preview_font_size(cx, |size| size + delta); } } fn reset_font_size( &mut self, action: &ResetBufferFontSize, _: &mut Window, cx: &mut Context, ) { if action.persist { let Ok(fs) = self .workspace .read_with(cx, |workspace, _| workspace.app_state().fs.clone()) else { return; }; update_settings_file(fs, cx, move |settings, _| { settings.theme.markdown_preview_font_size = None; }); } else { theme_settings::reset_markdown_preview_font_size(cx); } } fn scroll_by_amount(&self, distance: Pixels) { let offset = self.scroll_handle.offset(); self.scroll_handle .set_offset(point(offset.x, offset.y - distance)); } fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } self.scroll_by_amount(-viewport_height); cx.notify(); } fn scroll_page_down( &mut self, _: &ScrollPageDown, _window: &mut Window, cx: &mut Context, ) { let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } self.scroll_by_amount(viewport_height); cx.notify(); } fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { if let Some(bounds) = self .scroll_handle .bounds_for_item(self.scroll_handle.top_item()) { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); self.scroll_by_amount(-scroll_height); } else { let scroll_height = self.line_scroll_amount(cx); if !scroll_height.is_zero() { self.scroll_by_amount(-scroll_height); } } cx.notify(); } fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { if let Some(bounds) = self .scroll_handle .bounds_for_item(self.scroll_handle.top_item()) { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); self.scroll_by_amount(scroll_height); } else { let scroll_height = self.line_scroll_amount(cx); if !scroll_height.is_zero() { self.scroll_by_amount(scroll_height); } } cx.notify(); } fn scroll_up_by_item( &mut self, _: &ScrollUpByItem, _window: &mut Window, cx: &mut Context, ) { if let Some(bounds) = self .scroll_handle .bounds_for_item(self.scroll_handle.top_item()) { self.scroll_by_amount(-bounds.size.height); } cx.notify(); } fn scroll_down_by_item( &mut self, _: &ScrollDownByItem, _window: &mut Window, cx: &mut Context, ) { if let Some(bounds) = self .scroll_handle .bounds_for_item(self.scroll_handle.top_item()) { self.scroll_by_amount(bounds.size.height); } cx.notify(); } fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context) { self.scroll_handle.scroll_to_item(0); cx.notify(); } fn scroll_to_bottom( &mut self, _: &ScrollToBottom, _window: &mut Window, cx: &mut Context, ) { self.scroll_handle.scroll_to_bottom(); cx.notify(); } fn close_and_return_to_editor( &mut self, _: &CloseAndReturnToEditor, window: &mut Window, cx: &mut Context, ) { let Some(editor) = self .active_editor .as_ref() .map(|state| state.editor.clone()) else { return; }; let Some(workspace) = self.workspace.upgrade() else { return; }; let preview_id = cx.entity_id(); window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if !workspace.activate_item(&editor, true, true, window, cx) { workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); } if let Some(pane) = workspace.pane_for_item_id(preview_id) { pane.update(cx, |pane, cx| { pane.close_item_by_id(preview_id, SaveIntent::Skip, window, cx) }) .detach_and_log_err(cx); } }); }); } /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the /// user hasn't set one or it can't be resolved. fn resolve_preview_theme(&self, cx: &App) -> Option> { let theme_settings = ThemeSettings::get_global(cx); let theme_selection = theme_settings.markdown_preview_theme.as_ref()?; let theme_name = theme_selection.name(SystemAppearance::global(cx).0); ThemeRegistry::global(cx).get(&theme_name.0).ok() } fn render_markdown_element( &self, preview_theme: &Option>, window: &mut Window, cx: &mut Context, ) -> MarkdownElement { let active_editor = self .active_editor .as_ref() .map(|state| state.editor.clone()); let mut workspace_directory = None; if let Some(workspace_entity) = self.workspace.upgrade() { let project = workspace_entity.read(cx).project(); if let Some(tree) = project.read(cx).worktrees(cx).next() { workspace_directory = Some(tree.read(cx).abs_path().to_path_buf()); } } let markdown_style = if let Some(theme) = preview_theme { MarkdownStyle::themed_with_overrides( MarkdownFont::Preview, theme.colors(), theme.syntax(), window, cx, ) } else { MarkdownStyle::themed(MarkdownFont::Preview, window, cx) }; let mut markdown_element = MarkdownElement::new(self.markdown.clone(), markdown_style) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::VisibleOnHover, wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .scroll_handle(self.scroll_handle.clone()) .show_root_block_markers() .image_resolver({ let base_directory = self.base_directory.clone(); move |dest_url| { resolve_preview_image( dest_url, base_directory.as_deref(), workspace_directory.as_deref(), ) } }) .on_url_click({ let view_handle = cx.entity().downgrade(); let workspace = self.workspace.clone(); let base_directory = self.base_directory.clone(); move |url, window, cx| { handle_url_click( url, &view_handle, base_directory.clone(), &workspace, window, cx, ); } }) .on_url_hover({ let view_handle = cx.entity().downgrade(); move |hovered_url, _window, cx| { view_handle .update(cx, |view, cx| { if view.hovered_url != hovered_url { view.hovered_url = hovered_url; cx.notify(); } }) .log_err(); } }); if let Some(active_editor) = active_editor { let editor_for_checkbox = active_editor.clone(); let view_handle = cx.entity().downgrade(); markdown_element = markdown_element .on_source_click(move |source_index, click_count, window, cx| { if click_count == 1 { Self::change_selection_to_source_index( &active_editor, source_index, false, window, cx, ); } false }) .on_checkbox_toggle(move |source_range, new_checked, window, cx| { Self::apply_checkbox_toggle_to_editor( &editor_for_checkbox, source_range, new_checked, cx, ); Self::refresh_preview(view_handle.clone(), window, cx); }); } markdown_element } fn apply_checkbox_toggle_to_editor( editor: &Entity, source_range: std::ops::Range, new_checked: bool, cx: &mut App, ) { let task_marker = if new_checked { "[x]" } else { "[ ]" }; let expected_existing_marker = if new_checked { "[ ]" } else { "[x]" }; editor.update(cx, |editor, cx| { let existing_marker: String = editor .buffer() .read(cx) .snapshot(cx) .text_for_range( MultiBufferOffset(source_range.start)..MultiBufferOffset(source_range.end), ) .collect(); debug_assert_eq!(existing_marker, expected_existing_marker); editor.edit( [( MultiBufferOffset(source_range.start)..MultiBufferOffset(source_range.end), task_marker, )], cx, ); }); } fn refresh_preview(view_handle: WeakEntity, window: &mut Window, cx: &mut App) { if let Some(view) = view_handle.upgrade() { let preview_is_focused = view.read(cx).focus_handle.contains_focused(window, cx); if !preview_is_focused { return; } cx.update_entity(&view, |this, cx| { this.update_markdown_from_active_editor(false, false, window, cx); }); } } } fn handle_url_click( url: SharedString, view: &WeakEntity, base_directory: Option, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let (path_part, fragment) = split_local_url_fragment(url.as_ref()); if path_part.is_empty() { if let Some(fragment) = fragment { let view = view.clone(); let slug = SharedString::from(fragment.to_string()); window.defer(cx, move |window, cx| { if let Some(view) = view.upgrade() { let markdown = view.read(cx).markdown.clone(); let active_editor = view .read(cx) .active_editor .as_ref() .map(|state| state.editor.clone()); let source_index = markdown.update(cx, |markdown, cx| markdown.scroll_to_heading(&slug, cx)); if let Some(source_index) = source_index { if let Some(editor) = active_editor { MarkdownPreviewView::change_selection_to_source_index( &editor, source_index, true, window, cx, ); } } } }); } } else { open_preview_url( SharedString::from(path_part.to_string()), fragment.map(|fragment| SharedString::from(fragment.to_string())), base_directory, Some(view.clone()), workspace, window, cx, ); } } fn open_preview_url( url: SharedString, fragment: Option, base_directory: Option, source_view: Option>, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let decoded_path = urlencoding::decode(&url).unwrap_or_else(|_| Cow::Borrowed(&url)); // Only `://` URLs skip local handling (a scheme check would misclassify `main.rs:2`); ambiguous links like `tel:123` are tried as files first, then fall back to the URL handler. if !decoded_path.contains("://") { let parsed_path = PathWithPosition::parse_str(&decoded_path); let fragment_position = fragment .as_deref() .and_then(|fragment| fragment.strip_prefix('L')) .and_then(source_position_from_fragment); if !window.modifiers().alt && workspace .read_with(cx, |workspace, cx| { MarkdownPreviewView::is_markdown_path( &parsed_path.path, workspace.project().read(cx).languages(), ) }) .is_ok_and(|is_markdown| is_markdown) { let link_target = if let Some(row) = parsed_path.row { Some(PreviewLinkTarget::Position { row: row.saturating_sub(1), column: parsed_path.column.unwrap_or(1).saturating_sub(1), }) } else if let Some((row, column)) = fragment_position { Some(PreviewLinkTarget::Position { row, column }) } else { fragment.map(PreviewLinkTarget::Heading) }; open_markdown_link_in_preview( parsed_path.path.to_string_lossy().into_owned(), link_target, decoded_path.into_owned(), base_directory, source_view, workspace, window, cx, ); return; } let path_with_position = if parsed_path.row.is_some() { Some(decoded_path.to_string()) } else { fragment_position.map(|(row, column)| { if column == 0 { format!("{decoded_path}:{}", row + 1) } else { format!("{decoded_path}:{}:{}", row + 1, column + 1) } }) }; if let Some(path_with_position) = path_with_position { open_preview_path_with_position( path_with_position, decoded_path.into_owned(), base_directory, workspace, window, cx, ); return; } } if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { workspace.open_url_or_file(&decoded_path, base_directory.as_deref(), window, cx); }); } else { cx.open_url(url.as_ref()); } } fn find_preview_for_buffer( workspace: &Workspace, buffer: &Entity, cx: &App, ) -> Option> { workspace .items_of_type::(cx) .find(|view| { view.read(cx).mode == MarkdownPreviewMode::Default && view.read(cx).is_previewing(buffer, cx) }) } fn open_markdown_link_in_preview( path: String, link_target: Option, fallback_url: String, base_directory: Option, source_view: Option>, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let open_target = resolve_open_target( workspace, PathMatching::Exact, &path, base_directory.as_deref(), cx, ); let workspace = workspace.clone(); let workspace_for_error = workspace.clone(); let task = window.spawn(cx, async move |cx| { let target_path = open_target .await .filter(|open_target| open_target.is_file()) .map(|open_target| open_target.path().path.clone()); let Some(abs_path) = target_path else { workspace.update_in(cx, |workspace, window, cx| { workspace.open_url_or_file(&fallback_url, base_directory.as_deref(), window, cx); })?; return anyhow::Ok(()); }; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; let (worktree, relative_path) = project .update(cx, |project, cx| { project.find_or_create_worktree(abs_path, false, cx) }) .await .context("resolving Markdown preview link worktree")?; let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); let buffer = project .update(cx, |project, cx| { project.open_buffer( ProjectPath { worktree_id, path: relative_path, }, cx, ) }) .await .context("opening Markdown preview link buffer")?; workspace.update_in(cx, |workspace, window, cx| { if let Some(source_view) = source_view.as_ref().and_then(|view| view.upgrade()) && source_view.read(cx).is_previewing(&buffer, cx) { source_view.update(cx, |view, cx| { view.navigate_to_link_target(link_target, window, cx); }); return; } if let Some(existing_view) = find_preview_for_buffer(workspace, &buffer, cx) { workspace.activate_item(&existing_view, true, true, window, cx); existing_view.update(cx, |view, cx| { view.navigate_to_link_target(link_target, window, cx); }); return; } let editor = cx.new(|cx| { Editor::for_buffer(buffer, Some(workspace.project().clone()), window, cx) }); let preview = MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); let pane = source_view .as_ref() .and_then(|view| view.upgrade()) .and_then(|view| workspace.pane_for(&view)) .unwrap_or_else(|| workspace.active_pane().clone()); pane.update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx); }); preview.update(cx, |view, cx| { view.navigate_to_link_target(link_target, window, cx); }); })?; anyhow::Ok(()) }); task.detach_and_notify_err(workspace_for_error, window, cx); } fn open_preview_path_with_position( path_with_position: String, fallback_path: String, base_directory: Option, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let open_target = resolve_open_target( workspace, PathMatching::Exact, &path_with_position, base_directory.as_deref(), cx, ); let workspace = workspace.clone(); let workspace_for_error = workspace.clone(); let task = window.spawn(cx, async move |cx| { let opened = if let Some(open_target) = open_target.await { open_resolved_target(&workspace, &open_target, cx) .await .context("opening Markdown preview link")? } else { false }; if !opened { workspace.update_in(cx, |workspace, window, cx| { workspace.open_url_or_file(&fallback_path, base_directory.as_deref(), window, cx); })?; } anyhow::Ok(()) }); task.detach_and_notify_err(workspace_for_error, window, cx); } fn resolve_preview_image( dest_url: &str, base_directory: Option<&Path>, workspace_directory: Option<&Path>, ) -> Option { if dest_url.starts_with("data:") { return None; } if dest_url.starts_with("http://") || dest_url.starts_with("https://") { return Some(ImageSource::Resource(Resource::Uri(SharedUri::from( dest_url.to_string(), )))); } let decoded = urlencoding::decode(dest_url) .map(|decoded| decoded.into_owned()) .unwrap_or_else(|_| dest_url.to_string()); if let Some(stripped) = ['/', '\\'] .iter() .find_map(|prefix| decoded.strip_prefix(*prefix)) { if let Some(root) = workspace_directory { let absolute_path = root.join(stripped); if absolute_path.exists() { return Some(ImageSource::Resource(Resource::Path(Arc::from( absolute_path.as_path(), )))); } else { return None; } } } let path = if Path::new(&decoded).is_absolute() { PathBuf::from(decoded) } else { base_directory?.join(decoded) }; path.exists() .then(|| ImageSource::Resource(Resource::Path(Arc::from(path.as_path())))) } impl Focusable for MarkdownPreviewView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for MarkdownPreviewView {} impl EventEmitter for MarkdownPreviewView {} impl Item for MarkdownPreviewView { type Event = MarkdownPreviewEvent; fn act_as_type<'a>( &'a self, type_id: TypeId, self_handle: &'a Entity, _: &'a App, ) -> Option { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { self.active_editor .as_ref() .map(|state| state.editor.clone().into()) } else { None } } fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { Some(Icon::new(IconName::FileDoc)) } fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { self.active_editor .as_ref() .map(|editor_state| { let buffer = editor_state.editor.read(cx).buffer().read(cx); let title = buffer.title(cx); format!("Preview {}", title).into() }) .unwrap_or_else(|| SharedString::from("Markdown Preview")) } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Markdown Preview Opened") } fn added_to_workspace( &mut self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { if self.mode != MarkdownPreviewMode::Default { return; } if let Some(editor) = self.find_canonical_editor(workspace, cx) && self .active_editor .as_ref() .is_none_or(|s| s.editor != editor) { self.set_editor(editor, window, cx); } } fn can_save(&self, cx: &App) -> bool { self.active_editor .as_ref() .is_some_and(|editor_state| editor_state.editor.read(cx).can_save(cx)) } fn can_save_as(&self, cx: &App) -> bool { self.active_editor .as_ref() .is_some_and(|editor_state| editor_state.editor.read(cx).can_save_as(cx)) } fn save( &mut self, options: SaveOptions, project: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { self.active_editor .as_ref() .map(|editor_state| { editor_state .editor .update(cx, |editor, cx| editor.save(options, project, window, cx)) }) .unwrap_or_else(|| Task::ready(Ok(()))) } fn save_as( &mut self, project: Entity, path: project::ProjectPath, window: &mut Window, cx: &mut Context, ) -> Task> { self.active_editor .as_ref() .map(|editor_state| { editor_state .editor .update(cx, |editor, cx| editor.save_as(project, path, window, cx)) }) .unwrap_or_else(|| Task::ready(Ok(()))) } fn reload( &mut self, _project: Entity, _window: &mut Window, _cx: &mut Context, ) -> Task> { // The preview is not the owner of the source editor's buffer, so force-closing it should not discard editor changes. Task::ready(Ok(())) } fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) { match event { MarkdownPreviewEvent::SourceEditorChanged | MarkdownPreviewEvent::SourceFileHandleChanged => { f(workspace::item::ItemEvent::UpdateTab); f(workspace::item::ItemEvent::UpdateBreadcrumbs); } } } fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { ItemBufferKind::Singleton } fn as_searchable( &self, handle: &Entity, _: &App, ) -> Option> { Some(Box::new(handle.clone())) } } impl Render for MarkdownPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let preview_theme = self.resolve_preview_theme(cx); let bg_color = preview_theme .as_ref() .map(|theme| theme.colors().editor_background) .unwrap_or_else(|| cx.theme().colors().editor_background); let preview_font_size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx); let hovered_url = self.hovered_url.clone(); div() .image_cache(self.image_cache.clone()) .id("MarkdownPreview") .key_context("MarkdownPreview") .track_focus(&self.focus_handle(cx)) .on_hover(cx.listener(|view, hovered, _window, cx| { if !hovered && view.hovered_url.take().is_some() { cx.notify(); } })) .on_action(cx.listener(MarkdownPreviewView::scroll_page_up)) .on_action(cx.listener(MarkdownPreviewView::scroll_page_down)) .on_action(cx.listener(MarkdownPreviewView::scroll_up)) .on_action(cx.listener(MarkdownPreviewView::scroll_down)) .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item)) .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item)) .on_action(cx.listener(MarkdownPreviewView::scroll_to_top)) .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom)) .on_action(cx.listener(MarkdownPreviewView::close_and_return_to_editor)) .on_action(cx.listener(MarkdownPreviewView::increase_font_size)) .on_action(cx.listener(MarkdownPreviewView::decrease_font_size)) .on_action(cx.listener(MarkdownPreviewView::reset_font_size)) .w_full() .flex_1() .min_h_0() .relative() .bg(bg_color) .child( WithRemSize::new(preview_font_size).size_full().child( div() .id("markdown-preview-scroll-container") .size_full() .overflow_y_scroll() .track_scroll(&self.scroll_handle) .p_4() .child({ let markdown_element = self.render_markdown_element(&preview_theme, window, cx); let markdown = self.markdown.clone(); let max_width = MarkdownPreviewSettings::get_global(cx).max_width; let content = right_click_menu("markdown-preview-context-menu") .trigger(move |_, _, _| markdown_element) .maybe_menu(move |window, cx| { let focus = window.focused(cx); let markdown = markdown.read(cx); let context_menu_link = markdown.context_menu_link().cloned(); let selected_text = markdown.context_menu_selected_text().cloned(); let selected_markdown = markdown.context_menu_selected_markdown().cloned(); if context_menu_link.is_none() && selected_text.is_none() && selected_markdown.is_none() { return None; } Some(ContextMenu::build(window, cx, move |menu, _, _cx| { menu.when_some(focus, |menu, focus| menu.context(focus)) .when_some(selected_text, |menu, text| { menu.entry( "Copy", Some(Box::new(markdown::Copy)), move |_, cx| { cx.write_to_clipboard( ClipboardItem::new_string( text.to_string(), ), ); }, ) }) .when_some(selected_markdown, |menu, text| { menu.entry( "Copy as Markdown", Some(Box::new(markdown::CopyAsMarkdown)), move |_, cx| { cx.write_to_clipboard( ClipboardItem::new_string( text.to_string(), ), ); }, ) }) .when_some(context_menu_link, |menu, url| { menu.entry("Copy Link", None, move |_, cx| { cx.write_to_clipboard( ClipboardItem::new_string(url.to_string()), ); }) }) })) }); div() .w_full() .when_some(max_width, |this, max_width| { this.max_w(max_width).mx_auto() }) .child(content) }), ), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx) .when_some(hovered_url, |this, hovered_url| { this.child( div() .absolute() .bottom_2() .left_0() .max_w_full() .overflow_hidden() .child(LinkPreview::new(hovered_url.as_ref(), cx)), ) }) } } impl SearchableItem for MarkdownPreviewView { type Match = Range; fn supported_options(&self) -> SearchOptions { SearchOptions { case: true, word: true, regex: true, replacement: false, selection: false, select_all: false, find_in_results: false, } } fn get_matches(&self, _window: &mut Window, cx: &mut App) -> (Vec, SearchToken) { ( self.markdown.read(cx).search_highlights().to_vec(), SearchToken::default(), ) } fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context) { let had_highlights = !self.markdown.read(cx).search_highlights().is_empty(); self.markdown.update(cx, |markdown, cx| { markdown.clear_search_highlights(cx); }); if had_highlights { cx.emit(SearchEvent::MatchesInvalidated); } } fn update_matches( &mut self, matches: &[Self::Match], active_match_index: Option, _token: SearchToken, _window: &mut Window, cx: &mut Context, ) { debug_assert!( matches .windows(2) .all(|ranges| (ranges[0].start, ranges[0].end) <= (ranges[1].start, ranges[1].end)) ); let old_highlights = self.markdown.read(cx).search_highlights(); let changed = old_highlights != matches; self.markdown.update(cx, |markdown, cx| { markdown.set_search_highlights(matches.to_vec(), active_match_index, cx); }); if changed { cx.emit(SearchEvent::MatchesInvalidated); } } fn query_suggestion( &mut self, _seed_query_override: Option, _window: &mut Window, cx: &mut Context, ) -> String { self.markdown .read(cx) .selected_source() .unwrap_or_default() .to_string() } fn activate_match( &mut self, index: usize, matches: &[Self::Match], _token: SearchToken, _window: &mut Window, cx: &mut Context, ) { if let Some(match_range) = matches.get(index) { let start = match_range.start; self.markdown.update(cx, |markdown, cx| { markdown.set_active_search_highlight(Some(index), cx); markdown.request_autoscroll_to_source_index(start, cx); }); } } fn select_matches( &mut self, _matches: &[Self::Match], _token: SearchToken, _window: &mut Window, _cx: &mut Context, ) { } fn replace( &mut self, _: &Self::Match, _: &SearchQuery, _token: SearchToken, _window: &mut Window, _: &mut Context, ) { } fn find_matches( &mut self, query: Arc, _window: &mut Window, cx: &mut Context, ) -> Task> { let source = self.markdown.read(cx).source().to_string(); cx.background_spawn(async move { query.search_str(&source) }) } fn active_match_index( &mut self, direction: Direction, matches: &[Self::Match], _token: SearchToken, _window: &mut Window, cx: &mut Context, ) -> Option { if matches.is_empty() { return None; } let markdown = self.markdown.read(cx); let current_source_index = markdown .active_search_highlight() .and_then(|i| markdown.search_highlights().get(i)) .map(|m| m.start) .or(self.active_source_index) .unwrap_or(0); match direction { Direction::Next => matches .iter() .position(|m| m.start >= current_source_index) .or(Some(0)), Direction::Prev => matches .iter() .rposition(|m| m.start <= current_source_index) .or(Some(matches.len().saturating_sub(1))), } } } impl SerializableItem for MarkdownPreviewView { fn serialized_item_kind() -> &'static str { "MarkdownPreviewView" } fn deserialize( project: Entity, workspace: WeakEntity, workspace_id: WorkspaceId, item_id: ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { let db = persistence::MarkdownPreviewDb::global(cx); window.spawn(cx, async move |cx| { let (abs_path, mode_value) = db .get_preview(item_id, workspace_id)? .context("No markdown preview entry found")?; let mode = MarkdownPreviewMode::from_db(mode_value); let (worktree, relative_path) = project .update(cx, |project, cx| { project.find_or_create_worktree(abs_path.clone(), false, cx) }) .await .context("Path not found")?; let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); let project_path = ProjectPath { worktree_id, path: relative_path, }; let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx)) .await?; cx.update(|window, cx| { let language_registry = project.read(cx).languages().clone(); let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)); MarkdownPreviewView::new(mode, editor, workspace, language_registry, window, cx) }) }) } fn cleanup( workspace_id: WorkspaceId, alive_items: Vec, _window: &mut Window, cx: &mut App, ) -> Task> { let db = persistence::MarkdownPreviewDb::global(cx); delete_unloaded_items(alive_items, workspace_id, "markdown_previews", &db, cx) } fn serialize( &mut self, workspace: &mut Workspace, item_id: ItemId, _closing: bool, _window: &mut Window, cx: &mut Context, ) -> Option>> { let workspace_id = workspace.database_id()?; let editor = self.active_editor.as_ref()?.editor.clone(); let buffer = editor.read(cx).buffer().read(cx).as_singleton()?; let file = buffer.read(cx).file()?; let worktree_id = file.worktree_id(cx); let abs_path = workspace .project() .read(cx) .worktree_for_id(worktree_id, cx)? .read(cx) .absolutize(file.path()); let mode = self.mode.to_db(); let db = persistence::MarkdownPreviewDb::global(cx); Some(cx.background_spawn(async move { db.save_preview(item_id, workspace_id, abs_path, mode).await })) } fn should_serialize(&self, event: &Self::Event) -> bool { matches!( event, MarkdownPreviewEvent::SourceEditorChanged | MarkdownPreviewEvent::SourceFileHandleChanged ) } } mod persistence { use std::path::PathBuf; use db::{ query, sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; pub struct MarkdownPreviewDb(ThreadSafeConnection); impl Domain for MarkdownPreviewDb { const NAME: &str = stringify!(MarkdownPreviewDb); const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE markdown_previews ( workspace_id INTEGER, item_id INTEGER, abs_path BLOB, mode INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(workspace_id, item_id), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; )]; } db::static_connection!(MarkdownPreviewDb, [WorkspaceDb]); impl MarkdownPreviewDb { query! { pub async fn save_preview( item_id: ItemId, workspace_id: WorkspaceId, abs_path: PathBuf, mode: i64 ) -> Result<()> { INSERT OR REPLACE INTO markdown_previews(item_id, workspace_id, abs_path, mode) VALUES (?, ?, ?, ?) } } query! { pub fn get_preview(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { SELECT abs_path, mode FROM markdown_previews WHERE item_id = ? AND workspace_id = ? } } } } #[cfg(test)] mod tests { use crate::CloseAndReturnToEditor; use crate::markdown_preview_view::ImageSource; use crate::markdown_preview_view::Resource; use crate::markdown_preview_view::resolve_preview_image; use buffer_diff::BufferDiff; use editor::Editor; use fs::FakeFs; use gpui::{ AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, }; use language::{Buffer, DiskState, Point}; use project::Project; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use util::path; use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; use workspace::item::SerializableItem; use workspace::{ AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, }; use super::{MarkdownPreviewView, open_preview_url}; #[test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() { let tree = TempTree::new(json!({ "docs": {}, "test_image.png": "mock data" })); let workspace_directory = tree.path(); let base_directory = markdown_fixture_directory(&tree); let image_file = workspace_directory.join("test_image.png"); for workspace_root_relative_path in ["/test_image.png", "\\test_image.png"] { let resolved = resolve_preview_image( workspace_root_relative_path, Some(&base_directory), Some(workspace_directory), ); assert_resolved_preview_image_path(resolved, image_file.as_path()); } let missing = resolve_preview_image( "/missing_image.png", Some(&base_directory), Some(workspace_directory), ); assert!(missing.is_none()); } #[gpui::test] async fn opens_preview_file_links_at_positions(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "docs": {"guide.txt": "alpha\nbeta\ngamma\n"}, "src": {"main.rs": "first\nsecond\nthird\n"} }), ) .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; register_markdown_language(&project, cx); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); let workspace_weak = workspace.downgrade(); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "../src/main.rs:2:4".into(), None, Some(PathBuf::from(path!("/project/docs"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); let editor = workspace.read_with(cx, |workspace, cx| { workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("file should be open in an editor") }); editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(1, 3) ); }); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "../src/main.rs".into(), Some("L3".into()), Some(PathBuf::from(path!("/project/docs"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(2, 0) ); }); // `guide.txt:2` must open the sibling file at line 2 despite `guide.txt:` being a valid URI scheme prefix. multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "guide.txt:2".into(), None, Some(PathBuf::from(path!("/project/docs"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); let guide_editor = workspace.read_with(cx, |workspace, cx| { workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("guide.txt should be open in an editor") }); guide_editor.update_in(cx, |editor, window, cx| { let file_path = editor .buffer() .read(cx) .as_singleton() .and_then(|buffer| buffer.read(cx).file().map(|file| file.path().clone())) .expect("opened editor should have a file"); assert_eq!(file_path.as_ref(), rel_path("docs/guide.txt")); let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(1, 0) ); }); // An unresolved link must not open an unrelated file that merely shares its trailing path components. multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "main.rs:2".into(), None, Some(PathBuf::from(path!("/project/docs"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); assert_eq!(cx.opened_url().as_deref(), Some("main.rs:2")); editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(2, 0), "broken link must not move the cursor in a suffix-matching file" ); }); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "tel:123".into(), None, Some(PathBuf::from(path!("/project/docs"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); assert_eq!(cx.opened_url().as_deref(), Some("tel:123")); } #[gpui::test] async fn opens_markdown_links_in_preview(cx: &mut TestAppContext) { init_test(cx); let mut notes = String::from("# Notes\n\nintro\n\n"); for _ in 0..100 { notes.push_str("filler line\n\n"); } notes.push_str("## Target Section\n\nfound it\n"); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "notes.md": notes, "other.md": "# Other\n\nshort\n", }), ) .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; register_markdown_language(&project, cx); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); let workspace_weak = workspace.downgrade(); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md".into(), Some("target-section".into()), Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); let preview = workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 0, "markdown links must not open raw editors" ); workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("notes.md should open in a Markdown preview") }); assert_eq!( preview_source_path(cx, &preview).as_ref(), rel_path("notes.md") ); let heading_scroll_offset = preview.read_with(cx, |preview, _| preview.scroll_handle.offset()); assert!( heading_scroll_offset.y < px(0.), "preview should scroll down to the linked heading, got {heading_scroll_offset:?}" ); let preview_editor = preview.read_with(cx, |preview, _| { preview.active_editor.as_ref().unwrap().editor.clone() }); preview_editor.update_in(cx, |editor, _, cx| { let end = editor.buffer().read(cx).snapshot(cx).len(); editor.edit([(end..end, "\nappended\n")], cx); }); cx.executor().advance_clock(Duration::from_millis(300)); cx.run_until_parked(); assert_eq!( preview.read_with(cx, |preview, _| preview.scroll_handle.offset()), heading_scroll_offset, "reparsing after an edit must not replay the consumed heading scroll" ); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md".into(), None, Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 1 ); assert_eq!( workspace.active_item(cx).map(|item| item.item_id()), Some(preview.entity_id()) ); }); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md:105:1".into(), None, Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 0, "a position on a markdown link must not open a raw editor" ); assert_eq!( workspace.active_item(cx).map(|item| item.item_id()), Some(preview.entity_id()) ); }); preview_editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(104, 0) ); }); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md".into(), Some("L3".into()), Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 0, "an `#L` fragment on a markdown link must not open a raw editor" ); assert_eq!( workspace.active_item(cx).map(|item| item.item_id()), Some(preview.entity_id()) ); }); preview_editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(2, 0) ); }); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "other.md".into(), Some("missing-heading".into()), Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); let other_preview = workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 2 ); workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("other.md should open in a Markdown preview") }); assert_eq!( preview_source_path(cx, &other_preview).as_ref(), rel_path("other.md") ); assert_eq!( other_preview .read_with(cx, |preview, _| preview.scroll_handle.offset()) .y, px(0.), "a dead anchor must open the document at the top" ); cx.simulate_modifiers_change(Modifiers::alt()); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md:5:3".into(), None, Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); cx.simulate_modifiers_change(Modifiers::default()); let raw_editor = workspace.read_with(cx, |workspace, cx| { workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("alt-click should open the raw editor") }); raw_editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(4, 2) ); }); preview.read_with(cx, |preview, _| { assert_eq!( preview .active_editor .as_ref() .map(|state| state.editor.clone()), Some(raw_editor.clone()) ); }); } #[gpui::test] async fn scrolls_position_link_in_newly_created_preview(cx: &mut TestAppContext) { init_test(cx); let mut notes = String::from("# Notes\n\nintro\n\n"); for _ in 0..100 { notes.push_str("filler line\n\n"); } notes.push_str("## Target Section\n\nfound it\n"); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/project"), json!({ "notes.md": notes })) .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; register_markdown_language(&project, cx); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); let workspace_weak = workspace.downgrade(); multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( "notes.md:180:1".into(), None, Some(PathBuf::from(path!("/project"))), None, &workspace_weak, window, cx, ); }); cx.run_until_parked(); let preview = workspace.read_with(cx, |workspace, cx| { assert_eq!( workspace.items_of_type::(cx).count(), 0, "a position on a markdown link must not open a raw editor" ); workspace .active_item(cx) .and_then(|item| item.downcast::()) .expect("notes.md should open in a Markdown preview") }); assert_eq!( preview_source_path(cx, &preview).as_ref(), rel_path("notes.md") ); let preview_editor = preview.read_with(cx, |preview, _| { preview.active_editor.as_ref().unwrap().editor.clone() }); preview_editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), Point::new(179, 0) ); }); let scroll_offset = preview.read_with(cx, |preview, _| preview.scroll_handle.offset()); assert!( scroll_offset.y < px(0.), "a new preview should scroll down to the linked position, got {scroll_offset:?}" ); } #[gpui::test] async fn toggles_task_checkbox_and_saves_when_preview_is_active(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "todo.md": "- [ ] Finish work\n" }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/todo.md"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let preview = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); let editor: Entity = workspace .read(cx) .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); workspace.update(cx, |workspace, cx| { let preview = MarkdownPreviewView::create_markdown_view( workspace, editor.clone(), window, cx, ); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); preview }) }) .unwrap(); cx.run_until_parked(); let save_task = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace: Entity = multi_workspace.workspace().clone(); let view_handle = preview.downgrade(); assert!(preview.read(cx).focus_handle.contains_focused(window, cx)); preview.update(cx, |preview, cx| { let editor = preview.active_editor.as_ref().unwrap().editor.clone(); MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx); }); MarkdownPreviewView::refresh_preview(view_handle, window, cx); workspace.update(cx, |workspace: &mut Workspace, cx| { workspace.save_active_item(SaveIntent::Save, window, cx) }) }) .unwrap(); save_task.await.unwrap(); cx.run_until_parked(); assert_eq!( app_state .fs .load(path!("/dir/todo.md").as_ref()) .await .unwrap(), "- [x] Finish work\n" ); } #[gpui::test] async fn preview_uses_buffer_contents_instead_of_diff_contents(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "note.md": "new\n" }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/note.md"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let preview = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { let editor: Entity = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); let diff = cx.new(|cx| { BufferDiff::new_with_base_text( "old\n", &buffer.read(cx).text_snapshot(), cx, ) }); let multibuffer = editor.read(cx).buffer().clone(); multibuffer.update(cx, |multibuffer, cx| { multibuffer.add_diff(diff, cx); multibuffer.set_all_diff_hunks_expanded(cx); }); let diff_text = multibuffer.read(cx).snapshot(cx).text(); assert!(diff_text.contains("old")); assert!(diff_text.contains("new")); let preview = MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); preview }) }) .unwrap(); cx.run_until_parked(); assert_eq!( preview.read_with(cx, |preview, cx| preview .markdown .read(cx) .source() .to_string()), "new\n" ); } #[gpui::test] async fn force_closing_preview_preserves_source_editor_changes(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "todo.md": "- [ ] Finish work\n" }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/todo.md"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let (preview, editor) = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); let editor: Entity = workspace .read(cx) .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); let preview = workspace.update(cx, |workspace, cx| { let preview = MarkdownPreviewView::create_markdown_view( workspace, editor.clone(), window, cx, ); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); preview }); (preview, editor) }) .unwrap(); cx.run_until_parked(); multi_workspace .update(cx, |_, window, cx| { let view_handle = preview.downgrade(); assert!(preview.read(cx).focus_handle.contains_focused(window, cx)); MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx); MarkdownPreviewView::refresh_preview(view_handle, window, cx); }) .unwrap(); assert_eq!( editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), "- [x] Finish work\n" ); let close_task = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.active_pane().update(cx, |pane, cx| { pane.close_item_by_id(preview.entity_id(), SaveIntent::Skip, window, cx) }) }) }) .unwrap(); close_task.await.unwrap(); cx.run_until_parked(); assert_eq!( editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), "- [x] Finish work\n" ); } #[gpui::test] async fn close_and_return_to_editor_closes_preview_and_focuses_source_editor( cx: &mut TestAppContext, ) { let (multi_workspace, editor) = open_markdown_file(cx, "note.md", "# Note\n\nBody text\n").await; let preview = open_preview_for_active_editor(cx, &multi_workspace); cx.run_until_parked(); dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); cx.run_until_parked(); assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); assert_no_markdown_preview_items(cx, &multi_workspace); } #[gpui::test] async fn close_and_return_to_editor_reopens_source_editor_when_editor_tab_was_closed( cx: &mut TestAppContext, ) { let (multi_workspace, editor) = open_markdown_file(cx, "note.md", "# Note\n").await; let preview = open_preview_for_active_editor(cx, &multi_workspace); cx.run_until_parked(); let close_editor_task = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.active_pane().update(cx, |pane, cx| { pane.close_item_by_id(editor.entity_id(), SaveIntent::Skip, window, cx) }) }) }) .unwrap(); close_editor_task.await.unwrap(); cx.run_until_parked(); multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().read(cx); assert!( preview.read(cx).focus_handle.contains_focused(window, cx), "preview should remain focused after closing the source editor tab" ); assert!( workspace .items_of_type::(cx) .all(|open_editor| open_editor != editor), "source editor should no longer be open in any pane" ); assert_eq!( workspace.items_of_type::(cx).count(), 1 ); }) .unwrap(); dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); cx.run_until_parked(); assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); assert_no_markdown_preview_items(cx, &multi_workspace); } #[gpui::test] async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "todo.md": "![image](image.png)\n", "subdir": {}, }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let open_task = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { let worktree_id = workspace .project() .read(cx) .worktrees(cx) .next() .unwrap() .read(cx) .id(); workspace.open_path((worktree_id, rel_path("todo.md")), None, true, window, cx) }) }) .unwrap(); open_task.await.unwrap(); cx.run_until_parked(); let (preview, project, workspace_id) = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { workspace.set_random_database_id(); let workspace_id = workspace.database_id().unwrap(); let project = workspace.project().clone(); let editor: Entity = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); let preview = MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); (preview, project, workspace_id) }) }) .unwrap(); let workspace_serialization_tasks = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.flush_all_serialization(window, cx) }) .unwrap(); for task in workspace_serialization_tasks { task.await; } let serialize_task = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { preview .update(cx, |preview, cx| { preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx) }) .unwrap() }) }) .unwrap(); serialize_task.await.unwrap(); assert_eq!( saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), PathBuf::from(path!("/dir/todo.md")) ); let (entry_id, worktree_id, destination_path) = preview.read_with(cx, |preview, cx| { let editor = &preview.active_editor.as_ref().unwrap().editor; let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); let buffer = buffer.read(cx); let file = buffer.file().unwrap(); let worktree_id = file.worktree_id(cx); let source_path = file.path(); let mut destination_path = source_path.to_rel_path_buf(); destination_path.pop(); destination_path.push(rel_path("subdir/renamed.md")); let worktree = project.read(cx).worktree_for_id(worktree_id, cx).unwrap(); let entry_id = worktree.read(cx).entry_for_path(source_path).unwrap().id; ( entry_id, worktree_id, destination_path.as_rel_path().into_arc(), ) }); project .update(cx, |project, cx| { project.rename_entry(entry_id, (worktree_id, destination_path).into(), cx) }) .await .unwrap(); wait_for_preview_serialization(cx).await; assert_eq!( preview.read_with(cx, |preview, _| preview.base_directory.clone()), Some(PathBuf::from(path!("/dir/subdir"))) ); assert_eq!( saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), PathBuf::from(path!("/dir/subdir/renamed.md")) ); } #[gpui::test] async fn follow_preview_serialized_path_updates_when_followed_editor_changes( cx: &mut TestAppContext, ) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "a.md": "# A\n", "b.md": "# B\n", }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let worktree_id = multi_workspace .update(cx, |multi_workspace, _, cx| { multi_workspace .workspace() .read(cx) .project() .read(cx) .worktrees(cx) .next() .unwrap() .read(cx) .id() }) .unwrap(); let open_task = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.open_path((worktree_id, rel_path("a.md")), None, true, window, cx) }) }) .unwrap(); let opened_item = open_task.await.unwrap(); cx.run_until_parked(); let editor_a = cx.update(|cx| opened_item.act_as::(cx).unwrap()); let open_task = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.open_path((worktree_id, rel_path("b.md")), None, true, window, cx) }) }) .unwrap(); let opened_item = open_task.await.unwrap(); cx.run_until_parked(); let editor_b = cx.update(|cx| opened_item.act_as::(cx).unwrap()); let editor_b_path = editor_source_path(cx, &editor_b); assert_eq!(editor_b_path.as_ref(), rel_path("b.md")); let (preview, workspace_id) = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { workspace.set_random_database_id(); let workspace_id = workspace.database_id().unwrap(); let preview = MarkdownPreviewView::create_following_markdown_view( workspace, editor_a, window, cx, ); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); (preview, workspace_id) }) }) .unwrap(); let workspace_serialization_tasks = multi_workspace .update(cx, |multi_workspace, window, cx| { multi_workspace.flush_all_serialization(window, cx) }) .unwrap(); for task in workspace_serialization_tasks { task.await; } wait_for_preview_serialization(cx).await; let serialize_task = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { preview .update(cx, |preview, cx| { preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx) }) .unwrap() }) }) .unwrap(); serialize_task.await.unwrap(); assert_eq!( saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), PathBuf::from(path!("/dir/a.md")) ); multi_workspace .update(cx, |_, window, cx| { preview.update(cx, |preview, cx| { preview.set_editor(editor_b, window, cx); }); }) .unwrap(); wait_for_preview_serialization(cx).await; let followed_path = preview_source_path(cx, &preview); assert_eq!(followed_path.as_ref(), rel_path("b.md")); assert_eq!( saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), PathBuf::from(path!("/dir/b.md")), "a Follow preview should persist the source editor it most recently followed" ); } #[gpui::test] async fn default_preview_stays_bound_to_invoking_editor_across_splits(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "todo.md": "- [ ] Finish work\n" }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/todo.md"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let (preview, second_editor) = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { let first_editor: Entity = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); let buffer = first_editor .read(cx) .buffer() .read(cx) .as_singleton() .unwrap(); let project = workspace.project().clone(); let second_editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); let new_pane = workspace.split_pane( workspace.active_pane().clone(), workspace::SplitDirection::Right, window, cx, ); new_pane.update(cx, |pane, cx| { pane.add_item( Box::new(second_editor.clone()), true, true, None, window, cx, ) }); let preview = MarkdownPreviewView::create_markdown_view( workspace, second_editor.clone(), window, cx, ); new_pane.update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); (preview, second_editor) }) }) .unwrap(); cx.run_until_parked(); let bound_editor = preview.read_with(cx, |preview, _| { preview.active_editor.as_ref().unwrap().editor.clone() }); assert_eq!( bound_editor, second_editor, "a Default preview must stay bound to the editor it was opened from, not another \ editor that happens to share the same buffer in a different split" ); } async fn open_markdown_file( cx: &mut TestAppContext, file_name: &str, contents: &str, ) -> (WindowHandle, Entity) { let app_state = init_test(cx); let mut entries = serde_json::Map::new(); entries.insert(file_name.to_string(), json!(contents)); app_state .fs .as_fake() .insert_tree(path!("/dir"), serde_json::Value::Object(entries)) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir")).join(file_name)], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let editor = multi_workspace .update(cx, |multi_workspace, _, cx| { multi_workspace .workspace() .read(cx) .active_item_as::(cx) .unwrap() }) .unwrap(); (multi_workspace, editor) } fn open_preview_for_active_editor( cx: &mut TestAppContext, multi_workspace: &WindowHandle, ) -> Entity { multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { let editor: Entity = workspace.active_item_as(cx).unwrap(); let preview = MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) }); preview }) }) .unwrap() } fn dispatch_close_and_return_to_editor( cx: &mut TestAppContext, multi_workspace: &WindowHandle, preview: &Entity, ) { multi_workspace .update(cx, |_, window, cx| { assert!( preview.read(cx).focus_handle.contains_focused(window, cx), "preview must be focused for the keyboard action to dispatch to it" ); window.dispatch_action(Box::new(CloseAndReturnToEditor), cx); }) .unwrap(); } fn assert_editor_is_active_and_focused( cx: &mut TestAppContext, multi_workspace: &WindowHandle, expected_editor: &Entity, ) { multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().read(cx); let active_editor = workspace.active_item_as::(cx).unwrap(); assert_eq!(active_editor, *expected_editor); assert!( expected_editor .read(cx) .focus_handle(cx) .contains_focused(window, cx), "source editor should be focused" ); }) .unwrap(); } fn assert_no_markdown_preview_items( cx: &mut TestAppContext, multi_workspace: &WindowHandle, ) { multi_workspace .update(cx, |multi_workspace, _, cx| { assert_eq!( multi_workspace .workspace() .read(cx) .items_of_type::(cx) .count(), 0 ); }) .unwrap(); } #[gpui::test] async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "a.md": "# A\n", "b.md": "# B\n" }), ) .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/a.md"))], app_state.clone(), workspace::OpenOptions::default(), cx, ) }) .await .unwrap(); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let workspace = multi_workspace .update(cx, |multi_workspace, _, _| { multi_workspace.workspace().clone() }) .unwrap(); let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); let b_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/b.md"), cx) }) .await .unwrap(); let (first_pane, a_editor, second_pane, b_editor) = multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { let first_pane = workspace.active_pane().clone(); let a_editor: Entity = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) .unwrap(); let project = workspace.project().clone(); let second_pane = workspace.split_pane( first_pane.clone(), workspace::SplitDirection::Right, window, cx, ); let b_editor = cx.new(|cx| Editor::for_buffer(b_buffer, Some(project), window, cx)); second_pane.update(cx, |pane, cx| { pane.add_item(Box::new(b_editor.clone()), true, true, None, window, cx) }); (first_pane, a_editor, second_pane, b_editor) }) }) .unwrap(); cx.run_until_parked(); // With focus in the second pane (`b.md`), simulate clicking the // preview button in the first pane's toolbar, which targets that // pane's own editor (`a.md`). multi_workspace .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { assert_eq!( workspace.active_pane(), &second_pane, "test precondition: focus must be in the second pane" ); MarkdownPreviewView::open_preview_in_pane( workspace, a_editor.clone(), first_pane.clone(), window, cx, ); }) }) .unwrap(); cx.run_until_parked(); cx.update(|cx| { let preview = first_pane .read(cx) .active_item() .and_then(|item| item.downcast::()) .expect("the preview must open in the pane whose button was clicked"); let bound_editor = preview .read(cx) .active_editor .as_ref() .unwrap() .editor .clone(); assert_eq!( bound_editor, a_editor, "the preview must be bound to the clicked pane's editor, not the focused editor" ); assert_eq!( second_pane .read(cx) .active_item() .and_then(|item| item.downcast::()), Some(b_editor), "the focused pane's content must be unaffected" ); }); } #[gpui::test] async fn derives_remote_preview_source_directory(cx: &mut TestAppContext) { init_test(cx); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; let worktree = project.update(cx, |project, cx| { project.add_test_remote_worktree("/remote/project", cx) }); let file: Arc = Arc::new(project::File { worktree, path: rel_path("docs/readme.md").into(), disk_state: DiskState::New, entry_id: None, is_local: false, is_private: false, }); let buffer = cx.new(|cx| { let mut buffer = Buffer::local("# readme\n", cx); buffer.file_updated(file, cx); buffer }); let (editor, cx) = cx.add_window_view(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); let folder = editor.read_with(cx, |editor, cx| { MarkdownPreviewView::get_folder_for_active_editor(editor, cx) }); assert_eq!(folder, Some(PathBuf::from("/remote/project/docs"))); } fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); editor::init(cx); crate::init(cx); state }) } fn register_markdown_language(project: &Entity, cx: &mut TestAppContext) { let languages = project.read_with(cx, |project, _| project.languages().clone()); languages.add(Arc::new(language::Language::new( language::LanguageConfig { name: "Markdown".into(), matcher: Arc::new(language::LanguageMatcher { path_suffixes: vec!["md".to_string(), "markdown".to_string()], ..Default::default() }), ..Default::default() }, None, ))); } async fn wait_for_preview_serialization(cx: &mut TestAppContext) { cx.run_until_parked(); cx.executor().advance_clock(Duration::from_millis(250)); cx.run_until_parked(); } fn saved_preview_path( cx: &mut TestAppContext, item_id: ItemId, workspace_id: WorkspaceId, ) -> PathBuf { cx.update(|cx| { super::persistence::MarkdownPreviewDb::global(cx) .get_preview(item_id, workspace_id) .unwrap() .unwrap() .0 }) } fn preview_source_path( cx: &mut TestAppContext, preview: &Entity, ) -> Arc { let editor = preview.read_with(cx, |preview, _| { preview.active_editor.as_ref().unwrap().editor.clone() }); editor_source_path(cx, &editor) } fn editor_source_path(cx: &mut TestAppContext, editor: &Entity) -> Arc { editor.read_with(cx, |editor, cx| { let buffer = editor.buffer().read(cx).as_singleton().unwrap(); buffer.read(cx).file().unwrap().path().clone() }) } fn markdown_fixture_directory(tree: &TempTree) -> PathBuf { tree.path().join("docs") } #[track_caller] fn assert_resolved_preview_image_path( resolved: Option, expected_path: &std::path::Path, ) { match resolved { Some(ImageSource::Resource(Resource::Path(path))) => { assert_eq!(path.as_ref(), expected_path); } _ => panic!("Expected preview image to resolve to a local path"), } } }