Enable type on search by default for the project search (#49374)

Kirill Bulatov and Cole Miller created

Deals with https://github.com/zed-industries/zed/issues/9318
Re-lands https://github.com/zed-industries/zed/pull/42889 with more
fixes to reduce overall flickering

Release Notes:

- Enabled type on search by default for the project search

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                              |   1 
assets/settings/default.json            |   2 
crates/editor/src/editor_settings.rs    |   3 
crates/editor/src/element.rs            |   7 
crates/editor/src/split.rs              |  47 -
crates/multi_buffer/src/multi_buffer.rs |   2 
crates/multi_buffer/src/path_key.rs     | 122 ++++-
crates/search/Cargo.toml                |   1 
crates/search/src/buffer_search.rs      |   4 
crates/search/src/project_search.rs     | 553 ++++++++++++++++++++++----
crates/settings/src/vscode_import.rs    |   3 
crates/settings_content/src/editor.rs   |   2 
crates/settings_ui/src/page_data.rs     |  25 +
docs/src/reference/all-settings.md      |   8 
14 files changed, 636 insertions(+), 144 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14802,6 +14802,7 @@ dependencies = [
  "language",
  "lsp",
  "menu",
+ "multi_buffer",
  "pretty_assertions",
  "project",
  "serde",

assets/settings/default.json 🔗

@@ -671,6 +671,8 @@
     "regex": false,
     // Whether to center the cursor on each search match when navigating.
     "center_on_match": false,
+    // Whether to search on input in project search.
+    "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 in project search.
+    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/editor/src/element.rs 🔗

@@ -9556,8 +9556,13 @@ impl Element for EditorElement {
                     let em_layout_width = window.text_system().em_layout_width(font_id, font_size);
                     let glyph_grid_cell = size(em_advance, line_height);
 
-                    let gutter_dimensions =
+                    let mut gutter_dimensions =
                         snapshot.gutter_dimensions(font_id, font_size, style, window, cx);
+                    // Reduce flickering and rewraps when new excerpts are added and removed.
+                    if !is_singleton {
+                        let prev_width = self.editor.read(cx).gutter_dimensions.width;
+                        gutter_dimensions.width = gutter_dimensions.width.max(prev_width);
+                    }
                     let text_width = bounds.size.width - gutter_dimensions.width;
 
                     let settings = EditorSettings::get_global(cx);

crates/editor/src/split.rs 🔗

@@ -2028,7 +2028,7 @@ impl LhsEditor {
         let base_text_buffer = diff.read(lhs_cx).base_text_buffer();
         let diff_snapshot = diff.read(lhs_cx).snapshot(lhs_cx);
         let base_text_buffer_snapshot = base_text_buffer.read(lhs_cx).snapshot();
-        let new = rhs_multibuffer
+        let excerpt_ranges = rhs_multibuffer
             .excerpts_for_buffer(main_buffer.remote_id(), lhs_cx)
             .into_iter()
             .map(|(_, excerpt_range)| {
@@ -2052,13 +2052,26 @@ impl LhsEditor {
                     context: point_range_to_base_text_point_range(context),
                 }
             })
-            .collect();
+            .collect::<Vec<_>>();
 
-        let lhs_result = lhs_multibuffer.update_path_excerpts(
+        let (new, counts) = MultiBuffer::merge_excerpt_ranges(&excerpt_ranges);
+        let mut total = 0;
+        let rhs_merge_groups = counts
+            .iter()
+            .copied()
+            .map(|count| {
+                let group = rhs_excerpt_ids[total..total + count].to_vec();
+                total += count;
+                group
+            })
+            .collect::<Vec<_>>();
+        let lhs_result = lhs_multibuffer.set_merged_excerpt_ranges_for_path(
             path_key,
             base_text_buffer.clone(),
+            excerpt_ranges,
             &base_text_buffer_snapshot,
             new,
+            counts,
             lhs_cx,
         );
         if !lhs_result.excerpt_ids.is_empty()
@@ -2068,33 +2081,7 @@ impl LhsEditor {
         {
             lhs_multibuffer.add_inverted_diff(diff, lhs_cx);
         }
-
-        let rhs_merge_groups: Vec<Vec<ExcerptId>> = {
-            let mut groups = Vec::new();
-            let mut current_group = Vec::new();
-            let mut last_id = None;
-
-            for (i, &lhs_id) in lhs_result.excerpt_ids.iter().enumerate() {
-                if last_id == Some(lhs_id) {
-                    current_group.push(rhs_excerpt_ids[i]);
-                } else {
-                    if !current_group.is_empty() {
-                        groups.push(current_group);
-                    }
-                    current_group = vec![rhs_excerpt_ids[i]];
-                    last_id = Some(lhs_id);
-                }
-            }
-            if !current_group.is_empty() {
-                groups.push(current_group);
-            }
-            groups
-        };
-
-        let deduplicated_lhs_ids: Vec<ExcerptId> =
-            lhs_result.excerpt_ids.iter().dedup().copied().collect();
-
-        Some((deduplicated_lhs_ids, rhs_merge_groups))
+        Some((lhs_result.excerpt_ids, rhs_merge_groups))
     }
 
     fn sync_path_excerpts(

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -1734,7 +1734,7 @@ impl MultiBuffer {
     }
 
     #[instrument(skip_all)]
-    fn merge_excerpt_ranges<'a>(
+    pub fn merge_excerpt_ranges<'a>(
         expanded_ranges: impl IntoIterator<Item = &'a ExcerptRange<Point>> + 'a,
     ) -> (Vec<ExcerptRange<Point>>, Vec<usize>) {
         let mut merged_ranges: Vec<ExcerptRange<Point>> = Vec::new();

crates/multi_buffer/src/path_key.rs 🔗

@@ -15,6 +15,7 @@ use crate::{
 
 #[derive(Debug, Clone)]
 pub struct PathExcerptInsertResult {
+    pub inserted_ranges: Vec<Range<Anchor>>,
     pub excerpt_ids: Vec<ExcerptId>,
     pub added_new_excerpt: bool,
 }
@@ -100,7 +101,7 @@ impl MultiBuffer {
         let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot);
 
         let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
-        self.set_merged_excerpt_ranges_for_path(
+        let excerpt_insertion_result = self.set_merged_excerpt_ranges_for_path(
             path,
             buffer,
             excerpt_ranges,
@@ -108,6 +109,10 @@ impl MultiBuffer {
             new,
             counts,
             cx,
+        );
+        (
+            excerpt_insertion_result.inserted_ranges,
+            excerpt_insertion_result.added_new_excerpt,
         )
     }
 
@@ -120,7 +125,7 @@ impl MultiBuffer {
         cx: &mut Context<Self>,
     ) -> (Vec<Range<Anchor>>, bool) {
         let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
-        self.set_merged_excerpt_ranges_for_path(
+        let excerpt_insertion_result = self.set_merged_excerpt_ranges_for_path(
             path,
             buffer,
             excerpt_ranges,
@@ -128,6 +133,10 @@ impl MultiBuffer {
             new,
             counts,
             cx,
+        );
+        (
+            excerpt_insertion_result.inserted_ranges,
+            excerpt_insertion_result.added_new_excerpt,
         )
     }
 
@@ -156,7 +165,7 @@ impl MultiBuffer {
 
             multi_buffer
                 .update(&mut app, move |multi_buffer, cx| {
-                    let (ranges, _) = multi_buffer.set_merged_excerpt_ranges_for_path(
+                    let excerpt_insertion_result = multi_buffer.set_merged_excerpt_ranges_for_path(
                         path_key,
                         buffer,
                         excerpt_ranges,
@@ -165,7 +174,7 @@ impl MultiBuffer {
                         counts,
                         cx,
                     );
-                    ranges
+                    excerpt_insertion_result.inserted_ranges
                 })
                 .ok()
                 .unwrap_or_default()
@@ -264,7 +273,7 @@ impl MultiBuffer {
     }
 
     /// Sets excerpts, returns `true` if at least one new excerpt was added.
-    fn set_merged_excerpt_ranges_for_path(
+    pub fn set_merged_excerpt_ranges_for_path(
         &mut self,
         path: PathKey,
         buffer: Entity<Buffer>,
@@ -273,36 +282,110 @@ impl MultiBuffer {
         new: Vec<ExcerptRange<Point>>,
         counts: Vec<usize>,
         cx: &mut Context<Self>,
-    ) -> (Vec<Range<Anchor>>, bool) {
-        let insert_result = self.update_path_excerpts(path, buffer, buffer_snapshot, new, cx);
+    ) -> PathExcerptInsertResult {
+        let (new, counts) =
+            self.expand_new_ranges_to_existing(&path, buffer_snapshot, new, counts, cx);
+        let (excerpt_ids, added_new_excerpt) =
+            self.update_path_excerpts(path, buffer, buffer_snapshot, new, cx);
 
-        let mut result = Vec::new();
+        let mut inserted_ranges = Vec::new();
         let mut ranges = ranges.into_iter();
-        for (excerpt_id, range_count) in insert_result
-            .excerpt_ids
-            .into_iter()
-            .zip(counts.into_iter())
-        {
+        for (&excerpt_id, range_count) in excerpt_ids.iter().zip(counts.into_iter()) {
             for range in ranges.by_ref().take(range_count) {
                 let range = Anchor::range_in_buffer(
                     excerpt_id,
                     buffer_snapshot.anchor_before(&range.primary.start)
                         ..buffer_snapshot.anchor_after(&range.primary.end),
                 );
-                result.push(range)
+                inserted_ranges.push(range)
+            }
+        }
+
+        PathExcerptInsertResult {
+            inserted_ranges,
+            excerpt_ids,
+            added_new_excerpt,
+        }
+    }
+
+    /// Expand each new merged range to encompass any overlapping existing
+    /// excerpt, then re-merge. This turns "partial overlap where the union
+    /// equals the existing range" into an exact match, avoiding unnecessary
+    /// remove+insert churn that floods the wrap map with edits.
+    fn expand_new_ranges_to_existing(
+        &self,
+        path: &PathKey,
+        buffer_snapshot: &BufferSnapshot,
+        mut new: Vec<ExcerptRange<Point>>,
+        counts: Vec<usize>,
+        cx: &App,
+    ) -> (Vec<ExcerptRange<Point>>, Vec<usize>) {
+        let existing = self.excerpts_by_path.get(path).cloned().unwrap_or_default();
+        if existing.is_empty() || new.is_empty() {
+            return (new, counts);
+        }
+
+        let snapshot = self.snapshot(cx);
+        let buffer_id = buffer_snapshot.remote_id();
+        let existing_ranges: Vec<Range<Point>> = existing
+            .iter()
+            .filter_map(|&id| {
+                let excerpt = snapshot.excerpt(id)?;
+                (excerpt.buffer_id == buffer_id)
+                    .then(|| excerpt.range.context.to_point(buffer_snapshot))
+            })
+            .collect();
+
+        let mut changed = false;
+        for new_range in &mut new {
+            for existing_range in &existing_ranges {
+                if new_range.context.start <= existing_range.end
+                    && new_range.context.end >= existing_range.start
+                {
+                    let expanded_start = new_range.context.start.min(existing_range.start);
+                    let expanded_end = new_range.context.end.max(existing_range.end);
+                    if expanded_start != new_range.context.start
+                        || expanded_end != new_range.context.end
+                    {
+                        new_range.context.start = expanded_start;
+                        new_range.context.end = expanded_end;
+                        changed = true;
+                    }
+                }
+            }
+        }
+
+        if !changed {
+            return (new, counts);
+        }
+
+        let mut result_ranges: Vec<ExcerptRange<Point>> = Vec::new();
+        let mut result_counts: Vec<usize> = Vec::new();
+        for (range, count) in new.into_iter().zip(counts) {
+            if let Some(last) = result_ranges.last_mut() {
+                if last.context.end >= range.context.start
+                    || last.context.end.row + 1 == range.context.start.row
+                {
+                    last.context.end = last.context.end.max(range.context.end);
+                    *result_counts.last_mut().unwrap() += count;
+                    continue;
+                }
             }
+            result_ranges.push(range);
+            result_counts.push(count);
         }
-        (result, insert_result.added_new_excerpt)
+
+        (result_ranges, result_counts)
     }
 
-    pub fn update_path_excerpts(
+    fn update_path_excerpts(
         &mut self,
         path: PathKey,
         buffer: Entity<Buffer>,
         buffer_snapshot: &BufferSnapshot,
         new: Vec<ExcerptRange<Point>>,
         cx: &mut Context<Self>,
-    ) -> PathExcerptInsertResult {
+    ) -> (Vec<ExcerptId>, bool) {
         let mut insert_after = self
             .excerpts_by_path
             .range(..path.clone())
@@ -483,9 +566,6 @@ impl MultiBuffer {
                 .insert(path, excerpt_ids.iter().dedup().cloned().collect());
         }
 
-        PathExcerptInsertResult {
-            excerpt_ids,
-            added_new_excerpt: added_a_new_excerpt,
-        }
+        (excerpt_ids, added_a_new_excerpt)
     }
 }

crates/search/Cargo.toml 🔗

@@ -52,6 +52,7 @@ editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 lsp.workspace = true
+multi_buffer.workspace = true
 pretty_assertions.workspace = true
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/search/src/buffer_search.rs 🔗

@@ -3478,6 +3478,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3541,6 +3542,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3579,6 +3581,7 @@ mod tests {
                 include_ignored: false,
                 regex: false,
                 center_on_match: false,
+                search_on_input: false,
             },
             cx,
         );
@@ -3661,6 +3664,7 @@ 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),
                     });
                 });
             });

crates/search/src/project_search.rs 🔗

@@ -9,7 +9,7 @@ use crate::{
     },
 };
 use anyhow::Context as _;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use editor::{
     Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
     SelectionEffects,
@@ -333,7 +333,7 @@ impl ProjectSearch {
         }
     }
 
-    fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) {
+    fn search(&mut self, query: SearchQuery, incremental: bool, cx: &mut Context<Self>) {
         let search = self.project.update(cx, |project, cx| {
             project
                 .search_history_mut(SearchInputKind::Query)
@@ -360,18 +360,22 @@ impl ProjectSearch {
             let SearchResults { rx, _task_handle } = search;
 
             let mut matches = pin!(rx.ready_chunks(1024));
-            project_search
-                .update(cx, |project_search, cx| {
-                    project_search.match_ranges.clear();
-                    project_search
-                        .excerpts
-                        .update(cx, |excerpts, cx| excerpts.clear(cx));
-                    project_search.no_results = Some(true);
-                    project_search.limit_reached = false;
-                })
-                .ok()?;
+
+            if !incremental {
+                project_search
+                    .update(cx, |project_search, cx| {
+                        project_search.match_ranges.clear();
+                        project_search
+                            .excerpts
+                            .update(cx, |excerpts, cx| excerpts.clear(cx));
+                        project_search.no_results = Some(true);
+                        project_search.limit_reached = false;
+                    })
+                    .ok()?;
+            }
 
             let mut limit_reached = false;
+            let mut seen_paths = HashSet::default();
             while let Some(results) = matches.next().await {
                 let (buffers_with_ranges, has_reached_limit) = cx
                     .background_executor()
@@ -392,6 +396,50 @@ impl ProjectSearch {
                     })
                     .await;
                 limit_reached |= has_reached_limit;
+
+                if incremental {
+                    let buffers_with_ranges: Vec<_> = buffers_with_ranges
+                        .into_iter()
+                        .filter(|(_, ranges)| !ranges.is_empty())
+                        .collect();
+                    if buffers_with_ranges.is_empty() {
+                        continue;
+                    }
+                    let (mut chunk_ranges, chunk_paths) = project_search
+                        .update(cx, |project_search, cx| {
+                            let mut paths = Vec::new();
+                            let futures = project_search.excerpts.update(cx, |excerpts, cx| {
+                                buffers_with_ranges
+                                    .into_iter()
+                                    .map(|(buffer, ranges)| {
+                                        let path_key = PathKey::for_buffer(&buffer, cx);
+                                        paths.push(path_key.clone());
+                                        excerpts.set_anchored_excerpts_for_path(
+                                            path_key,
+                                            buffer,
+                                            ranges,
+                                            multibuffer_context_lines(cx),
+                                            cx,
+                                        )
+                                    })
+                                    .collect::<FuturesOrdered<_>>()
+                            });
+                            (futures, paths)
+                        })
+                        .ok()?;
+                    seen_paths.extend(chunk_paths);
+                    while let Some(ranges) = chunk_ranges.next().await {
+                        smol::future::yield_now().await;
+                        project_search
+                            .update(cx, |project_search, cx| {
+                                project_search.match_ranges.extend(ranges);
+                                cx.notify();
+                            })
+                            .ok()?;
+                    }
+                    continue;
+                }
+
                 let mut new_ranges = project_search
                     .update(cx, |project_search, cx| {
                         project_search.excerpts.update(cx, |excerpts, cx| {
@@ -423,16 +471,43 @@ impl ProjectSearch {
                 }
             }
 
-            project_search
-                .update(cx, |project_search, cx| {
-                    if !project_search.match_ranges.is_empty() {
-                        project_search.no_results = Some(false);
-                    }
-                    project_search.limit_reached = limit_reached;
-                    project_search.pending_search.take();
-                    cx.notify();
-                })
-                .ok()?;
+            if incremental {
+                project_search
+                    .update(cx, |project_search, cx| {
+                        if seen_paths.is_empty() {
+                            project_search
+                                .excerpts
+                                .update(cx, |excerpts, cx| excerpts.clear(cx));
+                        } else {
+                            project_search.excerpts.update(cx, |excerpts, cx| {
+                                let stale = excerpts
+                                    .paths()
+                                    .filter(|path| !seen_paths.contains(*path))
+                                    .cloned()
+                                    .collect::<Vec<_>>();
+                                for path in stale {
+                                    excerpts.remove_excerpts_for_path(path, cx);
+                                }
+                            });
+                        }
+                        project_search.no_results = Some(project_search.match_ranges.is_empty());
+                        project_search.limit_reached = limit_reached;
+                        project_search.pending_search.take();
+                        cx.notify();
+                    })
+                    .ok()?;
+            } else {
+                project_search
+                    .update(cx, |project_search, cx| {
+                        if !project_search.match_ranges.is_empty() {
+                            project_search.no_results = Some(false);
+                        }
+                        project_search.limit_reached = limit_reached;
+                        project_search.pending_search.take();
+                        cx.notify();
+                    })
+                    .ok()?;
+            }
 
             None
         }));
@@ -739,7 +814,7 @@ impl ProjectSearchView {
             && self.query_editor.read(cx).text(cx) != *last_search_query_text
         {
             // search query has changed, restart search and bail
-            self.search(cx);
+            self.search(false, cx);
             return;
         }
         if self.entity.read(cx).match_ranges.is_empty() {
@@ -767,7 +842,7 @@ impl ProjectSearchView {
             && self.query_editor.read(cx).text(cx) != *last_search_query_text
         {
             // search query has changed, restart search and bail
-            self.search(cx);
+            self.search(false, cx);
             return;
         }
         if self.active_match_index.is_none() {
@@ -866,15 +941,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);
+                        }
+                    }
+
+                    let search_settings = &EditorSettings::get_global(cx).search;
+                    if search_settings.search_on_input {
+                        if this.query_editor.read(cx).is_empty(cx) {
+                            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;
+                                model.last_search_query_text = None;
+                                cx.notify();
+                            });
+                        } else {
+                            this.search(true, cx);
+                        }
                     }
                 }
                 cx.emit(ViewEvent::EditorEvent(event.clone()))
@@ -1060,7 +1152,7 @@ impl ProjectSearchView {
             if let Some(new_query) = new_query {
                 let entity = cx.new(|cx| {
                     let mut entity = ProjectSearch::new(workspace.project().clone(), cx);
-                    entity.search(new_query, cx);
+                    entity.search(new_query, false, cx);
                     entity
                 });
                 let weak_workspace = cx.entity().downgrade();
@@ -1213,14 +1305,14 @@ impl ProjectSearchView {
             };
             if should_search {
                 this.update(cx, |this, cx| {
-                    this.search(cx);
+                    this.search(false, cx);
                 })?;
             }
             anyhow::Ok(())
         })
     }
 
-    fn search(&mut self, cx: &mut Context<Self>) {
+    fn search(&mut self, incremental: bool, cx: &mut Context<Self>) {
         let open_buffers = if self.included_opened_only {
             self.workspace
                 .update(cx, |workspace, cx| self.open_buffers(cx, workspace))
@@ -1229,7 +1321,8 @@ impl ProjectSearchView {
             None
         };
         if let Some(query) = self.build_search_query(cx, open_buffers) {
-            self.entity.update(cx, |model, cx| model.search(query, cx));
+            self.entity
+                .update(cx, |model, cx| model.search(query, incremental, cx));
         }
     }
 
@@ -1491,10 +1584,15 @@ impl ProjectSearchView {
     }
 
     fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let match_ranges = self.entity.read(cx).match_ranges.clone();
+        let model = self.entity.read(cx);
+        let match_ranges = model.match_ranges.clone();
+        let is_incremental_pending =
+            model.pending_search.is_some() && EditorSettings::get_global(cx).search.search_on_input;
 
         if match_ranges.is_empty() {
-            self.active_match_index = None;
+            if !is_incremental_pending {
+                self.active_match_index = None;
+            }
             self.results_editor.update(cx, |editor, cx| {
                 editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
             });
@@ -1514,7 +1612,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);
             }
         }
@@ -1577,9 +1679,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")
@@ -2498,7 +2604,7 @@ pub fn perform_project_search(
         search_view.query_editor.update(cx, |query_editor, cx| {
             query_editor.set_text(text, window, cx)
         });
-        search_view.search(cx);
+        search_view.search(false, cx);
     });
     cx.run_until_parked();
 }
@@ -2506,7 +2612,9 @@ pub fn perform_project_search(
 #[cfg(test)]
 pub mod tests {
     use std::{
+        cell::RefCell,
         path::PathBuf,
+        rc::Rc,
         sync::{
             Arc,
             atomic::{self, AtomicUsize},
@@ -2518,6 +2626,7 @@ pub mod tests {
     use editor::{DisplayPoint, display_map::DisplayRow};
     use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
     use language::{FakeLspAdapter, rust_lang};
+    use multi_buffer::Event as MultiBufferEvent;
     use pretty_assertions::assert_eq;
     use project::FakeFs;
     use serde_json::json;
@@ -2528,6 +2637,155 @@ pub mod tests {
     use util_macros::perf;
     use workspace::{DeploySearch, MultiWorkspace};
 
+    #[derive(Debug, Clone, PartialEq, Eq)]
+    enum ExcerptEvent {
+        Added { excerpts: usize },
+        Removed { ids: usize },
+        Edited,
+    }
+
+    fn subscribe_to_excerpt_events(
+        search: &Entity<ProjectSearch>,
+        cx: &mut TestAppContext,
+    ) -> (Rc<RefCell<Vec<ExcerptEvent>>>, Subscription) {
+        let events: Rc<RefCell<Vec<ExcerptEvent>>> = Rc::default();
+        let excerpts = cx.update(|cx| search.read(cx).excerpts.clone());
+        let subscription = cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(
+                    &excerpts,
+                    move |_, event: &MultiBufferEvent, _| match event {
+                        MultiBufferEvent::ExcerptsAdded { excerpts, .. } => {
+                            events.borrow_mut().push(ExcerptEvent::Added {
+                                excerpts: excerpts.len(),
+                            });
+                        }
+                        MultiBufferEvent::ExcerptsRemoved { ids, .. } => {
+                            events
+                                .borrow_mut()
+                                .push(ExcerptEvent::Removed { ids: ids.len() });
+                        }
+                        MultiBufferEvent::Edited { .. } => {
+                            events.borrow_mut().push(ExcerptEvent::Edited);
+                        }
+                        _ => {}
+                    },
+                )
+            }
+        });
+        (events, subscription)
+    }
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings = SettingsStore::test(cx);
+            cx.set_global(settings);
+
+            theme::init(theme::LoadThemes::JustBase, cx);
+
+            editor::init(cx);
+            crate::init(cx);
+
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings
+                        .editor
+                        .search
+                        .get_or_insert_default()
+                        .search_on_input = Some(false);
+                });
+            });
+        });
+    }
+
+    fn perform_search(
+        search_view: WindowHandle<ProjectSearchView>,
+        text: impl Into<Arc<str>>,
+        cx: &mut TestAppContext,
+    ) {
+        search_view
+            .update(cx, |search_view, window, cx| {
+                search_view.query_editor.update(cx, |query_editor, cx| {
+                    query_editor.set_text(text, window, cx)
+                });
+                search_view.search(false, cx);
+            })
+            .unwrap();
+        // Ensure editor highlights appear after the search is done
+        cx.executor().advance_clock(
+            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
+        );
+        cx.background_executor.run_until_parked();
+    }
+
+    fn perform_incremental_search(
+        search_view: WindowHandle<ProjectSearchView>,
+        text: impl Into<Arc<str>>,
+        cx: &mut TestAppContext,
+    ) {
+        search_view
+            .update(cx, |search_view, window, cx| {
+                search_view.query_editor.update(cx, |query_editor, cx| {
+                    query_editor.set_text(text, window, cx)
+                });
+                search_view.search(true, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(
+            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
+        );
+        cx.background_executor.run_until_parked();
+    }
+
+    fn read_match_count(
+        search_view: WindowHandle<ProjectSearchView>,
+        cx: &mut TestAppContext,
+    ) -> usize {
+        search_view
+            .read_with(cx, |search_view, cx| {
+                search_view.entity.read(cx).match_ranges.len()
+            })
+            .unwrap()
+    }
+
+    fn read_match_texts(
+        search_view: WindowHandle<ProjectSearchView>,
+        cx: &mut TestAppContext,
+    ) -> Vec<String> {
+        search_view
+            .read_with(cx, |search_view, cx| {
+                let search = search_view.entity.read(cx);
+                let snapshot = search.excerpts.read(cx).snapshot(cx);
+                search
+                    .match_ranges
+                    .iter()
+                    .map(|range| snapshot.text_for_range(range.clone()).collect::<String>())
+                    .collect()
+            })
+            .unwrap()
+    }
+
+    fn assert_all_highlights_match_query(
+        search_view: WindowHandle<ProjectSearchView>,
+        query: &str,
+        cx: &mut TestAppContext,
+    ) {
+        let match_texts = read_match_texts(search_view, cx);
+        assert_eq!(
+            match_texts.len(),
+            read_match_count(search_view, cx),
+            "match texts count should equal match_ranges count for query {query:?}"
+        );
+        for text in &match_texts {
+            assert_eq!(
+                text.to_uppercase(),
+                query.to_uppercase(),
+                "every highlighted range should match the query {query:?}"
+            );
+        }
+    }
+
     #[test]
     fn test_split_glob_patterns() {
         assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
@@ -3073,7 +3331,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3117,7 +3375,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("TWO", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3270,7 +3528,7 @@ pub mod tests {
                         .update(cx, |exclude_editor, cx| {
                             exclude_editor.set_text("four.rs", window, cx)
                         });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3300,7 +3558,7 @@ pub mod tests {
             .update(cx, |_, _, cx| {
                 search_view.update(cx, |search_view, cx| {
                     search_view.toggle_filters(cx);
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3427,7 +3685,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3472,7 +3730,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("TWO", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 })
             })
             .unwrap();
@@ -3572,7 +3830,7 @@ pub mod tests {
                     search_view_2.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("FOUR", window, cx)
                     });
-                    search_view_2.search(cx);
+                    search_view_2.search(false, cx);
                 });
             })
             .unwrap();
@@ -3718,7 +3976,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("const", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3793,7 +4051,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("ONE", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3805,7 +4063,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("TWO", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -3816,7 +4074,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("THREE", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 })
             })
             .unwrap();
@@ -3973,7 +4231,7 @@ pub mod tests {
                     search_view.query_editor.update(cx, |query_editor, cx| {
                         query_editor.set_text("TWO_NEW", window, cx)
                     });
-                    search_view.search(cx);
+                    search_view.search(false, cx);
                 });
             })
             .unwrap();
@@ -4187,7 +4445,7 @@ pub mod tests {
                             search_view.query_editor.update(cx, |query_editor, cx| {
                                 query_editor.set_text(query, window, cx)
                             });
-                            search_view.search(cx);
+                            search_view.search(false, cx);
                         });
                     })
                     .unwrap();
