editor: Highlight all matching occurrences of text in selection (#24835)

smit , Agus Zubiaga , and Danilo created

Closes #12635

- [x] Get it working
- [x] Disable for multi cursor
- [x] Disable for vim visual line selection
- [x] Add setting to disable it
- [x] Add scrollbar marker
- [x] Handle delete state capturing selection

Preview:

https://github.com/user-attachments/assets/a76cde64-4f6c-4575-91cc-3a03a954e7a9

Release Notes:

- Added support to highlight all matching occurrences of text within the
selection in editor.

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Danilo <danilo@zed.dev>

Change summary

assets/settings/default.json         |  6 +
crates/editor/src/editor.rs          | 91 ++++++++++++++++++++++++++++++
crates/editor/src/editor_settings.rs | 15 ++++
crates/editor/src/element.rs         | 10 ++
docs/src/configuring-zed.md          | 24 +++++++
5 files changed, 144 insertions(+), 2 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -154,6 +154,10 @@
   // 4. Highlight the full line (default):
   //    "all"
   "current_line_highlight": "all",
+  // Whether to highlight all occurrences of the selected text in an editor.
+  "selection_highlight": true,
+  // The debounce delay before querying highlights based on the selected text.
+  "selection_highlight_debounce": 50,
   // The debounce delay before querying highlights from the language
   // server based on the current cursor location.
   "lsp_highlight_debounce": 75,
@@ -259,6 +263,8 @@
     "git_diff": true,
     // Whether to show buffer search results in the scrollbar.
     "search_results": true,
+    // Whether to show selected text occurrences in the scrollbar.
+    "selected_text": true,
     // Whether to show selected symbol occurrences in the scrollbar.
     "selected_symbol": true,
     // Which diagnostic indicators to show in the scrollbar:

crates/editor/src/editor.rs 🔗

@@ -283,6 +283,7 @@ impl InlayId {
 enum DocumentHighlightRead {}
 enum DocumentHighlightWrite {}
 enum InputComposition {}
+enum SelectedTextHighlight {}
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
 pub enum Navigated {
@@ -681,6 +682,7 @@ pub struct Editor {
     next_completion_id: CompletionId,
     available_code_actions: Option<(Location, Rc<[AvailableCodeAction]>)>,
     code_actions_task: Option<Task<Result<()>>>,
+    selection_highlight_task: Option<Task<()>>,
     document_highlights_task: Option<Task<()>>,
     linked_editing_range_task: Option<Task<Option<()>>>,
     linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
@@ -1384,6 +1386,7 @@ impl Editor {
             code_action_providers,
             available_code_actions: Default::default(),
             code_actions_task: Default::default(),
+            selection_highlight_task: Default::default(),
             document_highlights_task: Default::default(),
             linked_editing_range_task: Default::default(),
             pending_rename: Default::default(),
@@ -2165,6 +2168,7 @@ impl Editor {
             }
             self.refresh_code_actions(window, cx);
             self.refresh_document_highlights(cx);
+            self.refresh_selected_text_highlights(window, cx);
             refresh_matching_bracket_highlights(self, window, cx);
             self.update_visible_inline_completion(window, cx);
             self.edit_prediction_requires_modifier_in_leading_space = true;
@@ -4722,6 +4726,93 @@ impl Editor {
         None
     }
 
+    pub fn refresh_selected_text_highlights(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        self.selection_highlight_task.take();
+        if !EditorSettings::get_global(cx).selection_highlight {
+            self.clear_background_highlights::<SelectedTextHighlight>(cx);
+            return;
+        }
+        if self.selections.count() != 1 || self.selections.line_mode {
+            self.clear_background_highlights::<SelectedTextHighlight>(cx);
+            return;
+        }
+        let selection = self.selections.newest::<Point>(cx);
+        if selection.is_empty() || selection.start.row != selection.end.row {
+            self.clear_background_highlights::<SelectedTextHighlight>(cx);
+            return;
+        }
+        let debounce = EditorSettings::get_global(cx).selection_highlight_debounce;
+        self.selection_highlight_task = Some(cx.spawn_in(window, |editor, mut cx| async move {
+            cx.background_executor()
+                .timer(Duration::from_millis(debounce))
+                .await;
+            let Some(matches_task) = editor
+                .read_with(&mut cx, |editor, cx| {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+                    cx.background_executor().spawn(async move {
+                        let mut ranges = Vec::new();
+                        let buffer_ranges =
+                            vec![buffer.anchor_before(0)..buffer.anchor_after(buffer.len())];
+                        let query = buffer.text_for_range(selection.range()).collect::<String>();
+                        for range in buffer_ranges {
+                            for (search_buffer, search_range, excerpt_id) in
+                                buffer.range_to_buffer_ranges(range)
+                            {
+                                ranges.extend(
+                                    project::search::SearchQuery::text(
+                                        query.clone(),
+                                        false,
+                                        false,
+                                        false,
+                                        Default::default(),
+                                        Default::default(),
+                                        None,
+                                    )
+                                    .unwrap()
+                                    .search(search_buffer, Some(search_range.clone()))
+                                    .await
+                                    .into_iter()
+                                    .map(|match_range| {
+                                        let start = search_buffer
+                                            .anchor_after(search_range.start + match_range.start);
+                                        let end = search_buffer
+                                            .anchor_before(search_range.start + match_range.end);
+                                        Anchor::range_in_buffer(
+                                            excerpt_id,
+                                            search_buffer.remote_id(),
+                                            start..end,
+                                        )
+                                    }),
+                                );
+                            }
+                        }
+                        ranges
+                    })
+                })
+                .log_err()
+            else {
+                return;
+            };
+            let matches = matches_task.await;
+            editor
+                .update_in(&mut cx, |editor, _, cx| {
+                    editor.clear_background_highlights::<SelectedTextHighlight>(cx);
+                    if !matches.is_empty() {
+                        editor.highlight_background::<SelectedTextHighlight>(
+                            &matches,
+                            |theme| theme.editor_document_highlight_bracket_background,
+                            cx,
+                        )
+                    }
+                })
+                .log_err();
+        }));
+    }
+
     pub fn refresh_inline_completion(
         &mut self,
         debounce: bool,

crates/editor/src/editor_settings.rs 🔗

@@ -9,6 +9,8 @@ pub struct EditorSettings {
     pub cursor_blink: bool,
     pub cursor_shape: Option<CursorShape>,
     pub current_line_highlight: CurrentLineHighlight,
+    pub selection_highlight: bool,
+    pub selection_highlight_debounce: u64,
     pub lsp_highlight_debounce: u64,
     pub hover_popover_enabled: bool,
     pub hover_popover_delay: u64,
@@ -102,6 +104,7 @@ pub struct Toolbar {
 pub struct Scrollbar {
     pub show: ShowScrollbar,
     pub git_diff: bool,
+    pub selected_text: bool,
     pub selected_symbol: bool,
     pub search_results: bool,
     pub diagnostics: ScrollbarDiagnostics,
@@ -271,6 +274,14 @@ pub struct EditorSettingsContent {
     ///
     /// Default: all
     pub current_line_highlight: Option<CurrentLineHighlight>,
+    /// Whether to highlight all occurrences of the selected text in an editor.
+    ///
+    /// Default: true
+    pub selection_highlight: Option<bool>,
+    /// The debounce delay before querying highlights based on the selected text.
+    ///
+    /// Default: 75
+    pub selection_highlight_debounce: Option<u64>,
     /// The debounce delay before querying highlights from the language
     /// server based on the current cursor location.
     ///
@@ -404,6 +415,10 @@ pub struct ScrollbarContent {
     ///
     /// Default: true
     pub search_results: Option<bool>,
+    /// Whether to show selected text occurrences in the scrollbar.
+    ///
+    /// Default: true
+    pub selected_text: Option<bool>,
     /// Whether to show selected symbol occurrences in the scrollbar.
     ///
     /// Default: true

crates/editor/src/element.rs 🔗

@@ -20,8 +20,8 @@ use crate::{
     EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GoToHunk,
     GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, InlineCompletion,
     JumpData, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point, RevertSelectedHunks, RowExt,
-    RowRangeExt, SelectPhase, Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold,
-    ToggleStagedSelectedDiffHunks, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT,
+    RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap, StickyHeaderExcerpt,
+    ToPoint, ToggleFold, ToggleStagedSelectedDiffHunks, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT,
     GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
 };
 use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus};
@@ -1296,6 +1296,9 @@ impl EditorElement {
                     // Buffer Search Results
                     (is_singleton && scrollbar_settings.search_results && editor.has_background_highlights::<BufferSearchHighlights>())
                     ||
+                    // Selected Text Occurrences
+                    (is_singleton && scrollbar_settings.selected_text && editor.has_background_highlights::<SelectedTextHighlight>())
+                    ||
                     // Selected Symbol Occurrences
                     (is_singleton && scrollbar_settings.selected_symbol && (editor.has_background_highlights::<DocumentHighlightRead>() || editor.has_background_highlights::<DocumentHighlightWrite>()))
                     ||
@@ -5439,11 +5442,14 @@ impl EditorElement {
                             {
                                 let is_search_highlights = *background_highlight_id
                                     == TypeId::of::<BufferSearchHighlights>();
+                                let is_text_highlights = *background_highlight_id
+                                    == TypeId::of::<SelectedTextHighlight>();
                                 let is_symbol_occurrences = *background_highlight_id
                                     == TypeId::of::<DocumentHighlightRead>()
                                     || *background_highlight_id
                                         == TypeId::of::<DocumentHighlightWrite>();
                                 if (is_search_highlights && scrollbar_settings.search_results)
+                                    || (is_text_highlights && scrollbar_settings.selected_text)
                                     || (is_symbol_occurrences && scrollbar_settings.selected_symbol)
                                 {
                                     let mut color = theme.status().info;

docs/src/configuring-zed.md 🔗

@@ -472,6 +472,19 @@ List of `string` values
 "current_line_highlight": "all"
 ```
 
+## Selection Highlight
+
+- Description: Whether to highlight all occurrences of the selected text in an editor.
+- Setting: `selection_highlight`
+- Default: `true`
+
+## Selection Highlight Debounce
+
+- Description: The debounce delay before querying highlights based on the selected text.
+
+- Setting: `selection_highlight_debounce`
+- Default: `75`
+
 ## LSP Highlight Debounce
 
 - Description: The debounce delay before querying highlights from the language server based on the current cursor location.
@@ -532,6 +545,7 @@ List of `string` values
   "cursors": true,
   "git_diff": true,
   "search_results": true,
+  "selected_text": true,
   "selected_symbol": true,
   "diagnostics": "all",
   "axes": {
@@ -611,6 +625,16 @@ List of `string` values
 
 `boolean` values
 
+### Selected Text Indicators
+
+- Description: Whether to show selected text occurrences in the scrollbar.
+- Setting: `selected_text`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
 ### Selected Symbols Indicators
 
 - Description: Whether to show selected symbol occurrences in the scrollbar.