Add search_on_input setting to Project Search (#42889)

holoflash created

I was really missing the ability to instantly see search results while
typing in the Project Search and decided to try and implement it.
As this may not be a feature that _everyone_ wants, I made it
toggle-able via the settings (or the settings.json)
### Settings
Set to false by default
<img width="911" height="618" alt="Screenshot 2025-11-17 at 16 17 09"
src="https://github.com/user-attachments/assets/8eaaab65-684e-4c5f-9a3c-9cb62cff0925"
/>
### settings.json
Set to false by default
<img width="396" height="193" alt="Screenshot 2025-11-17 at 16 18 21"
src="https://github.com/user-attachments/assets/90ebda95-c454-4bc5-8423-5da593832fd2"
/>

### Video demo:

https://github.com/user-attachments/assets/715d6b77-3a61-45f8-8e1a-9bd880c697c3

- Search input is debounced with 250ms in this mode (cool?)


The desire for this feature has been expressed here too:
https://github.com/zed-industries/zed/discussions/30843

Release Notes:

- Enabled project search on input. Can be turned off with
`search.search_on_input` settings toggle.

Change summary

assets/settings/default.json          |  2 
crates/editor/src/editor_settings.rs  |  3 +
crates/search/src/buffer_search.rs    | 85 +++++++++++++++++++++++++++++
crates/search/src/project_search.rs   | 63 ++++++++++++++++----
crates/settings_content/src/editor.rs |  2 
crates/settings_ui/src/page_data.rs   | 25 ++++++++
docs/src/reference/all-settings.md    | 12 ++--
7 files changed, 171 insertions(+), 21 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -667,6 +667,8 @@
     "regex": false,
     // Whether to center the cursor on each search match when navigating.
     "center_on_match": false,
+    // Whether to search on input.
+    "search_on_input": true,
   },
   // When to populate a new search's query based on the text under the cursor.
   // This setting can take the following three values:

crates/editor/src/editor_settings.rs 🔗

@@ -175,6 +175,8 @@ pub struct SearchSettings {
     pub regex: bool,
     /// Whether to center the cursor on each search match when navigating.
     pub center_on_match: bool,
+    /// Whether to search on input.
+    pub search_on_input: bool,
 }
 
 impl EditorSettings {
@@ -271,6 +273,7 @@ impl Settings for EditorSettings {
                 include_ignored: search.include_ignored.unwrap(),
                 regex: search.regex.unwrap(),
                 center_on_match: search.center_on_match.unwrap(),
+                search_on_input: search.search_on_input.unwrap(),
             },
             auto_signature_help: editor.auto_signature_help.unwrap(),
             show_signature_help_after_edits: editor.show_signature_help_after_edits.unwrap(),

crates/search/src/buffer_search.rs 🔗

@@ -3421,6 +3421,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3484,6 +3485,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3522,6 +3524,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3604,9 +3607,91 @@ mod tests {
                         include_ignored: Some(search_settings.include_ignored),
                         regex: Some(search_settings.regex),
                         center_on_match: Some(search_settings.center_on_match),
+                        search_on_input: Some(search_settings.search_on_input),
                     });
                 });
             });
         });
     }
+    #[gpui::test]
+    async fn test_search_on_input_setting(cx: &mut TestAppContext) {
+        let (editor, search_bar, cx) = init_test(cx);
+
+        update_search_settings(
+            SearchSettings {
+                button: true,
+                whole_word: false,
+                case_sensitive: false,
+                include_ignored: false,
+                regex: false,
+                center_on_match: false,
+                search_on_input: false,
+            },
+            cx,
+        );
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.show(window, cx);
+            search_bar.query_editor.update(cx, |query_editor, cx| {
+                query_editor.buffer().update(cx, |buffer, cx| {
+                    buffer.edit(
+                        [(MultiBufferOffset(0)..MultiBufferOffset(0), "expression")],
+                        None,
+                        cx,
+                    );
+                });
+            });
+        });
+
+        cx.background_executor.run_until_parked();
+
+        editor.update_in(cx, |editor, window, cx| {
+            let highlights = editor.all_text_background_highlights(window, cx);
+            assert!(
+                highlights.is_empty(),
+                "No highlights should appear when search_on_input is false"
+            );
+        });
+
+        update_search_settings(
+            SearchSettings {
+                button: true,
+                whole_word: false,
+                case_sensitive: false,
+                include_ignored: false,
+                regex: false,
+                center_on_match: false,
+                search_on_input: true,
+            },
+            cx,
+        );
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.dismiss(&Dismiss, window, cx);
+            search_bar.show(window, cx);
+        });
+
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search("expression", None, true, window, cx)
+            })
+            .await
+            .unwrap();
+
+        editor.update_in(cx, |editor, window, cx| {
+            let highlights = display_points_of(editor.all_text_background_highlights(window, cx));
+            assert_eq!(
+                highlights.len(),
+                2,
+                "Should find 2 matches for 'expression' when search_on_input is true"
+            );
+            assert_eq!(
+                highlights,
+                &[
+                    DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 20),
+                    DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 19),
+                ]
+            );
+        });
+    }
 }

crates/search/src/project_search.rs 🔗

