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