markdown_preview: Add search support to markdown preview (#52502)

Ahmet Kaan Gümüş and Conrad Irwin created

Context

The markdown preview had no search functionality β€” pressing Ctrl+F did
nothing. This PR implements the SearchableItem trait for
MarkdownPreviewView, enabling in-pane text search with match
highlighting and navigation.

  Changes span four crates:

- project: Added SearchQuery::search_str() β€” a synchronous method to
search plain &str text, since the existing search() only works on
BufferSnapshot.
- markdown: Added search highlight storage to the Markdown entity and
paint_search_highlights to MarkdownElement. Extracted the existing
selection painting into a reusable paint_highlight_range helper to avoid
duplicating quad-painting logic.
- markdown_preview: Implemented SearchableItem with full match
navigation, active match tracking, and proper SearchEvent emission
matching Editor behavior.
- Keymaps: Added buffer_search::Deploy bindings to the MarkdownPreview
context on all three platforms.
  
The PR hopefully Closes
https://github.com/zed-industries/zed/issues/27154

  How to Review

1. crates/project/src/search.rs β€” search_str method at the end of impl
SearchQuery. Handles both Text (AhoCorasick) and Regex variants with
whole-word and multiline support.
  2. crates/markdown/src/markdown.rs β€” Three areas:
    - New fields and methods on Markdown struct (~line 264, 512-548)
- paint_highlight_range extraction and paint_search_highlights (~line
1059-1170)
    - The single-line addition in Element::paint (~line 2003)
3. crates/markdown_preview/src/markdown_preview_view.rs β€” The main
change. Focus on:
- SearchEvent::MatchesInvalidated emission in schedule_markdown_update
(line 384)
    - EventEmitter<SearchEvent> and as_searchable (lines 723, 748-754)
- The SearchableItem impl (lines 779-927), especially active_match_index
which computes position from old highlights to handle query changes
correctly
  4. Keymap files β€” Two lines each for Linux/Windows, one for macOS.

  Self-Review Checklist

- [ x ] I've reviewed my own diff for quality, security, and reliability
- [ x ] Unsafe blocks (if any) have justifying comments (no unsafe)
- [ x ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
(should be :smile: )
- [ - ] Tests cover the new/changed behavior (not sure)
- [ - ] Performance impact has been considered and is acceptable (I'm
not sure about it and it would be nice to see experienced people to
test)


Release Notes:
- Added search support (Ctrl+F / Cmd+F) to the markdown preview

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

Cargo.lock                                           |   1 
assets/keymaps/default-linux.json                    |   2 
assets/keymaps/default-macos.json                    |   1 
assets/keymaps/default-windows.json                  |   2 
assets/keymaps/vim.json                              |   1 
crates/debugger_tools/src/dap_log.rs                 |   1 
crates/editor/src/items.rs                           |   2 
crates/language_tools/src/lsp_log_view.rs            |   1 
crates/markdown/src/markdown.rs                      | 109 ++++++++
crates/markdown_preview/Cargo.toml                   |   1 
crates/markdown_preview/src/markdown_preview_view.rs | 155 +++++++++++++
crates/project/src/search.rs                         |  52 ++++
crates/search/src/buffer_search.rs                   |  19 
crates/terminal_view/src/terminal_view.rs            |   1 
crates/workspace/src/searchable.rs                   |   2 
15 files changed, 330 insertions(+), 20 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -10159,6 +10159,7 @@ dependencies = [
  "language",
  "log",
  "markdown",
+ "project",
  "settings",
  "tempfile",
  "theme_settings",

assets/keymaps/default-linux.json πŸ”—

@@ -1275,6 +1275,8 @@
       "alt-down": "markdown::ScrollDownByItem",
       "ctrl-home": "markdown::ScrollToTop",
       "ctrl-end": "markdown::ScrollToBottom",
+      "find": "buffer_search::Deploy",
+      "ctrl-f": "buffer_search::Deploy",
     },
   },
   {

assets/keymaps/default-macos.json πŸ”—

@@ -1375,6 +1375,7 @@
       "alt-down": "markdown::ScrollDownByItem",
       "cmd-up": "markdown::ScrollToTop",
       "cmd-down": "markdown::ScrollToBottom",
+      "cmd-f": "buffer_search::Deploy",
     },
   },
   {

assets/keymaps/default-windows.json πŸ”—

@@ -1300,6 +1300,8 @@
       "alt-down": "markdown::ScrollDownByItem",
       "ctrl-home": "markdown::ScrollToTop",
       "ctrl-end": "markdown::ScrollToBottom",
+      "find": "buffer_search::Deploy",
+      "ctrl-f": "buffer_search::Deploy",
     },
   },
   {

assets/keymaps/vim.json πŸ”—

@@ -1096,6 +1096,7 @@
       "ctrl-e": "markdown::ScrollDown",
       "g g": "markdown::ScrollToTop",
       "shift-g": "markdown::ScrollToBottom",
+      "/": "buffer_search::Deploy",
     },
   },
   {

crates/editor/src/items.rs πŸ”—

@@ -1630,6 +1630,7 @@ impl SearchableItem for Editor {
                 regex: true,
                 replacement: false,
                 selection: false,
+                select_all: true,
                 find_in_results: true,
             }
         } else {
@@ -1639,6 +1640,7 @@ impl SearchableItem for Editor {
                 regex: true,
                 replacement: true,
                 selection: true,
+                select_all: true,
                 find_in_results: false,
             }
         }

