diff --git a/Cargo.lock b/Cargo.lock index 7bd267cdcfae63bbb0aaf7d595345fee805fe3cb..02a5e6fa1081e8893e88c9b2cd9eac17784287ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14802,6 +14802,7 @@ dependencies = [ "language", "lsp", "menu", + "multi_buffer", "pretty_assertions", "project", "serde", diff --git a/assets/settings/default.json b/assets/settings/default.json index f45e9f541746363c08f4d41ff9df53c292d3ee30..fd82db12473d23e0df0df6c2b1328d3ec9af7cc8 100644 --- a/assets/settings/default.json +++ b/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: diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 47210a7561f4a3ebbddb994f00afcaff9158254d..ffd5d323c0f303986ba5653410f22ae0e81fffcc 100644 --- a/crates/editor/src/editor_settings.rs +++ b/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(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3ae4713ee4ce337b0d535a89da4c1246a024f6ad..faf6b6ddf3cdf2cd7432a79374ea792dd08f410e 100644 --- a/crates/editor/src/element.rs +++ b/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); diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index ffe094f38f037978c4de1cebbe923c4845f32877..8fbd264684efa070fafd1ff6d55d0f199d2dc626 100644 --- a/crates/editor/src/split.rs +++ b/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::>(); - 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::>(); + 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> = { - 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 = - 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( diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 15d7b9f3610eaf9e9063c7da95e915c73f95a341..e2335497939b706e4d531fd33b3cf52f39d87154 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/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> + 'a, ) -> (Vec>, Vec) { let mut merged_ranges: Vec> = Vec::new(); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index eb65da13eeaae70af96e74844f6420b3584330ad..9da791ccafa7472e1e6b9716812a2cd51961e6f7 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -15,6 +15,7 @@ use crate::{ #[derive(Debug, Clone)] pub struct PathExcerptInsertResult { + pub inserted_ranges: Vec>, pub excerpt_ids: Vec, 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, ) -> (Vec>, 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, @@ -273,36 +282,110 @@ impl MultiBuffer { new: Vec>, counts: Vec, cx: &mut Context, - ) -> (Vec>, 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>, + counts: Vec, + cx: &App, + ) -> (Vec>, Vec) { + 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> = 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> = Vec::new(); + let mut result_counts: Vec = 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_snapshot: &BufferSnapshot, new: Vec>, cx: &mut Context, - ) -> PathExcerptInsertResult { + ) -> (Vec, 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) } } diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 9613bd720919d77f2e7c9421ed51a0b18edf7355..d4a962523b9b7b43873d8c347ba8db42f0f15ac1 100644 --- a/crates/search/Cargo.toml +++ b/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"] } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 7112fad656485eb45244aecc4dc685f93273cb6e..95c5ccae1557d2c8a78c2faeb2f4bf41a9655cd3 100644 --- a/crates/search/src/buffer_search.rs +++ b/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), }); }); }); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 9b23c96259e4933bc1660af960b508c0678fe767..c11b793e2e86c0f08ed0641c7600e6205a90ad09 100644 --- a/crates/search/src/project_search.rs +++ b/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) { + fn search(&mut self, query: SearchQuery, incremental: bool, cx: &mut Context) { 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::>() + }); + (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::>(); + 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) { + fn search(&mut self, incremental: bool, cx: &mut Context) { 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) { - 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, + cx: &mut TestAppContext, + ) -> (Rc>>, Subscription) { + let events: Rc>> = 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, + text: impl Into>, + 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, + text: impl Into>, + 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, + 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, + cx: &mut TestAppContext, + ) -> Vec { + 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::()) + .collect() + }) + .unwrap() + } + + fn assert_all_highlights_match_query( + search_view: WindowHandle, + 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 = (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 { + 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, - text: impl Into>, - 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()); } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d0643be3bbee82be02c9c461a5f18ba62893a3cd..13252b1366a91d65eae318d48c1657f1feb79dbe 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -357,7 +357,8 @@ impl VsCodeSettings { fn search_content(&self) -> Option { skip_default(SearchSettingsContent { include_ignored: self.read_bool("search.useIgnoreFiles"), - ..Default::default() + search_on_input: self.read_bool("search.searchOnType"), + ..SearchSettingsContent::default() }) } diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 4d824e85e0e2ee020f48cdddb530bf494b2ce800..aee50c9df8ba55e49d55c4148e94c57de629fd0b 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -828,6 +828,8 @@ pub struct SearchSettingsContent { pub regex: Option, /// Whether to center the cursor on each search match when navigating. pub center_on_match: Option, + /// Whether to search on input in project search. + pub search_on_input: Option, } #[with_fallible_options] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 738eff917bc57a7a2543f9c31494af02883299d1..d3ccc428408a7b59e4f833f36bc7f45d1164b101 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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.", diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index b32d75b9ab5cdc654f13f01f4b08f36b6e03f6b5..45bd5a23ac2be11f020d6ead9e0cdd04a049ad8c 100644 --- a/docs/src/reference/all-settings.md +++ b/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