markdown_preview_view.rs

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