Cargo.lock π
@@ -10159,6 +10159,7 @@ dependencies = [
"language",
"log",
"markdown",
+ "project",
"settings",
"tempfile",
"theme_settings",
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>
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(-)
@@ -10159,6 +10159,7 @@ dependencies = [
"language",
"log",
"markdown",
+ "project",
"settings",
"tempfile",
"theme_settings",
@@ -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",
},
},
{
@@ -1375,6 +1375,7 @@
"alt-down": "markdown::ScrollDownByItem",
"cmd-up": "markdown::ScrollToTop",
"cmd-down": "markdown::ScrollToBottom",
+ "cmd-f": "buffer_search::Deploy",
},
},
{
@@ -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",
},
},
{
@@ -1096,6 +1096,7 @@
"ctrl-e": "markdown::ScrollDown",
"g g": "markdown::ScrollToTop",
"shift-g": "markdown::ScrollToBottom",
+ "/": "buffer_search::Deploy",
},
},
{
@@ -1086,6 +1086,7 @@ impl SearchableItem for DapLogView {
// DAP log is read-only.
replacement: false,
selection: false,
+ select_all: true,
}
}
fn active_match_index(
@@ -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,
}
}
@@ -880,6 +880,7 @@ impl SearchableItem for LspLogView {
// LSP log is read-only.
replacement: false,
selection: false,
+ select_all: true,
}
}
fn active_match_index(
@@ -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);
}
}
@@ -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
@@ -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;
@@ -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
+ }
}
@@ -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| {
@@ -1820,6 +1820,7 @@ impl SearchableItem for TerminalView {
regex: true,
replacement: false,
selection: false,
+ select_all: false,
find_in_results: false,
}
}
@@ -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,
}
}