From fd4d8444cfe6afc1476780020bf43a985e2fb321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Kaan=20G=C3=BCm=C3=BC=C5=9F?= Date: Mon, 6 Apr 2026 19:42:20 +0300 Subject: [PATCH] markdown_preview: Add search support to markdown preview (#52502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- 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 + .../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(-) diff --git a/Cargo.lock b/Cargo.lock index d091e026ff3a6e0c27b477b26454b3ca47ae947b..279fcec10f1efb4c3174bfdd8e28192cda2f6a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10159,6 +10159,7 @@ dependencies = [ "language", "log", "markdown", + "project", "settings", "tempfile", "theme_settings", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4930fbea84b2b449f3b5c35fee2a390525cb3551..0beabfcbc555a336ad75424fb4079e5d4a867b89 100644 --- a/assets/keymaps/default-linux.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 85c01bb33b54c30a55b5d046d03eb391d8c058c1..c514a8fbfc71f7b2b62e017b940790a39cf59db7 100644 --- a/assets/keymaps/default-macos.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0705717062ab5015de20cc3b93f651f867b5116d..a9eb3933423ff60fe60ac391b12773ce7146fb0d 100644 --- a/assets/keymaps/default-windows.json +++ b/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", }, }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1a7e7bf77248b6f863d4a6dbc1e268b4c5ae3576..220b44ff537ffa791b23c0c5b7d86b6768d74dc2 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1096,6 +1096,7 @@ "ctrl-e": "markdown::ScrollDown", "g g": "markdown::ScrollToTop", "shift-g": "markdown::ScrollToBottom", + "/": "buffer_search::Deploy", }, }, { diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 6a6ac706ecd7e4e3e7369afe503652b9756b6dec..2c653217716b0218cff0b60eb2bce4ac1ce02e5d 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -1086,6 +1086,7 @@ impl SearchableItem for DapLogView { // DAP log is read-only. replacement: false, selection: false, + select_all: true, } } fn active_match_index( diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 28e920c28bd9854a38a5019622248fa79cd0a8e1..d2c157014330cc26f0024ace87ee0e3688f85eaa 100644 --- a/crates/editor/src/items.rs +++ b/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, } } diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index ff1ec56b41ccf12ce6e497c21439aea5c97c3d39..97f0676d250cac2cee54b307e7c07d894d3d3128 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -880,6 +880,7 @@ impl SearchableItem for LspLogView { // LSP log is read-only. replacement: false, selection: false, + select_all: true, } } fn active_match_index( diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 871cf5848d9348f2301363b16c30a4811cf5c24e..247c082d223005a7e0bd6d57696751ce76cc4d86 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -263,6 +263,8 @@ pub struct Markdown { copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_selected_text: Option, + search_highlights: Vec>, + active_search_highlight: Option, } #[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>, + active: Option, + cx: &mut Context, + ) { + self.search_highlights = highlights; + self.active_search_highlight = active; + cx.notify(); + } + + pub fn clear_search_highlights(&mut self, cx: &mut Context) { + 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, cx: &mut Context) { + if self.active_search_highlight != active { + self.active_search_highlight = active; + cx.notify(); + } + } + + pub fn search_highlights(&self) -> &[Range] { + &self.search_highlights + } + + pub fn active_search_highlight(&self) -> Option { + self.active_search_highlight + } + fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context) { 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, + 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, + 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, + 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); } } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 19f1270bb91e8a7e9e660a62d8191a9d12b66641..3a07b258c5bd17ef2da02820ef2e724f7389ce13 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/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 diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index f978fdfcce13808b58cd1d7467379c44b95e7433..3e6423b36603e247ba5da2a2166a8357701fa5cd 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/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 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, + _: &App, + ) -> Option> { + Some(Box::new(handle.clone())) + } } impl Render for MarkdownPreviewView { @@ -807,6 +826,140 @@ impl Render for MarkdownPreviewView { } } +impl SearchableItem for MarkdownPreviewView { + type Match = Range; + + fn supported_options(&self) -> SearchOptions { + SearchOptions { + case: true, + word: true, + regex: true, + replacement: false, + selection: false, + select_all: false, + find_in_results: false, + } + } + + fn get_matches(&self, _window: &mut Window, cx: &mut App) -> (Vec, SearchToken) { + ( + self.markdown.read(cx).search_highlights().to_vec(), + SearchToken::default(), + ) + } + + fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context) { + let had_highlights = !self.markdown.read(cx).search_highlights().is_empty(); + self.markdown.update(cx, |markdown, cx| { + markdown.clear_search_highlights(cx); + }); + if had_highlights { + cx.emit(SearchEvent::MatchesInvalidated); + } + } + + fn update_matches( + &mut self, + matches: &[Self::Match], + active_match_index: Option, + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) { + 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) -> 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, + ) { + 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, + ) { + } + + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _token: SearchToken, + _window: &mut Window, + _: &mut Context, + ) { + } + + fn find_matches( + &mut self, + query: Arc, + _window: &mut Window, + cx: &mut Context, + ) -> Task> { + let source = self.markdown.read(cx).source().to_string(); + cx.background_spawn(async move { query.search_str(&source) }) + } + + fn active_match_index( + &mut self, + direction: Direction, + matches: &[Self::Match], + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + if matches.is_empty() { + return None; + } + + let markdown = self.markdown.read(cx); + let current_source_index = markdown + .active_search_highlight() + .and_then(|i| markdown.search_highlights().get(i)) + .map(|m| m.start) + .or(self.active_source_index) + .unwrap_or(0); + + match direction { + Direction::Next => matches + .iter() + .position(|m| m.start >= current_source_index) + .or(Some(0)), + Direction::Prev => matches + .iter() + .rposition(|m| m.start <= current_source_index) + .or(Some(matches.len().saturating_sub(1))), + } + } +} + #[cfg(test)] mod tests { use crate::markdown_preview_view::ImageSource; diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 3a554eb3da1557849e18846b09a7787ab939f46d..cd4702d04863c2fc3026700b2d6653e1db24dbff 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -620,4 +620,56 @@ impl SearchQuery { Self::Text { .. } => None, } } + + pub fn search_str(&self, text: &str) -> Vec> { + 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 + } } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 1328805b50fe077e36d38b3290cb7936f24301f2..46177c5642a8d05daaf22e9fb24b205cd10ca42b 100644 --- a/crates/search/src/buffer_search.rs +++ b/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| { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0c9bbcbec32dcd0fbb8240d524b83f461ac778c3..3ecc6c844db834da91e2f24c3f0cf2d460b5f246 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1820,6 +1820,7 @@ impl SearchableItem for TerminalView { regex: true, replacement: false, selection: false, + select_all: false, find_in_results: false, } } diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 93d809d7a522d11e4b4bd78e71899b89aa4d0508..f0932a7d7b3e7880c27b40c28890f063f4de731e 100644 --- a/crates/workspace/src/searchable.rs +++ b/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 { regex: true, replacement: true, selection: true, + select_all: true, find_in_results: false, } }