@@ -4887,35 +5145,166 @@ pub mod tests {
             .unwrap();
     }
 
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings = SettingsStore::test(cx);
-            cx.set_global(settings);
+    #[gpui::test]
+    async fn test_incremental_search_narrows_and_widens(cx: &mut TestAppContext) {
+        init_test(cx);
 
-            theme::init(theme::LoadThemes::JustBase, cx);
+        // Two matches 5 lines apart: with context_line_count=2, contexts
+        // [3..7] and [8..12] are adjacent and merge into a single excerpt
+        // [3..12]. Narrowing to "targeted" produces context [3..7] ⊂ [3..12]
+        // — the expand_new_ranges_to_existing fix ensures zero excerpt events.
+        let mut lines: Vec<String> = (0..20).map(|i| format!("line {i}: filler")).collect();
+        lines[5] = "line 5: targeted item".into();
+        lines[10] = "line 10: target item".into();
+        let big_file = lines.join("\n");
 
-            editor::init(cx);
-            crate::init(cx);
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            path!("/dir"),
+            json!({
+                "one.rs": "const ONE: usize = 1;\nconst ONEROUS: usize = 2;",
+                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
+                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
+                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
+                "only_one.rs": "const ONLY_ONE: usize = 1;",
+                "big.txt": big_file,
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let window =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
+        let search_view = cx.add_window(|window, cx| {
+            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
         });
-    }
+        let (events, _subscription) = subscribe_to_excerpt_events(&search, cx);
+        let take_excerpt_changes = || -> Vec<ExcerptEvent> {
+            events
+                .borrow_mut()
+                .drain(..)
+                .filter(|e| !matches!(e, ExcerptEvent::Edited))
+                .collect()
+        };
+        let expected_one_matches = vec![
+            "one", "ONE", "ONE", "ONE", "ONE", "one", "ONE", "one", "ONE", "one", "ONE",
+        ];
 
-    fn perform_search(
-        search_view: WindowHandle<ProjectSearchView>,
-        text: impl Into<Arc<str>>,
-        cx: &mut TestAppContext,
-    ) {
-        search_view
-            .update(cx, |search_view, window, cx| {
-                search_view.query_editor.update(cx, |query_editor, cx| {
-                    query_editor.set_text(text, window, cx)
-                });
-                search_view.search(cx);
-            })
-            .unwrap();
-        // Ensure editor highlights appear after the search is done
-        cx.executor().advance_clock(
-            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
+        // Initial non-incremental search for "ONE" — clears then inserts one excerpt per file.
+        perform_search(search_view, "ONE", cx);
+        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
+        assert_all_highlights_match_query(search_view, "ONE", cx);
+        assert_eq!(
+            take_excerpt_changes(),
+            vec![
+                ExcerptEvent::Removed { ids: 0 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+            ]
         );
-        cx.background_executor.run_until_parked();
+
+        // Natural narrowing: typing "R" after "ONE" -> "ONER".
+        // Only one.rs has ONEROUS, 4 other files removed.
+        perform_incremental_search(search_view, "ONER", cx);
+        assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
+        assert_all_highlights_match_query(search_view, "ONER", cx);
+        assert_eq!(
+            take_excerpt_changes(),
+            vec![
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+            ]
+        );
+
+        // Continue typing "OUS" -> "ONEROUS". Still one.rs only, zero excerpt churn.
+        perform_incremental_search(search_view, "ONEROUS", cx);
+        assert_eq!(read_match_texts(search_view, cx), vec!["ONEROUS"]);
+        assert_all_highlights_match_query(search_view, "ONEROUS", cx);
+        assert_eq!(take_excerpt_changes(), Vec::new());
+
+        // Backspace to "ONER" — still one.rs only, zero events.
+        perform_incremental_search(search_view, "ONER", cx);
+        assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
+        assert_all_highlights_match_query(search_view, "ONER", cx);
+        assert_eq!(take_excerpt_changes(), Vec::new());
+
+        // Backspace to "ONE" — 4 files re-added.
+        perform_incremental_search(search_view, "ONE", cx);
+        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
+        assert_all_highlights_match_query(search_view, "ONE", cx);
+        assert_eq!(
+            take_excerpt_changes(),
+            vec![
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+            ]
+        );
+
+        // Repeat the same "ONE" query — excerpts already match, zero events emitted.
+        perform_incremental_search(search_view, "ONE", cx);
+        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
+        assert_all_highlights_match_query(search_view, "ONE", cx);
+        assert_eq!(events.borrow().len(), 0);
+
+        // Narrow to "ONLY_ONE" — single match in only_one.rs, 4 files removed.
+        perform_incremental_search(search_view, "ONLY_ONE", cx);
+        assert_eq!(read_match_texts(search_view, cx), vec!["ONLY_ONE"]);
+        assert_all_highlights_match_query(search_view, "ONLY_ONE", cx);
+        assert_eq!(
+            take_excerpt_changes(),
+            vec![
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+                ExcerptEvent::Removed { ids: 1 },
+            ]
+        );
+
+        // Widen back to "ONE" — 4 files re-added.
+        perform_incremental_search(search_view, "ONE", cx);
+        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
+        assert_all_highlights_match_query(search_view, "ONE", cx);
+        assert_eq!(
+            take_excerpt_changes(),
+            vec![
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+                ExcerptEvent::Added { excerpts: 1 },
+            ]
+        );
+
+        // Narrowing when all files still match — zero excerpt events.
+        // "usize" matches all 5 .rs files; "usize =" is narrower but still in every file.
+        perform_search(search_view, "usize", cx);
+        assert_eq!(read_match_count(search_view, cx), 6);
+        events.borrow_mut().clear();
+        perform_incremental_search(search_view, "usize =", cx);
+        assert_eq!(read_match_count(search_view, cx), 6);
+        assert_eq!(events.borrow().len(), 0);
+
+        // Merged-excerpt narrowing: "target" matches lines 5 and 10 in big.txt,
+        // whose context lines merge into one excerpt [3..12]. Narrowing to
+        // "targeted" shrinks context to [3..7] ⊂ [3..12] — the existing excerpt
+        // must be kept with zero events.
+        perform_search(search_view, "target", cx);
+        assert_all_highlights_match_query(search_view, "target", cx);
+        assert_eq!(read_match_count(search_view, cx), 2);
+        take_excerpt_changes();
+
+        perform_incremental_search(search_view, "targeted", cx);
+        assert_all_highlights_match_query(search_view, "targeted", cx);
+        assert_eq!(read_match_count(search_view, cx), 1);
+        assert_eq!(take_excerpt_changes(), Vec::new());
     }
 }

crates/settings/src/vscode_import.rs 🔗

@@ -357,7 +357,8 @@ impl VsCodeSettings {
     fn search_content(&self) -> Option<SearchSettingsContent> {
         skip_default(SearchSettingsContent {
             include_ignored: self.read_bool("search.useIgnoreFiles"),
-            ..Default::default()
+            search_on_input: self.read_bool("search.searchOnType"),
+            ..SearchSettingsContent::default()
         })
     }
 

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 in project search.
+    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 in project search.",
+                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 🔗

@@ -3279,13 +3279,7 @@ Non-negative `integer` values
 
 - Description: Whether to search on input in project search.
 - Setting: `search_on_input`
-- Default: `false`
-
-### Search On Input Debounce Ms
-
-- Description: Debounce time in milliseconds for search on input in project search. Set to 0 to disable debouncing.
-- Setting: `search_on_input_debounce_ms`
-- Default: `200`
+- Default: `true`
 
 ### Center On Match