diff --git a/assets/settings/default.json b/assets/settings/default.json index 68a9f2324912db7c1724c81cefd60ecbc41bf4b1..744b28c75b4e0aa9045348baac058a9ef75e6ca9 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -680,6 +680,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 548053da7d794de83d99afdfddb098e4cfb2b18e..fc4988a50efc020b5db163a423282f4945bf2f28 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -179,6 +179,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 { @@ -278,6 +280,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/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 724f659e44e8153a9ef5b1a01b6b66529ab3a3e2..419941da308f4502211991e0f2eba79b7f3bdaf4 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1762,7 +1762,7 @@ impl MultiBuffer { } #[instrument(skip_all)] - fn merge_excerpt_ranges<'a>( + pub fn merge_excerpt_ranges<'a>( expanded_ranges: impl IntoIterator> + 'a, ) -> Vec> { let mut sorted: Vec<_> = expanded_ranges.into_iter().collect(); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 5c2123d0f9c1b09c16fd99531973df81c45140f7..3df0236dd54058a089c43d39579e8d8281a290ac 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -100,7 +100,7 @@ impl MultiBuffer { let merged = Self::merge_excerpt_ranges(&excerpt_ranges); let (inserted, _path_key_index) = - self.set_merged_excerpt_ranges_for_path(path, buffer, &buffer_snapshot, merged, cx); + let excerpt_insertion_result = self.set_merged_excerpt_ranges_for_path(path, buffer, &buffer_snapshot, merged, cx); inserted } @@ -367,7 +367,77 @@ impl MultiBuffer { index } - pub fn update_path_excerpts( + /// 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_ranges, result_counts) + } + + fn update_path_excerpts( &mut self, path_key: PathKey, buffer: Entity, diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 4213aa39a046e944cd34f9a1530bd15d1c442863..f3dcd1f3b7837e69e1979408a5abe2d644a22062 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -53,6 +53,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 3a5fbe3fcae6241495deb43930b83bb78ba81968..070efa169cc4dcd4dd205cce80ea11b049670018 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -3616,6 +3616,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_input: false, }, cx, ); @@ -3679,6 +3680,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_input: false, }, cx, ); @@ -3717,6 +3719,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_input: false, }, cx, ); @@ -3799,6 +3802,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 7e7903674e3d883bfb98ac8d57b5f407237f66d1..2965e221372317ed121bf8ea4a4c0ee41cbfabca 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, @@ -237,6 +237,7 @@ pub struct ProjectSearch { search_id: usize, no_results: Option, limit_reached: bool, + search_input_confirmed: bool, search_history_cursor: SearchHistoryCursor, search_included_history_cursor: SearchHistoryCursor, search_excluded_history_cursor: SearchHistoryCursor, @@ -299,6 +300,7 @@ impl ProjectSearch { search_id: 0, no_results: None, limit_reached: false, + search_input_confirmed: false, search_history_cursor: Default::default(), search_included_history_cursor: Default::default(), search_excluded_history_cursor: Default::default(), @@ -323,10 +325,10 @@ impl ProjectSearch { search_id: self.search_id, no_results: self.no_results, limit_reached: self.limit_reached, - search_history_cursor: self.search_history_cursor.clone(), - search_included_history_cursor: self.search_included_history_cursor.clone(), - search_excluded_history_cursor: self.search_excluded_history_cursor.clone(), - _excerpts_subscription: subscription, + search_input_confirmed: self.search_input_confirmed, + search_history_cursor: self.search_history_cursor.clone(), + search_included_history_cursor: self.search_included_history_cursor.clone(), + search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),_excerpts_subscription: subscription, } }) } @@ -387,7 +389,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) @@ -410,22 +412,27 @@ impl ProjectSearch { self.search_id += 1; self.active_query = Some(query); self.match_ranges.clear(); + self.search_input_confirmed = !incremental; self.pending_search = Some(cx.spawn(async move |project_search, cx| { 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() @@ -446,6 +453,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| { @@ -477,16 +528,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 })); @@ -807,7 +885,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() { @@ -841,7 +919,7 @@ impl ProjectSearchView { self.entity.read(cx).last_search_query_text.as_deref() != Some(query_text.as_str()); if query_is_stale { self.pending_replace_all = true; - self.search(cx); + self.search(false, cx); if self.entity.read(cx).pending_search.is_none() { self.pending_replace_all = false; } @@ -946,15 +1024,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())) @@ -1141,7 +1236,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(); @@ -1315,14 +1410,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)) @@ -1331,7 +1426,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)); } } @@ -1527,23 +1623,44 @@ impl ProjectSearchView { ) }); - let range_to_select = match_ranges[new_index].clone(); - self.results_editor.update(cx, |editor, cx| { - let range_to_select = editor.range_for_match(&range_to_select); - let autoscroll = if EditorSettings::get_global(cx).search.center_on_match { - Autoscroll::center() - } else { - Autoscroll::fit() - }; - editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx); - editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { - s.select_ranges([range_to_select]) - }); - }); - self.highlight_matches(&match_ranges, Some(new_index), cx); + self.select_match_range(&match_ranges, new_index, window, cx); } } + fn select_first_match(&mut self, window: &mut Window, cx: &mut Context) { + self.entity.clone().update(cx, |entity, cx| { + if !entity.match_ranges.is_empty() { + self.active_match_index = Some(0); + self.select_match_range(&entity.match_ranges, 0, window, cx); + } + }) + } + + fn select_match_range( + &mut self, + match_ranges: &[Range], + index: usize, + window: &mut Window, + cx: &mut App, + ) { + let Some(range) = match_ranges.get(index) else { + return; + }; + self.results_editor.update(cx, |editor, cx| { + let range_to_select = editor.range_for_match(range); + let autoscroll = if EditorSettings::get_global(cx).search.center_on_match { + Autoscroll::center() + } else { + Autoscroll::fit() + }; + editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx); + editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { + s.select_ranges([range_to_select]) + }); + }); + self.highlight_matches(match_ranges, Some(index), cx); + } + fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context) { self.query_editor.update(cx, |query_editor, cx| { query_editor.select_all(&SelectAll, window, cx); @@ -1594,10 +1711,16 @@ 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 search_input_confirmed = model.search_input_confirmed; + 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); }); @@ -1617,7 +1740,12 @@ 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 = + search_input_confirmed || !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); } } @@ -1684,9 +1812,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") @@ -1823,12 +1955,28 @@ impl ProjectSearchBar { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(search_view) = self.active_project_search.as_ref() { search_view.update(cx, |search_view, cx| { - if !search_view + if search_view .replacement_editor .focus_handle(cx) .is_focused(window) { - cx.stop_propagation(); + return; + } + + cx.stop_propagation(); + if EditorSettings::get_global(cx).search.search_on_input { + // Results are already available from incremental search. + // Mark confirmed so entity_changed auto-focuses when + // a pending search completes, and select the first match + // if results already exist. + search_view + .entity + .update(cx, |model, _| model.search_input_confirmed = true); + if search_view.has_matches() { + search_view.select_first_match(window, cx); + search_view.focus_results_editor(window, cx); + } + } else { search_view .prompt_to_save_if_dirty_then_search(window, cx) .detach_and_log_err(cx); @@ -2619,7 +2767,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(); } @@ -2627,7 +2775,9 @@ pub fn perform_project_search( #[cfg(test)] pub mod tests { use std::{ + cell::RefCell, path::PathBuf, + rc::Rc, sync::{ Arc, atomic::{self, AtomicUsize}, @@ -2639,6 +2789,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, Fs}; use serde_json::json; @@ -2649,6 +2800,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"]); @@ -3200,7 +3500,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(); @@ -3244,7 +3544,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(); @@ -3397,7 +3697,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(); @@ -3427,7 +3727,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(); @@ -3554,7 +3854,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(); @@ -3599,7 +3899,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(); @@ -3699,7 +3999,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(); @@ -3845,7 +4145,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(); @@ -3920,7 +4220,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(); @@ -3932,7 +4232,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(); @@ -3943,7 +4243,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(); @@ -4106,7 +4406,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(); @@ -4384,7 +4684,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(); @@ -5434,35 +5734,312 @@ pub mod tests { }); } - fn init_test(cx: &mut TestAppContext) { + #[gpui::test] + async fn test_incremental_search_narrows_and_widens(cx: &mut TestAppContext) { + init_test(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"); + + 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", + ]; + + // 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 }, + ] + ); + + // 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()); + } + + #[gpui::test] + async fn test_search_on_input_keeps_focus_confirm_shifts_it(cx: &mut TestAppContext) { + init_test(cx); cx.update(|cx| { - let settings = SettingsStore::test(cx); - cx.set_global(settings); + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .editor + .search + .get_or_insert_default() + .search_on_input = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); theme_settings::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - crate::init(cx); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) }); - } - 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) + let search_view = cx + .read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) + .expect("Search view expected to appear after new search event trigger"); + + // Type into the query editor — with search_on_input the EditorEvent::Edited + // subscriber triggers an incremental search. The query editor must stay focused. + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("ONE", 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), - ); cx.background_executor.run_until_parked(); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + !search_view.entity.read(cx).match_ranges.is_empty(), + "Incremental search should have found matches", + ); + assert!( + search_view.query_editor.focus_handle(cx).is_focused(window), + "Query editor should remain focused after incremental search with search_on_input", + ); + assert!( + !search_view.results_editor.focus_handle(cx).is_focused(window), + "Results editor should not be focused after incremental search with search_on_input", + ); + }); + }) + .unwrap(); + + // Move the results editor cursor to the end of the buffer so it's + // far away from the first match, then re-focus the query editor. + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.results_editor.update(cx, |editor, cx| { + editor.move_to_end(&editor::actions::MoveToEnd, window, cx); + }); + let ranges = search_view.results_editor.update(cx, |editor, cx| { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) + }); + assert_eq!(ranges.len(), 1); + assert_ne!( + ranges[0].start, + DisplayPoint::new(DisplayRow(0), 0), + "Cursor should be past the start of the buffer after MoveToEnd", + ); + search_view.query_editor.focus_handle(cx).focus(window, cx); + }); + }) + .unwrap(); + + // Press Enter (Confirm) via the search bar — this should shift focus + // to the results editor with the first match selected, regardless of + // where the cursor was before. + cx.dispatch_action(Confirm); + cx.background_executor.run_until_parked(); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + !search_view.entity.read(cx).match_ranges.is_empty(), + "Confirm search should have found matches", + ); + assert!( + search_view.results_editor.focus_handle(cx).is_focused(window), + "Results editor should be focused after confirming search with search_on_input", + ); + assert!( + !search_view.query_editor.focus_handle(cx).is_focused(window), + "Query editor should not be focused after confirming search with search_on_input", + ); + assert_eq!( + search_view.active_match_index, + Some(0), + "First match should be selected after confirm", + ); + let results_editor = search_view.results_editor.read(cx); + let selection = results_editor.selections.newest_anchor(); + let snapshot = results_editor.buffer().read(cx).snapshot(cx); + let selected_text = snapshot + .text_for_range(selection.start..selection.end) + .collect::(); + assert_eq!( + selected_text, "ONE", + "First match text should be selected after confirm", + ); + }); + }) + .unwrap(); } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c83e56577373aa9834f76b3c32488a069844d249..65d41b00e203c30a81627da956a64cdbe4e8f1ef 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -362,7 +362,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 60c2686c084ba428992dfc82a9c18b6c24860a66..868f4716674e29a36fbbfb2710453b4d3441a360 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -852,6 +852,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 f3f44a754035f1b4531f8a0b987e26981c8963df..2c3aabbd7865e2e6294b137354c2721192ea0955 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3090,7 +3090,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 { @@ -3224,6 +3224,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.",