crates/markdown/src/markdown.rs πŸ”—

@@ -263,6 +263,8 @@ pub struct Markdown {
     copied_code_blocks: HashSet<ElementId>,
     code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
     context_menu_selected_text: Option<String>,
+    search_highlights: Vec<Range<usize>>,
+    active_search_highlight: Option<usize>,
 }
 
 #[derive(Clone, Copy, Default)]
@@ -430,6 +432,8 @@ impl Markdown {
             copied_code_blocks: HashSet::default(),
             code_block_scroll_handles: BTreeMap::default(),
             context_menu_selected_text: None,
+            search_highlights: Vec::new(),
+            active_search_highlight: None,
         };
         this.parse(cx);
         this
@@ -541,6 +545,8 @@ impl Markdown {
         self.autoscroll_request = None;
         self.pending_parse = None;
         self.should_reparse = false;
+        self.search_highlights.clear();
+        self.active_search_highlight = None;
         // Don't clear parsed_markdown here - keep existing content visible until new parse completes
         self.parse(cx);
     }
@@ -576,6 +582,40 @@ impl Markdown {
         }
     }
 
+    pub fn set_search_highlights(
+        &mut self,
+        highlights: Vec<Range<usize>>,
+        active: Option<usize>,
+        cx: &mut Context<Self>,
+    ) {
+        self.search_highlights = highlights;
+        self.active_search_highlight = active;
+        cx.notify();
+    }
+
+    pub fn clear_search_highlights(&mut self, cx: &mut Context<Self>) {
+        if !self.search_highlights.is_empty() || self.active_search_highlight.is_some() {
+            self.search_highlights.clear();
+            self.active_search_highlight = None;
+            cx.notify();
+        }
+    }
+
+    pub fn set_active_search_highlight(&mut self, active: Option<usize>, cx: &mut Context<Self>) {
+        if self.active_search_highlight != active {
+            self.active_search_highlight = active;
+            cx.notify();
+        }
+    }
+
+    pub fn search_highlights(&self) -> &[Range<usize>] {
+        &self.search_highlights
+    }
+
+    pub fn active_search_highlight(&self) -> Option<usize> {
+        self.active_search_highlight
+    }
+
     fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
         if self.selection.end <= self.selection.start {
             return;
@@ -1084,18 +1124,18 @@ impl MarkdownElement {
         builder.pop_div();
     }
 
-    fn paint_selection(
-        &self,
+    fn paint_highlight_range(
         bounds: Bounds<Pixels>,
+        start: usize,
+        end: usize,
+        color: Hsla,
         rendered_text: &RenderedText,
         window: &mut Window,
-        cx: &mut App,
     ) {
-        let selection = self.markdown.read(cx).selection.clone();
-        let selection_start = rendered_text.position_for_source_index(selection.start);
-        let selection_end = rendered_text.position_for_source_index(selection.end);
+        let start_pos = rendered_text.position_for_source_index(start);
+        let end_pos = rendered_text.position_for_source_index(end);
         if let Some(((start_position, start_line_height), (end_position, end_line_height))) =
-            selection_start.zip(selection_end)
+            start_pos.zip(end_pos)
         {
             if start_position.y == end_position.y {
                 window.paint_quad(quad(
@@ -1104,7 +1144,7 @@ impl MarkdownElement {
                         point(end_position.x, end_position.y + end_line_height),
                     ),
                     Pixels::ZERO,
-                    self.style.selection_background_color,
+                    color,
                     Edges::default(),
                     Hsla::transparent_black(),
                     BorderStyle::default(),
@@ -1116,7 +1156,7 @@ impl MarkdownElement {
                         point(bounds.right(), start_position.y + start_line_height),
                     ),
                     Pixels::ZERO,
-                    self.style.selection_background_color,
+                    color,
                     Edges::default(),
                     Hsla::transparent_black(),
                     BorderStyle::default(),
@@ -1129,7 +1169,7 @@ impl MarkdownElement {
                             point(bounds.right(), end_position.y),
                         ),
                         Pixels::ZERO,
-                        self.style.selection_background_color,
+                        color,
                         Edges::default(),
                         Hsla::transparent_black(),
                         BorderStyle::default(),
@@ -1142,7 +1182,7 @@ impl MarkdownElement {
                         point(end_position.x, end_position.y + end_line_height),
                     ),
                     Pixels::ZERO,
-                    self.style.selection_background_color,
+                    color,
                     Edges::default(),
                     Hsla::transparent_black(),
                     BorderStyle::default(),
@@ -1151,6 +1191,52 @@ impl MarkdownElement {
         }
     }
 
+    fn paint_selection(
+        &self,
+        bounds: Bounds<Pixels>,
+        rendered_text: &RenderedText,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let selection = self.markdown.read(cx).selection.clone();
+        Self::paint_highlight_range(
+            bounds,
+            selection.start,
+            selection.end,
+            self.style.selection_background_color,
+            rendered_text,
+            window,
+        );
+    }
+
+    fn paint_search_highlights(
+        &self,
+        bounds: Bounds<Pixels>,
+        rendered_text: &RenderedText,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let markdown = self.markdown.read(cx);
+        let active_index = markdown.active_search_highlight;
+        let colors = cx.theme().colors();
+
+        for (i, highlight_range) in markdown.search_highlights.iter().enumerate() {
+            let color = if Some(i) == active_index {
+                colors.search_active_match_background
+            } else {
+                colors.search_match_background
+            };
+            Self::paint_highlight_range(
+                bounds,
+                highlight_range.start,
+                highlight_range.end,
+                color,
+                rendered_text,
+                window,
+            );
+        }
+    }
+
     fn paint_mouse_listeners(
         &mut self,
         hitbox: &Hitbox,
@@ -1955,6 +2041,7 @@ impl Element for MarkdownElement {
 
         self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx);
         rendered_markdown.element.paint(window, cx);
+        self.paint_search_highlights(bounds, &rendered_markdown.text, window, cx);
         self.paint_selection(bounds, &rendered_markdown.text, window, cx);
     }
 }

crates/markdown_preview/Cargo.toml πŸ”—

@@ -21,6 +21,7 @@ gpui.workspace = true
 language.workspace = true
 log.workspace = true
 markdown.workspace = true
+project.workspace = true
 settings.workspace = true
 theme_settings.workspace = true
 ui.workspace = true

crates/markdown_preview/src/markdown_preview_view.rs πŸ”—

@@ -1,4 +1,5 @@
 use std::cmp::min;
+use std::ops::Range;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::Duration;
@@ -16,11 +17,15 @@ use markdown::{
     CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont,
     MarkdownOptions, MarkdownStyle,
 };
+use project::search::SearchQuery;
 use settings::Settings;
 use theme_settings::ThemeSettings;
 use ui::{WithScrollbar, prelude::*};
 use util::normalize_path;
-use workspace::item::{Item, ItemHandle};
+use workspace::item::{Item, ItemBufferKind, ItemHandle};
+use workspace::searchable::{
+    Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
+};
 use workspace::{OpenOptions, OpenVisible, Pane, Workspace};
 
 use crate::{
@@ -382,6 +387,7 @@ impl MarkdownPreviewView {
                         markdown.reset(contents, cx);
                     });
                     view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx);
+                    cx.emit(SearchEvent::MatchesInvalidated);
                 }
                 view.pending_update_task = None;
                 cx.notify();
@@ -751,6 +757,7 @@ impl Focusable for MarkdownPreviewView {
 }
 
 impl EventEmitter<()> for MarkdownPreviewView {}
+impl EventEmitter<SearchEvent> for MarkdownPreviewView {}
 
 impl Item for MarkdownPreviewView {
     type Event = ();
@@ -775,6 +782,18 @@ impl Item for MarkdownPreviewView {
     }
 
     fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {}
+
+    fn buffer_kind(&self, _cx: &App) -> ItemBufferKind {
+        ItemBufferKind::Singleton
+    }
+
+    fn as_searchable(
+        &self,
+        handle: &Entity<Self>,
+        _: &App,
+    ) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(handle.clone()))
+    }
 }
 
 impl Render for MarkdownPreviewView {
@@ -807,6 +826,140 @@ impl Render for MarkdownPreviewView {
     }
 }
 
+impl SearchableItem for MarkdownPreviewView {
+    type Match = Range<usize>;
+
+    fn supported_options(&self) -> SearchOptions {
+        SearchOptions {
+            case: true,
+            word: true,
+            regex: true,
+            replacement: false,
+            selection: false,
+            select_all: false,
+            find_in_results: false,
+        }
+    }
+
+    fn get_matches(&self, _window: &mut Window, cx: &mut App) -> (Vec<Self::Match>, SearchToken) {
+        (
+            self.markdown.read(cx).search_highlights().to_vec(),
+            SearchToken::default(),
+        )
+    }
+
+    fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        let had_highlights = !self.markdown.read(cx).search_highlights().is_empty();
+        self.markdown.update(cx, |markdown, cx| {
+            markdown.clear_search_highlights(cx);
+        });
+        if had_highlights {
+            cx.emit(SearchEvent::MatchesInvalidated);
+        }
+    }
+
+    fn update_matches(
+        &mut self,
+        matches: &[Self::Match],
+        active_match_index: Option<usize>,
+        _token: SearchToken,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let old_highlights = self.markdown.read(cx).search_highlights();
+        let changed = old_highlights != matches;
+        self.markdown.update(cx, |markdown, cx| {
+            markdown.set_search_highlights(matches.to_vec(), active_match_index, cx);
+        });
+        if changed {
+            cx.emit(SearchEvent::MatchesInvalidated);
+        }
+    }
+
+    fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
+        self.markdown.read(cx).selected_text().unwrap_or_default()
+    }
+
+    fn activate_match(
+        &mut self,
+        index: usize,
+        matches: &[Self::Match],
+        _token: SearchToken,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(match_range) = matches.get(index) {
+            let start = match_range.start;
+            self.markdown.update(cx, |markdown, cx| {
+                markdown.set_active_search_highlight(Some(index), cx);
+                markdown.request_autoscroll_to_source_index(start, cx);
+            });
+            cx.emit(SearchEvent::ActiveMatchChanged);
+        }
+    }
+
+    fn select_matches(
+        &mut self,
+        _matches: &[Self::Match],
+        _token: SearchToken,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+    }
+
+    fn replace(
+        &mut self,
+        _: &Self::Match,
+        _: &SearchQuery,
+        _token: SearchToken,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) {
+    }
+
+    fn find_matches(
+        &mut self,
+        query: Arc<SearchQuery>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Vec<Self::Match>> {
+        let source = self.markdown.read(cx).source().to_string();
+        cx.background_spawn(async move { query.search_str(&source) })
+    }
+
+    fn active_match_index(
+        &mut self,
+        direction: Direction,
+        matches: &[Self::Match],
+        _token: SearchToken,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<usize> {
+        if matches.is_empty() {
+            return None;
+        }
+
+        let markdown = self.markdown.read(cx);
+        let current_source_index = markdown
+            .active_search_highlight()
+            .and_then(|i| markdown.search_highlights().get(i))
+            .map(|m| m.start)
+            .or(self.active_source_index)
+            .unwrap_or(0);
+
+        match direction {
+            Direction::Next => matches
+                .iter()
+                .position(|m| m.start >= current_source_index)
+                .or(Some(0)),
+            Direction::Prev => matches
+                .iter()
+                .rposition(|m| m.start <= current_source_index)
+                .or(Some(matches.len().saturating_sub(1))),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use crate::markdown_preview_view::ImageSource;

crates/project/src/search.rs πŸ”—

@@ -620,4 +620,56 @@ impl SearchQuery {
             Self::Text { .. } => None,
         }
     }
+
+    pub fn search_str(&self, text: &str) -> Vec<Range<usize>> {
+        if self.as_str().is_empty() {
+            return Vec::new();
+        }
+
+        let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
+
+        let mut matches = Vec::new();
+        match self {
+            Self::Text {
+                search, whole_word, ..
+            } => {
+                for mat in search.find_iter(text.as_bytes()) {
+                    if *whole_word {
+                        let prev_char = text[..mat.start()].chars().last();
+                        let next_char = text[mat.end()..].chars().next();
+                        if prev_char.is_some_and(&is_word_char)
+                            || next_char.is_some_and(&is_word_char)
+                        {
+                            continue;
+                        }
+                    }
+                    matches.push(mat.start()..mat.end());
+                }
+            }
+            Self::Regex {
+                regex,
+                multiline,
+                one_match_per_line,
+                ..
+            } => {
+                if *multiline {
+                    for mat in regex.find_iter(text).flatten() {
+                        matches.push(mat.start()..mat.end());
+                    }
+                } else {
+                    let mut line_offset = 0;
+                    for line in text.split('\n') {
+                        for mat in regex.find_iter(line).flatten() {
+                            matches.push((line_offset + mat.start())..(line_offset + mat.end()));
+                            if *one_match_per_line {
+                                break;
+                            }
+                        }
+                        line_offset += line.len() + 1;
+                    }
+                }
+            }
+        }
+        matches
+    }
 }

crates/search/src/buffer_search.rs πŸ”—

@@ -291,6 +291,7 @@ impl Render for BufferSearchBar {
             regex,
             replacement,
             selection,
+            select_all,
             find_in_results,
         } = self.supported_options(cx);
 
@@ -461,14 +462,16 @@ impl Render for BufferSearchBar {
                         ))
                     });
 
-                el.child(render_action_button(
-                    "buffer-search-nav-button",
-                    IconName::SelectAll,
-                    Default::default(),
-                    "Select All Matches",
-                    &SelectAllMatches,
-                    query_focus,
-                ))
+                el.when(select_all, |el| {
+                    el.child(render_action_button(
+                        "buffer-search-nav-button",
+                        IconName::SelectAll,
+                        Default::default(),
+                        "Select All Matches",
+                        &SelectAllMatches,
+                        query_focus.clone(),
+                    ))
+                })
                 .child(matches_column)
             })
             .when(find_in_results, |el| {

crates/workspace/src/searchable.rs πŸ”—

@@ -55,6 +55,7 @@ pub struct SearchOptions {
     /// Specifies whether the  supports search & replace.
     pub replacement: bool,
     pub selection: bool,
+    pub select_all: bool,
     pub find_in_results: bool,
 }
 
@@ -78,6 +79,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
             regex: true,
             replacement: true,
             selection: true,
+            select_all: true,
             find_in_results: false,
         }
     }