markdown_preview_view.rs

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