Cargo.lock 🔗
@@ -14802,6 +14802,7 @@ dependencies = [
"language",
"lsp",
"menu",
+ "multi_buffer",
"pretty_assertions",
"project",
"serde",
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>
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(-)
@@ -14802,6 +14802,7 @@ dependencies = [
"language",
"lsp",
"menu",
+ "multi_buffer",
"pretty_assertions",
"project",
"serde",
@@ -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:
@@ -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(),
@@ -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);
@@ -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(
@@ -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();
@@ -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)
}
}
@@ -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"] }
@@ -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),
});
});
});
@@ -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());
}
}
@@ -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()
})
}
@@ -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]
@@ -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.",
@@ -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