@@ -869,15 +869,32 @@ impl ProjectSearchView {
         // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
         subscriptions.push(
             cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
-                if let EditorEvent::Edited { .. } = event
-                    && EditorSettings::get_global(cx).use_smartcase_search
-                {
-                    let query = this.search_query_text(cx);
-                    if !query.is_empty()
-                        && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
-                            != contains_uppercase(&query)
-                    {
-                        this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+                if let EditorEvent::Edited { .. } = event {
+                    if EditorSettings::get_global(cx).use_smartcase_search {
+                        let query = this.search_query_text(cx);
+                        if !query.is_empty()
+                            && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
+                                != contains_uppercase(&query)
+                        {
+                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+                        }
+                    }
+                    // Trigger search on input:
+                    if EditorSettings::get_global(cx).search.search_on_input {
+                        let query = this.search_query_text(cx);
+                        if query.is_empty() {
+                            // Clear results immediately when query is empty and abort ongoing search
+                            this.entity.update(cx, |model, cx| {
+                                model.pending_search = None;
+                                model.match_ranges.clear();
+                                model.excerpts.update(cx, |excerpts, cx| excerpts.clear(cx));
+                                model.no_results = None;
+                                model.limit_reached = false;
+                                cx.notify();
+                            });
+                        } else {
+                            this.search(cx);
+                        }
                     }
                 }
                 cx.emit(ViewEvent::EditorEvent(event.clone()))
@@ -1529,7 +1546,11 @@ impl ProjectSearchView {
                     editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
                 }
             });
-            if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
+            let should_auto_focus = !EditorSettings::get_global(cx).search.search_on_input;
+            if is_new_search
+                && self.query_editor.focus_handle(cx).is_focused(window)
+                && should_auto_focus
+            {
                 self.focus_results_editor(window, cx);
             }
         }
@@ -1592,9 +1613,13 @@ impl ProjectSearchView {
         v_flex()
             .gap_1()
             .child(
-                Label::new("Hit enter to search. For more options:")
-                    .color(Color::Muted)
-                    .mb_2(),
+                Label::new(if EditorSettings::get_global(cx).search.search_on_input {
+                    "Start typing to search. For more options:"
+                } else {
+                    "Hit enter to search. For more options:"
+                })
+                .color(Color::Muted)
+                .mb_2(),
             )
             .child(
                 Button::new("filter-paths", "Include/exclude specific paths")
@@ -2537,7 +2562,8 @@ pub mod tests {
     use project::FakeFs;
     use serde_json::json;
     use settings::{
-        InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
+        InlayHintSettingsContent, SearchSettingsContent, SettingsStore, ThemeColorsContent,
+        ThemeStyleContent,
     };
     use util::{path, paths::PathStyle, rel_path::rel_path};
     use util_macros::perf;
@@ -4756,6 +4782,15 @@ pub mod tests {
             let settings = SettingsStore::test(cx);
             cx.set_global(settings);
 
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.editor.search = Some(SearchSettingsContent {
+                        search_on_input: Some(false),
+                        ..Default::default()
+                    });
+                });
+            });
+
             theme::init(theme::LoadThemes::JustBase, cx);
 
             editor::init(cx);

crates/settings_content/src/editor.rs 🔗

@@ -828,6 +828,8 @@ pub struct SearchSettingsContent {
     pub regex: Option<bool>,
     /// Whether to center the cursor on each search match when navigating.
     pub center_on_match: Option<bool>,
+    /// Whether to search on input.
+    pub search_on_input: Option<bool>,
 }
 
 #[with_fallible_options]

crates/settings_ui/src/page_data.rs 🔗

@@ -2999,7 +2999,7 @@ fn languages_and_tools_page(cx: &App) -> SettingsPage {
 }
 
 fn search_and_files_page() -> SettingsPage {
-    fn search_section() -> [SettingsPageItem; 9] {
+    fn search_section() -> [SettingsPageItem; 10] {
         [
             SettingsPageItem::SectionHeader("Search"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -3133,6 +3133,29 @@ fn search_and_files_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Search on Input",
+                description: "Whether to search on input.",
+                field: Box::new(SettingField {
+                    json_path: Some("editor.search.search_on_input"),
+                    pick: |settings_content| {
+                        settings_content
+                            .editor
+                            .search
+                            .as_ref()
+                            .and_then(|search| search.search_on_input.as_ref())
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .editor
+                            .search
+                            .get_or_insert_default()
+                            .search_on_input = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Seed Search Query From Cursor",
                 description: "When to populate a new search's query based on the text under the cursor.",

docs/src/reference/all-settings.md 🔗

@@ -3270,6 +3270,12 @@ Non-negative `integer` values
 - Setting: `regex`
 - Default: `false`
 
+### Search On Input
+
+- Description: Whether to search on input.
+- Setting: `search_on_input
+- Default: `true`
+
 ### Center On Match
 
 - Description: Whether to center the cursor on each search match when navigating.
@@ -3282,12 +3288,6 @@ Non-negative `integer` values
 - Setting: `search_wrap`
 - Default: `true`
 
-## Center on Match
-
-- Description: If `center_on_match` is enabled, the editor will center the cursor on the current match when searching.
-- Setting: `center_on_match`
-- Default: `false`
-
 ## Seed Search Query From Cursor
 
 - Description: When to populate a new search's query based on the text under the cursor.