From 28c7f0cd8e21274d259435538e0cc43ba467bdde Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 26 Aug 2026 14:33:03 +0000 Subject: [PATCH] Enable project search on type by default (#62506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/9318 Most of the diff is tests. Notable behavior changes during this mode, compared to current mode: * unsaved changes in multi buffer won't spawn any "save or not" pop-ups during typing — submitting the query still does it, as before. Unsaved files will stay so after being sorted out on typing: we cannot save them by default as file-less buffers need a save modal; Zed should already be able to persist and restore such changes on restart. * gutter may jump during search, if new excerpts with large line numbers appear during query input — yet it would not shrink back to avoid excessive flickering and rewraps Release Notes: - Enabled project search on type by default (use `{ "search": { "search_on_type": false } }` to restore the previous behavior) --- Cargo.lock | 1 + assets/settings/default.json | 2 + crates/editor/src/display_map.rs | 1 - crates/editor/src/display_map/wrap_map.rs | 44 +- crates/editor/src/editor.rs | 132 +- crates/editor/src/editor_settings.rs | 3 + crates/editor/src/editor_tests.rs | 59 + crates/editor/src/element.rs | 31 +- crates/multi_buffer/src/multi_buffer.rs | 2 +- crates/multi_buffer/src/multi_buffer_tests.rs | 309 ++ crates/multi_buffer/src/path_key.rs | 194 +- crates/project/src/project_search.rs | 77 +- .../remote_server/src/remote_editing_tests.rs | 143 +- crates/search/Cargo.toml | 1 + crates/search/src/buffer_search.rs | 4 + crates/search/src/project_search.rs | 3628 ++++++++++++++++- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 2 + crates/settings_ui/src/page_data.rs | 26 +- docs/src/reference/all-settings.md | 9 +- typos.toml | 2 + 21 files changed, 4459 insertions(+), 212 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e76b343b41..df66861ba2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16063,6 +16063,7 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "log", "lsp", "menu", "multi_buffer", diff --git a/assets/settings/default.json b/assets/settings/default.json index 2ab51bb8d7..2a8db0f99b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -767,6 +767,8 @@ "regex": false, // Whether to center the cursor on each search match when navigating. "center_on_match": false, + // Start searching as you type in project search, without pressing Enter. + "search_on_type": 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/display_map.rs b/crates/editor/src/display_map.rs index 1d937ad057..dfef0499bb 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1371,7 +1371,6 @@ impl DisplayMap { } } - #[cfg(test)] pub fn is_rewrapping(&self, cx: &gpui::App) -> bool { self.wrap_map.read(cx).is_rewrapping() } diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index c2b559a03d..27177a0297 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -203,9 +203,8 @@ impl WrapMap { (handle, snapshot) } - #[cfg(test)] pub fn is_rewrapping(&self) -> bool { - self.background_task.is_some() + self.background_task.is_some() || (self.wrap_width.is_some() && self.snapshot.interpolated) } #[ztracing::instrument(skip_all)] @@ -225,6 +224,13 @@ impl WrapMap { self.snapshot.interpolated = false; } + debug_assert!( + self.background_task.is_some() + || self.wrap_width.is_none() + || !self.snapshot.interpolated, + "an interpolated snapshot must always have a background task rewrapping it, \ + otherwise is_rewrapping never settles and frozen scrollbar ranges leak" + ); (self.snapshot.clone(), mem::take(&mut self.edits_since_sync)) } @@ -368,7 +374,7 @@ impl WrapMap { if let Some(wrap_width) = self.wrap_width && self.background_task.is_none() { - let mut pending_edits = self.pending_edits.clone(); + let pending_edits = self.pending_edits.clone(); let mut snapshot = self.snapshot.clone(); let text_system = cx.text_system().clone(); let (font, font_size) = self.font_with_size.clone(); @@ -376,20 +382,24 @@ impl WrapMap { LineFragmentBuilder::new(text_system.clone(), &font, font_size); let mut line_wrapper = text_system.line_wrapper(font, font_size); - if pending_edits.len() == 1 - && let Some((_, tab_edits)) = pending_edits.back() - && let [edit] = &**tab_edits - && ((edit.new.end.row().saturating_sub(edit.new.start.row()) + 1) as usize) - < WRAP_YIELD_ROW_INTERVAL - && let Some((tab_snapshot, tab_edits)) = pending_edits.pop_back() - { - let wrap_edits = gpui::block_on(snapshot.update( - tab_snapshot, - &tab_edits, - wrap_width, - &mut line_wrapper, - &mut fragment_builder, - )); + let update_passes = pending_edits.len(); + let total_new_rows = pending_edits + .iter() + .flat_map(|(_, tab_edits)| tab_edits.iter()) + .map(|edit| (edit.new.end.row().saturating_sub(edit.new.start.row()) + 1) as usize) + .sum::(); + if update_passes + total_new_rows < WRAP_YIELD_ROW_INTERVAL { + let mut wrap_edits = Patch::default(); + for (tab_snapshot, tab_edits) in pending_edits { + let edits = gpui::block_on(snapshot.update( + tab_snapshot, + &tab_edits, + wrap_width, + &mut line_wrapper, + &mut fragment_builder, + )); + wrap_edits = wrap_edits.compose(&edits); + } self.snapshot = snapshot; self.edits_since_sync = self.edits_since_sync.compose(&wrap_edits); } else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3d4c27d96f..3f94f90d61 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -915,6 +915,28 @@ struct ActionFetchReady { actions: Rc<[AvailableCodeAction]>, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum GutterLineNumberWidth { + #[default] + Dynamic, + Sticky { + min_digits: usize, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct ScrollRangeHold { + held: bool, + settled: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct SettledScrollRange { + range: Size, + editor_width: Pixels, + editor_bounds_size: Size, +} + /// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`]. /// /// See the [module level documentation](self) for more information. @@ -980,6 +1002,8 @@ pub struct Editor { enable_runnables: bool, enable_code_lens: bool, enable_mouse_wheel_zoom: bool, + gutter_line_number_width: GutterLineNumberWidth, + scroll_range_hold: Option, show_line_numbers: Option, use_relative_line_numbers: Option, show_git_diff_gutter: Option, @@ -1207,6 +1231,7 @@ pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, offset_content: bool, + gutter_line_number_width: GutterLineNumberWidth, show_line_numbers: Option, number_deleted_lines: bool, show_git_diff_gutter: Option, @@ -2302,6 +2327,8 @@ impl Editor { offset_content: !matches!(mode, EditorMode::SingleLine), breadcrumbs_visibility: BreadcrumbsVisibility::from_settings(cx), show_gutter: full_mode, + gutter_line_number_width: GutterLineNumberWidth::Dynamic, + scroll_range_hold: None, show_line_numbers: (!full_mode).then_some(false), use_relative_line_numbers: None, disable_expand_excerpt_buttons: !full_mode, @@ -3032,6 +3059,7 @@ impl Editor { mode: self.mode.clone(), show_gutter: self.show_gutter, offset_content: self.offset_content, + gutter_line_number_width: self.gutter_line_number_width, show_line_numbers: self.show_line_numbers, number_deleted_lines: self.number_deleted_lines, show_git_diff_gutter: self.show_git_diff_gutter, @@ -3091,6 +3119,99 @@ impl Editor { self.in_project_search = in_project_search; } + /// Lets the gutter grow to fit the widest line number seen but never shrink, + /// until [`Editor::reset_gutter_line_number_width`] is called. + pub fn enable_sticky_gutter_line_number(&mut self, cx: &mut Context) { + if self.gutter_line_number_width == GutterLineNumberWidth::Dynamic { + self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: 0 }; + self.latch_gutter_line_number_width(cx); + } + } + + pub fn reset_gutter_line_number_width(&mut self, cx: &mut Context) { + if matches!( + self.gutter_line_number_width, + GutterLineNumberWidth::Sticky { min_digits } if min_digits != 0 + ) { + self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: 0 }; + cx.notify(); + } + } + + /// Shrinks the sticky gutter width down to fit the current content and keeps latching from + /// there, so a settled search snaps to its real width instead of staying stuck at the widest + /// line number seen mid-typing. + pub fn refit_gutter_line_number_width(&mut self, cx: &mut Context) { + let GutterLineNumberWidth::Sticky { min_digits } = self.gutter_line_number_width else { + return; + }; + let digits = self.widest_line_number_digits(cx); + if digits != min_digits { + self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits }; + cx.notify(); + } + } + + fn latch_gutter_line_number_width(&mut self, cx: &mut Context) { + let GutterLineNumberWidth::Sticky { min_digits } = self.gutter_line_number_width else { + return; + }; + let digits = self.widest_line_number_digits(cx); + if digits > min_digits { + self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits }; + cx.notify(); + } + } + + fn widest_line_number_digits(&self, cx: &App) -> usize { + let snapshot = self.buffer.read(cx).read(cx); + if snapshot.is_empty() { + return 0; + } + (snapshot.widest_line_number().max(1).ilog10() + 1) as usize + } + + #[cfg(any(test, feature = "test-support"))] + pub fn min_gutter_line_number_digits(&self) -> Option { + match self.gutter_line_number_width { + GutterLineNumberWidth::Dynamic => None, + GutterLineNumberWidth::Sticky { min_digits } => Some(min_digits), + } + } + + pub fn hold_scrollbar_range(&mut self, hold: bool) { + self.scroll_range_hold.get_or_insert_default().held = hold; + } + + #[cfg(any(test, feature = "test-support"))] + pub fn is_scrollbar_range_held(&self) -> Option { + Some(self.scroll_range_hold?.held) + } + + fn frozen_scroll_range( + &mut self, + is_rewrapping: bool, + current_range: Size, + current_editor_width: Pixels, + current_editor_bounds_size: Size, + ) -> Option { + let hold = self.scroll_range_hold.as_mut()?; + let current = SettledScrollRange { + range: current_range, + editor_width: current_editor_width, + editor_bounds_size: current_editor_bounds_size, + }; + if !hold.held && !is_rewrapping { + hold.settled = Some(current); + return None; + } + let settled = hold.settled.get_or_insert(current); + if settled.editor_bounds_size != current_editor_bounds_size { + *settled = current; + } + Some(*settled) + } + pub fn set_custom_context_menu( &mut self, f: impl 'static @@ -9692,6 +9813,7 @@ impl Editor { } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; + self.latch_gutter_line_number_width(cx); self.refresh_active_diagnostics(cx); self.refresh_code_actions_for_selection(window, cx); self.refresh_single_line_folds(window, cx); @@ -11737,8 +11859,14 @@ impl EditorSnapshot { let line_gutter_width = if show_line_numbers { // Avoid flicker-like gutter resizes when the line number gains another digit by // only resizing the gutter on files with > 10**min_line_number_digits lines. - let min_width_for_number_on_gutter = - ch_advance * gutter_settings.min_line_number_digits as f32; + let sticky_min_digits = match self.gutter_line_number_width { + GutterLineNumberWidth::Dynamic => 0, + GutterLineNumberWidth::Sticky { min_digits } => min_digits, + }; + let min_digits = gutter_settings + .min_line_number_digits + .max(sticky_min_digits); + let min_width_for_number_on_gutter = ch_advance * min_digits as f32; self.max_line_number_width(style, window) .max(min_width_for_number_on_gutter) } else { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 394f24892f..06ca28e4f9 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -195,6 +195,8 @@ pub struct SearchSettings { pub regex: bool, /// Whether to center the cursor on each search match when navigating. pub center_on_match: bool, + /// Start searching as you type in project search, without pressing Enter. + pub search_on_type: bool, } impl EditorSettings { @@ -297,6 +299,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_type: search.search_on_type.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/editor_tests.rs b/crates/editor/src/editor_tests.rs index cd220d28e2..2d8ce29316 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -44211,3 +44211,62 @@ async fn assert_range_format_merge( ); assert!(!cx.read(|cx| editor.is_dirty(cx))); } + +#[gpui::test] +async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, _| { + let bounds = size(px(1800.), px(900.)); + let settled = |width: f32, height: f32, editor_width: f32, bounds: Size| { + Some(SettledScrollRange { + range: size(px(width), px(height)), + editor_width: px(editor_width), + editor_bounds_size: bounds, + }) + }; + editor.hold_scrollbar_range(true); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds), + settled(1780., 100., 1786., bounds), + "a hold that engages before any settled frame must freeze on the first \ + state it sees instead of falling through to the live churning range" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1776.), bounds), + settled(1780., 100., 1786., bounds), + "an unwrapped interpolation spike and a gutter-driven viewport change \ + mid-churn must not leak into the held state; comparing a settled width \ + against a viewport from another gutter regime blinks the scrollbar" + ); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), + settled(1780., 100., 1786., bounds), + "while held, the settled pair stays even when the live state shrinks" + ); + let resized_bounds = size(px(1300.), px(900.)); + assert_eq!( + editor.frozen_scroll_range(true, size(px(1300.), px(160.)), px(1276.), resized_bounds), + settled(1300., 160., 1276., resized_bounds), + "a window resize invalidates the frozen state even mid-churn, otherwise the \ + scrollbar thumbs stay sized for the old window until the search settles" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1276.), resized_bounds), + settled(1300., 160., 1276., resized_bounds), + "after re-freezing on the resize, churn at the same bounds stays frozen again" + ); + editor.hold_scrollbar_range(false); + assert_eq!( + editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), + None, + "once released and not rewrapping, the live state settles" + ); + assert_eq!( + editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1786.), bounds), + settled(1770., 160., 1776., bounds), + "a rewrap after release keeps the last settled pair frozen" + ); + }); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f88b928a3b..e85b980f06 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -9058,14 +9058,38 @@ impl Element for EditorElement { cx, ); + let frozen_scroll_state = if self.editor.read(cx).scroll_range_hold.is_some() { + let is_rewrapping = + self.editor.read(cx).display_map.read(cx).is_rewrapping(cx); + self.editor.update(cx, |editor, _| { + editor.frozen_scroll_range( + is_rewrapping, + scrollbar_layout_information.scroll_range, + editor_width, + scrollbar_layout_information.editor_bounds.size, + ) + }) + } else { + None + }; + let effective_scrollbar_layout_information = + frozen_scroll_state.map_or(scrollbar_layout_information, |settled| { + ScrollbarLayoutInformation { + scroll_range: settled.range, + ..scrollbar_layout_information + } + }); + let effective_editor_width = + frozen_scroll_state.map_or(editor_width, |settled| settled.editor_width); + let scrollbars_layout = self.layout_scrollbars( &snapshot, - &scrollbar_layout_information, + &effective_scrollbar_layout_information, content_offset, scroll_position, non_visible_cursors, right_margin, - editor_width, + effective_editor_width, window, cx, ); @@ -9289,7 +9313,7 @@ impl Element for EditorElement { &snapshot, minimap_width, scroll_position, - &scrollbar_layout_information, + &effective_scrollbar_layout_information, scrollbars_layout.as_ref(), window, cx, @@ -9597,6 +9621,7 @@ struct ContextMenuLayout { } /// Holds information required for layouting the editor scrollbars. +#[derive(Clone, Copy)] struct ScrollbarLayoutInformation { /// The bounds of the editor area (excluding the content offset). editor_bounds: Bounds, diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 10b0e25dd7..3cd6cc56a0 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3131,7 +3131,7 @@ impl MultiBuffer { } } -fn build_excerpt_ranges( +pub fn build_excerpt_ranges( ranges: impl IntoIterator>, context_line_count: u32, buffer_snapshot: &BufferSnapshot, diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 8e52eac1cb..5acbafe187 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -856,6 +856,315 @@ fn test_excerpt_events(cx: &mut App) { assert_eq!(*follower_edit_event_count.read(), 5); } +#[gpui::test] +fn test_set_excerpts_for_path_reuses_excerpts_when_only_primary_changes(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local(sample_text(10, 6, 'a'), cx)); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let path = PathKey::for_buffer(&buffer, cx); + + let edited_event_count = Arc::new(RwLock::new(0)); + let ranges_updated_event_count = Arc::new(RwLock::new(0)); + multibuffer.update(cx, |_, cx| { + cx.subscribe(&multibuffer, { + let edited_event_count = edited_event_count.clone(); + let ranges_updated_event_count = ranges_updated_event_count.clone(); + move |_, _, event, _| match event { + Event::Edited { .. } => *edited_event_count.write() += 1, + Event::BufferRangesUpdated { .. } => *ranges_updated_event_count.write() += 1, + _ => {} + } + }) + .detach(); + }); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path.clone(), + buffer.clone(), + vec![Point::new(3, 0)..Point::new(3, 1)], + 2, + cx, + ); + }); + let text = multibuffer.read(cx).snapshot(cx).text(); + assert_eq!(*edited_event_count.read(), 1); + assert_eq!(*ranges_updated_event_count.read(), 1); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path.clone(), + buffer.clone(), + vec![Point::new(3, 0)..Point::new(3, 2)], + 2, + cx, + ); + }); + assert_eq!(multibuffer.read(cx).snapshot(cx).text(), text); + assert_eq!(*edited_event_count.read(), 1); + assert_eq!(*ranges_updated_event_count.read(), 2); + + let buffer_snapshot = buffer.read(cx).snapshot(); + let primary_ranges = multibuffer + .read(cx) + .snapshot(cx) + .excerpts() + .map(|range| range.primary.to_point(&buffer_snapshot)) + .collect::>(); + assert_eq!(primary_ranges, vec![Point::new(3, 0)..Point::new(3, 2)]); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path.clone(), + buffer.clone(), + vec![Point::new(3, 0)..Point::new(3, 2)], + 2, + cx, + ); + }); + assert_eq!(*edited_event_count.read(), 1); + assert_eq!(*ranges_updated_event_count.read(), 2); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path, + buffer, + vec![Point::new(7, 0)..Point::new(7, 1)], + 2, + cx, + ); + }); + assert_ne!(multibuffer.read(cx).snapshot(cx).text(), text); + assert_eq!(*edited_event_count.read(), 2); + assert_eq!(*ranges_updated_event_count.read(), 3); +} + +#[gpui::test] +fn test_set_excerpts_for_path_reuses_excerpts_after_edits_shift_anchors(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local(sample_text(20, 6, 'a'), cx)); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let path = PathKey::for_buffer(&buffer, cx); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path.clone(), + buffer.clone(), + vec![Point::new(10, 0)..Point::new(10, 1)], + 2, + cx, + ); + }); + let text = multibuffer.read(cx).snapshot(cx).text(); + + let excerpt_edit_count = Arc::new(RwLock::new(0)); + let ranges_updated_event_count = Arc::new(RwLock::new(0)); + multibuffer.update(cx, |_, cx| { + cx.subscribe(&multibuffer, { + let excerpt_edit_count = excerpt_edit_count.clone(); + let ranges_updated_event_count = ranges_updated_event_count.clone(); + move |_, _, event, _| match event { + Event::Edited { + edited_buffer: None, + .. + } => *excerpt_edit_count.write() += 1, + Event::BufferRangesUpdated { .. } => *ranges_updated_event_count.write() += 1, + _ => {} + } + }) + .detach(); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "zzzzzz\n")], None, cx) + }); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + path, + buffer, + vec![Point::new(11, 0)..Point::new(11, 1)], + 2, + cx, + ); + }); + assert_eq!( + multibuffer.read(cx).snapshot(cx).text(), + text, + "an edit above the excerpt shifts the match but not its content, so the shown text \ + must stay the same" + ); + assert_eq!( + *excerpt_edit_count.read(), + 0, + "the shifted excerpt covers the same buffer content, so it must be reused as is \ + instead of being replaced" + ); + assert_eq!( + *ranges_updated_event_count.read(), + 0, + "the shifted primary range covers the same buffer content, so no range update \ + must be reported" + ); +} + +#[gpui::test] +fn test_ranges_grouped_by_excerpt_path_skips_stale_ranges(cx: &mut App) { + let buffer_a = cx.new(|cx| Buffer::local(sample_text(6, 6, 'a'), cx)); + let buffer_b = cx.new(|cx| Buffer::local(sample_text(6, 6, 'g'), cx)); + let buffer_c = cx.new(|cx| Buffer::local(sample_text(6, 6, 'm'), cx)); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let path_a = PathKey::sorted(0); + let path_b = PathKey::sorted(1); + let path_c = PathKey::sorted(2); + + multibuffer.update(cx, |multibuffer, cx| { + for (path, buffer) in [ + (path_a.clone(), &buffer_a), + (path_b.clone(), &buffer_b), + (path_c.clone(), &buffer_c), + ] { + multibuffer.set_excerpts_for_path( + path, + buffer.clone(), + vec![Point::new(2, 0)..Point::new(2, 3)], + 1, + cx, + ); + } + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + let ranges = [&buffer_a, &buffer_b, &buffer_c] + .into_iter() + .map(|buffer| { + let buffer_snapshot = buffer.read(cx).snapshot(); + let start = buffer_snapshot.anchor_before(Point::new(2, 0)); + let end = buffer_snapshot.anchor_after(Point::new(2, 3)); + snapshot + .anchor_range_in_buffer(start..end) + .expect("the buffer has excerpts") + }) + .collect::>(); + + assert_eq!( + multibuffer.read(cx).ranges_grouped_by_excerpt_path(&ranges), + vec![ + (path_a.clone(), 0..1), + (path_b.clone(), 1..2), + (path_c.clone(), 2..3), + ] + ); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(path_b, cx); + }); + + assert_eq!( + multibuffer.read(cx).ranges_grouped_by_excerpt_path(&ranges), + vec![(path_a, 0..1), (path_c, 2..3)], + "a range whose path lost its excerpts must be skipped instead of blocking the \ + consumption of every range that follows it" + ); +} + +#[gpui::test] +fn test_remove_excerpts_for_paths(cx: &mut App) { + let buffers = (0u64..4) + .map(|index| { + cx.new(|cx| Buffer::local(sample_text(4, 4, (b'a' + index as u8 * 4) as char), cx)) + }) + .collect::>(); + + for paths_to_remove in [ + vec![1, 2, 9], + vec![2, 3], + vec![0, 1, 2, 3], + vec![0, 2], + vec![3], + vec![9], + ] { + let batched = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let sequential = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + for multibuffer in [&batched, &sequential] { + multibuffer.update(cx, |multibuffer, cx| { + for (index, buffer) in buffers.iter().enumerate() { + multibuffer.set_excerpts_for_path( + PathKey::sorted(index as u64), + buffer.clone(), + vec![Point::new(1, 0)..Point::new(1, 3)], + 1, + cx, + ); + } + }); + } + + let edited_event_count = Arc::new(RwLock::new(0)); + let removed_buffer_events = Arc::new(RwLock::new(Vec::new())); + batched.update(cx, |_, cx| { + cx.subscribe(&batched, { + let edited_event_count = edited_event_count.clone(); + let removed_buffer_events = removed_buffer_events.clone(); + move |_, _, event, _| match event { + Event::Edited { .. } => *edited_event_count.write() += 1, + Event::BuffersRemoved { removed_buffer_ids } => removed_buffer_events + .write() + .push(removed_buffer_ids.clone()), + _ => {} + } + }) + .detach(); + }); + + let old_snapshot = batched.read(cx).snapshot(cx); + let subscription = batched.update(cx, |multibuffer, _| multibuffer.subscribe()); + batched.update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts_for_paths( + paths_to_remove + .iter() + .map(|index| PathKey::sorted(*index)) + .collect(), + cx, + ); + }); + sequential.update(cx, |multibuffer, cx| { + for index in &paths_to_remove { + multibuffer.remove_excerpts(PathKey::sorted(*index), cx); + } + }); + + let batched_snapshot = batched.read(cx).snapshot(cx); + assert_eq!( + batched_snapshot.text(), + sequential.read(cx).snapshot(cx).text(), + "removing {paths_to_remove:?} in one batch must match sequential removals" + ); + check_multibuffer_edits(&batched_snapshot, &old_snapshot, subscription); + assert_eq!( + batched_snapshot.trailing_excerpt_update_count() + - old_snapshot.trailing_excerpt_update_count(), + usize::from(paths_to_remove.contains(&3)), + "removing {paths_to_remove:?} must bump the trailing excerpt update count only \ + when the trailing excerpt was actually removed" + ); + + let expected_removed_ids = paths_to_remove + .iter() + .filter_map(|index| buffers.get(*index as usize)) + .map(|buffer| buffer.read(cx).remote_id()) + .collect::>(); + if expected_removed_ids.is_empty() { + assert_eq!(*edited_event_count.read(), 0); + assert_eq!(*removed_buffer_events.read(), Vec::>::new()); + } else { + assert_eq!( + *edited_event_count.read(), + 1, + "removing {paths_to_remove:?} must edit the multibuffer exactly once" + ); + assert_eq!(*removed_buffer_events.read(), vec![expected_removed_ids]); + } + } +} + #[gpui::test] fn test_expand_excerpts(cx: &mut App) { let buffer = cx.new(|cx| Buffer::local(sample_text(20, 3, 'a'), cx)); diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 436cd5763d..2b268924cc 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -44,12 +44,13 @@ impl PathKey { } pub fn for_buffer(buffer: &Entity, cx: &App) -> Self { - if let Some(file) = buffer.read(cx).file() { + let buffer = buffer.read(cx); + if let Some(file) = buffer.file() { Self::with_sort_prefix(file.worktree_id(cx).to_proto(), file.path().clone()) } else { Self { sort_prefix: None, - path: RelPath::from_unix_str(&buffer.entity_id().to_string()) + path: RelPath::from_unix_str(&buffer.remote_id().to_string()) .unwrap() .into_arc(), } @@ -421,6 +422,7 @@ impl MultiBuffer { }); } + let mut primary_ranges_changed = false; while let Some(excerpt) = cursor.item() && excerpt.path_key == path_key { @@ -428,7 +430,11 @@ impl MultiBuffer { let Some(next_excerpt) = to_insert.peek() else { break; }; - if &excerpt.range == *next_excerpt { + let context_anchors_match = excerpt.range.context == next_excerpt.context; + if context_anchors_match + || excerpt.range.context.to_offset(buffer_snapshot) + == next_excerpt.context.to_offset(buffer_snapshot) + { let before = new_excerpts.summary().len(); new_excerpts.update_last( |prev_excerpt| { @@ -442,7 +448,26 @@ impl MultiBuffer { }, (), ); - new_excerpts.push(excerpt.clone(), ()); + let primary_offsets_changed = excerpt.range.primary != next_excerpt.primary + && excerpt.range.primary.to_offset(buffer_snapshot) + != next_excerpt.primary.to_offset(buffer_snapshot); + primary_ranges_changed |= primary_offsets_changed; + let reused_excerpt = if context_anchors_match { + let mut reused_excerpt = excerpt.clone(); + if reused_excerpt.range.primary != next_excerpt.primary { + reused_excerpt.range.primary = next_excerpt.primary.clone(); + } + reused_excerpt + } else { + Excerpt::new( + path_key.clone(), + path_key_index, + buffer_snapshot, + (*next_excerpt).clone(), + excerpt.has_trailing_newline, + ) + }; + new_excerpts.push(reused_excerpt, ()); to_insert.next(); cursor.next(); continue; @@ -609,11 +634,70 @@ impl MultiBuffer { ranges: new_ranges, }); cx.notify(); + } else if primary_ranges_changed { + cx.emit(Event::BufferRangesUpdated { + buffer, + path_key: path_key.clone(), + ranges: new_ranges, + }); + cx.notify(); } added_new_excerpt } + pub fn ranges_grouped_by_excerpt_path( + &self, + ranges: &[Range], + ) -> Vec<(PathKey, Range)> { + debug_assert!( + ranges + .iter() + .all(|range| matches!(range.start, Anchor::Excerpt(_))), + "ranges must start with excerpt anchors, Min/Max starts stall the grouping" + ); + let snapshot = self.snapshot.borrow(); + let mut grouped: Vec<(PathKey, Range)> = Vec::new(); + let mut next_range_index = 0; + for excerpt in snapshot.excerpts.iter() { + while ranges + .get(next_range_index) + .is_some_and(|range| match &range.start { + Anchor::Excerpt(anchor) => { + anchor.path != excerpt.path_key_index + && snapshot + .path_keys + .get_index(anchor.path.0 as usize) + .is_some_and(|path| path < &excerpt.path_key) + } + Anchor::Min | Anchor::Max => false, + }) + { + next_range_index += 1; + } + let span_start = match grouped.last() { + Some((last_path, span)) if *last_path == excerpt.path_key => span.start, + _ => next_range_index, + }; + while ranges + .get(next_range_index) + .is_some_and(|range| match &range.start { + Anchor::Excerpt(anchor) => anchor.path == excerpt.path_key_index, + Anchor::Min | Anchor::Max => false, + }) + { + next_range_index += 1; + } + match grouped.last_mut() { + Some((last_path, span)) if *last_path == excerpt.path_key => { + span.end = next_range_index; + } + _ => grouped.push((excerpt.path_key.clone(), span_start..next_range_index)), + } + } + grouped + } + pub fn remove_excerpts_for_buffer(&mut self, buffer: BufferId, cx: &mut Context) { let snapshot = self.sync_mut(cx); let Some(path) = snapshot.path_for_buffer(buffer).cloned() else { @@ -622,6 +706,108 @@ impl MultiBuffer { self.remove_excerpts(path, cx); } + pub fn remove_excerpts_for_paths(&mut self, paths: Vec, cx: &mut Context) { + debug_assert!(paths.is_sorted(), "paths must be in ascending order"); + assert_eq!(self.history.transaction_depth(), 0); + self.sync_mut(cx); + + let mut snapshot = self.snapshot.get_mut(); + let old_len = snapshot.excerpts.summary().len(); + let mut cursor = snapshot + .excerpts + .cursor::>(()); + let mut new_excerpts = SumTree::new(()); + let mut excerpt_edits: Vec> = Vec::new(); + let mut removed_buffer_ids = Vec::new(); + + for path in &paths { + new_excerpts.append(cursor.slice(path, Bias::Left), ()); + let Some(excerpt) = cursor.item() else { + break; + }; + if excerpt.path_key != *path { + continue; + } + removed_buffer_ids.push(excerpt.buffer_id); + let edit_start = cursor.position.1; + cursor.seek_forward(path, Bias::Right); + let edit_end = cursor.position.1; + match excerpt_edits.last_mut() { + Some(last_edit) if last_edit.old.end == edit_start => { + last_edit.old.end = edit_end; + } + _ => { + let new_position = new_excerpts.summary().len(); + excerpt_edits.push(Edit { + old: edit_start..edit_end, + new: new_position..new_position, + }); + } + } + } + let suffix = cursor.suffix(); + let changed_trailing_excerpt = suffix.is_empty() + && excerpt_edits + .last() + .is_some_and(|last_edit| last_edit.old.end == old_len); + new_excerpts.append(suffix, ()); + drop(cursor); + + if removed_buffer_ids.is_empty() { + return; + } + + for buffer_id in &removed_buffer_ids { + snapshot.buffers.remove(buffer_id); + remove_diff_state(&mut snapshot.diffs, *buffer_id); + self.buffers.remove(buffer_id); + self.diffs.remove(buffer_id); + } + cx.emit(Event::BuffersRemoved { removed_buffer_ids }); + + if changed_trailing_excerpt { + snapshot.trailing_excerpt_update_count += 1; + new_excerpts.update_last( + |excerpt| { + if excerpt.has_trailing_newline { + excerpt.has_trailing_newline = false; + if let Some(last_edit) = excerpt_edits.last_mut() { + last_edit.old.start.0.0 = last_edit + .old + .start + .0 + .0 + .checked_sub(1) + .expect("should have at least one excerpt"); + last_edit.new.start.0.0 = last_edit + .new + .start + .0 + .0 + .checked_sub(1) + .expect("should have at least one excerpt"); + last_edit.new.end = last_edit.new.start; + } + } + }, + (), + ); + } + + snapshot.excerpts = new_excerpts; + + let edits = + Self::sync_diff_transforms(&mut snapshot, excerpt_edits, DiffChangeKind::BufferEdited); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } + cx.emit(Event::Edited { + edited_buffer: None, + source: BufferEditSource::User, + }); + cx.notify(); + } + pub fn remove_excerpts(&mut self, path: PathKey, cx: &mut Context) { assert_eq!(self.history.transaction_depth(), 0); self.sync_mut(cx); diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 91c89d7c93..3abcde1cf1 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -22,7 +22,7 @@ use postage::oneshot; use rpc::{AnyProtoClient, proto}; use language::ByteContent; -use util::{ResultExt, maybe, paths::compare_rel_paths, rel_path::RelPath}; +use util::{ResultExt, maybe, rel_path::RelPath}; use worktree::{ Entry, ProjectEntryId, Snapshot, Worktree, WorktreeSettings, decode_byte_header, decode_file_text, @@ -119,7 +119,11 @@ impl Search { limit: usize, cx: &mut App, ) -> Self { - let worktrees = worktree_store.read(cx).visible_worktrees(cx).collect(); + let mut worktrees = worktree_store + .read(cx) + .visible_worktrees(cx) + .collect::>(); + worktrees.sort_by_key(|worktree| worktree.read(cx).id()); Self { kind: SearchKind::Local { fs, worktrees }, buffer_store, @@ -165,7 +169,9 @@ impl Search { pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle { let mut open_buffers = HashSet::default(); let mut unnamed_buffers = Vec::new(); + let mut entryless_file_buffers = Vec::new(); const MAX_CONCURRENT_BUFFER_OPENS: usize = 64; + let searches_all_unnamed_buffers = !matches!(self.kind, SearchKind::OpenBuffersOnly); let buffers = self.buffer_store.read(cx); for handle in buffers.buffers() { let buffer = handle.read(cx); @@ -178,11 +184,23 @@ impl Search { continue; } else if let Some(entry_id) = buffer.entry_id(cx) { open_buffers.insert(entry_id); - } else { - self.limit = self.limit.saturating_sub(1); - unnamed_buffers.push(handle) + } else if searches_all_unnamed_buffers { + match (&self.kind, buffer.file()) { + (SearchKind::Local { .. }, Some(file)) => { + self.limit = self.limit.saturating_sub(1); + let sort_key = (file.worktree_id(cx).to_proto(), file.path().clone()); + entryless_file_buffers.push((sort_key, handle)); + } + (SearchKind::Remote { .. }, _) => {} + _ => { + self.limit = self.limit.saturating_sub(1); + unnamed_buffers.push(handle); + } + } }; } + unnamed_buffers.sort_by_cached_key(|buffer| path_key_sort_key(buffer, cx)); + entryless_file_buffers.sort_by(|(key_a, _), (key_b, _)| key_a.cmp(key_b)); let open_buffers = Arc::new(open_buffers); let executor = cx.background_executor().clone(); let (tx, rx) = unbounded(); @@ -241,6 +259,7 @@ impl Search { self.buffer_store, get_buffer_for_full_scan_rx, grab_buffer_snapshot_tx, + entryless_file_buffers, cx.clone(), ) .boxed_local(), @@ -540,13 +559,19 @@ impl Search { buffer_store: Entity, rx: Receiver<(ProjectPath, MatchPositionHint)>, find_all_matches_tx: Sender<(Entity, MatchPositionHint)>, + sorted_entryless_file_buffers: Vec<((u64, Arc), Entity)>, mut cx: AsyncApp, ) { + let mut entryless_file_buffers = sorted_entryless_file_buffers.into_iter().peekable(); let mut rx = pin!(rx.ready_chunks(64)); _ = maybe!(async move { while let Some(requested_paths) = rx.next().await { let line_hints: Vec = requested_paths.iter().map(|(_, line)| *line).collect(); + let sort_keys: Vec<(u64, Arc)> = requested_paths + .iter() + .map(|(path, _)| (path.worktree_id.to_proto(), path.path.clone())) + .collect(); let mut buffers = buffer_store.update(&mut cx, |this, cx| { requested_paths .into_iter() @@ -554,13 +579,28 @@ impl Search { .collect::>() }); let mut line_hints = line_hints.into_iter(); + let mut sort_keys = sort_keys.into_iter(); while let Some(buffer) = buffers.next().await { let line_hint = line_hints.next().unwrap_or(MatchPositionHint::default()); + if let Some(sort_key) = sort_keys.next() { + while let Some((_, entryless_buffer)) = + entryless_file_buffers.next_if(|(key, _)| *key < sort_key) + { + find_all_matches_tx + .send((entryless_buffer, MatchPositionHint::default())) + .await?; + } + } if let Some(buffer) = buffer.log_err() { find_all_matches_tx.send((buffer, line_hint)).await?; } } } + for (_, entryless_buffer) in entryless_file_buffers { + find_all_matches_tx + .send((entryless_buffer, MatchPositionHint::default())) + .await?; + } Result::<_, anyhow::Error>::Ok(()) }) .await; @@ -649,21 +689,28 @@ impl Search { }) .cloned() .collect::>(); - buffers.sort_by(|a, b| { - let a = a.read(cx); - let b = b.read(cx); - match (a.file(), b.file()) { - (None, None) => a.remote_id().cmp(&b.remote_id()), - (None, Some(_)) => std::cmp::Ordering::Less, - (Some(_), None) => std::cmp::Ordering::Greater, - (Some(a), Some(b)) => compare_rel_paths((a.path(), true), (b.path(), true)), - } - }); + buffers.sort_by_cached_key(|buffer| path_key_sort_key(buffer, cx)); + buffers.dedup_by_key(|buffer| buffer.entity_id()); buffers } } +fn path_key_sort_key( + buffer: &Entity, + cx: &App, +) -> (Option, Option>, String) { + let buffer = buffer.read(cx); + match buffer.file() { + Some(file) => ( + Some(file.worktree_id(cx).to_proto()), + Some(file.path().clone()), + String::new(), + ), + None => (None, None, buffer.remote_id().to_string()), + } +} + struct Worker { query: Arc, open_buffers: Arc>, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 7948e1b6a7..5e46c2f637 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -47,7 +47,9 @@ use project::{ use remote::RemoteClient; use rpc::proto; use serde_json::json; -use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content}; +use settings::{ + Settings, SettingsLocation, SettingsStore, SplicingVec, initial_server_settings_content, +}; use smol::stream::StreamExt; use std::{ path::{Path, PathBuf}, @@ -611,6 +613,145 @@ async fn test_remote_project_search_inclusion( .await; } +#[gpui::test] +async fn test_remote_project_search_reports_open_excluded_file_once( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + "aaa.rs": "fn needle_aa() {}", + "mmm.rs": "fn needle_mm() {}", + "zzz.rs": "fn needle_zz() {}", + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + server_cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(SplicingVec::from(vec!["**/mmm.rs".to_string()])); + }); + }); + }); + + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let _excluded_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("mmm.rs")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + worktree.read_with(cx, |worktree, _| { + assert_eq!( + worktree + .entry_for_path(rel_path("mmm.rs")) + .map(|entry| entry.id), + None, + "mmm.rs must have no worktree entry for this test to be meaningful" + ); + }); + + do_search_and_assert( + &project, + "needle", + Default::default(), + false, + &[ + path!("project1/aaa.rs"), + path!("project1/mmm.rs"), + path!("project1/zzz.rs"), + ], + cx.clone(), + ) + .await; +} + +#[gpui::test] +async fn test_remote_project_search_reports_untitled_buffer_once( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + "aaa.rs": "fn needle_aa() {}", + }, + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + let (_worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let untitled_buffer = project + .update(cx, |project, cx| project.create_buffer(None, true, cx)) + .await + .unwrap(); + untitled_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "fn needle_untitled() {}")], None, cx) + }); + cx.run_until_parked(); + + let receiver = project.update(cx, |project, cx| { + project.search( + SearchQuery::text( + "needle", + false, + true, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap(), + cx, + ) + }); + let mut result_buffers = Vec::new(); + while let Ok(result) = receiver.rx.recv().await { + match result { + SearchResult::Buffer { buffer, .. } => result_buffers.push(buffer), + SearchResult::LimitReached => panic!("unexpected limit"), + SearchResult::WaitingForScan | SearchResult::Searching => {} + } + } + assert_eq!( + result_buffers + .iter() + .filter(|buffer| **buffer == untitled_buffer) + .count(), + 1, + "an untitled buffer on a remote project must be searched by the server only, \ + not once per side" + ); + assert_eq!(result_buffers.len(), 2); +} + #[gpui::test] async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 220879dda8..80ec649665 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -32,6 +32,7 @@ fs.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true menu.workspace = true multi_buffer.workspace = true picker.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index dbd0cfe6cd..5660dde6af 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -3976,6 +3976,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_type: false, }, cx, ); @@ -4039,6 +4040,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_type: false, }, cx, ); @@ -4077,6 +4079,7 @@ mod tests { include_ignored: false, regex: false, center_on_match: false, + search_on_type: false, }, cx, ); @@ -4305,6 +4308,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_type: Some(search_settings.search_on_type), }); }); }); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 909959e6db..7ec8b181c2 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -39,6 +39,7 @@ use project::{ use settings::Settings; use std::{ any::{Any, TypeId}, + iter::Peekable, mem, ops::{Not, Range}, pin::pin, @@ -46,7 +47,9 @@ use std::{ Arc, atomic::{AtomicBool, Ordering}, }, + time::Duration, }; +use text::OffsetRangeExt; use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*, utils::SearchInputWidth, @@ -246,6 +249,8 @@ fn contains_uppercase(str: &str) -> bool { str.chars().any(|c| c.is_uppercase()) } +const SEARCH_ON_TYPE_DEBOUNCE: Duration = Duration::from_millis(250); + pub struct ProjectSearch { pub(crate) project: Entity, pub excerpts: Entity, @@ -255,6 +260,9 @@ pub struct ProjectSearch { last_search_query_text: Option, pub search_id: usize, search_state: SearchState, + phase: SearchPhase, + reuses_excerpts: bool, + results_refreshed: bool, search_history_cursor: SearchHistoryCursor, search_included_history_cursor: SearchHistoryCursor, search_excluded_history_cursor: SearchHistoryCursor, @@ -262,11 +270,44 @@ pub struct ProjectSearch { _excerpts_subscription: Subscription, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SearchMode { + Manual, + OnType, + Refresh, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SearchSignature { + query: String, + included_files: String, + excluded_files: String, + options: SearchOptions, + filters_enabled: bool, + included_opened_only: bool, + opened_buffers: Option>, +} + +/// Tracks how the current results were produced, so the view knows whether to preserve the +/// user's scroll/selection (mid-typing) or take focus and jump to the first match (confirmed). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SearchPhase { + #[default] + Idle, + /// A search-on-type run whose results the user has not confirmed yet. + Typing, + /// A manual search, or an on-type search the user confirmed with Enter. + Confirmed, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum SearchState { #[default] Idle, - Running(SearchActivity), + Running { + activity: SearchActivity, + previous_completion: Option, + }, Completed(SearchCompletion), } @@ -283,10 +324,25 @@ enum SearchCompletion { } impl SearchState { + fn completion(self) -> Option { + match self { + SearchState::Idle => None, + SearchState::Running { + previous_completion, + .. + } => previous_completion, + SearchState::Completed(completion) => Some(completion), + } + } + + fn no_results_so_far(self) -> bool { + self.completion() == Some(SearchCompletion::NoResults) + } + fn limit_reached(self) -> bool { matches!( - self, - SearchState::Completed(SearchCompletion::Results { + self.completion(), + Some(SearchCompletion::Results { limit_reached: true }) ) @@ -317,8 +373,11 @@ pub struct ProjectSearchView { filters_enabled: bool, replace_enabled: bool, pending_replace_all: bool, + pending_replace_next: bool, included_opened_only: bool, regex_language: Option>, + debounced_search: Option>, + last_search_signature: Option, _subscriptions: Vec, } @@ -348,6 +407,9 @@ impl ProjectSearch { last_search_query_text: None, search_id: 0, search_state: SearchState::Idle, + phase: SearchPhase::Idle, + reuses_excerpts: false, + results_refreshed: true, search_history_cursor: Default::default(), search_included_history_cursor: Default::default(), search_excluded_history_cursor: Default::default(), @@ -376,6 +438,13 @@ impl ProjectSearch { } else { self.search_state }, + phase: if self.phase == SearchPhase::Confirmed { + SearchPhase::Confirmed + } else { + SearchPhase::Idle + }, + reuses_excerpts: false, + results_refreshed: true, 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(), @@ -420,8 +489,10 @@ impl ProjectSearch { excerpts.snapshot(cx) }); - self.match_ranges - .retain(|range| snapshot.anchor_to_buffer_anchor(range.start).is_some()); + if self.pending_search.is_none() { + self.match_ranges + .retain(|range| snapshot.anchor_to_buffer_anchor(range.start).is_some()); + } cx.notify(); } @@ -441,10 +512,69 @@ impl ProjectSearch { } } - fn search(&mut self, query: SearchQuery, cx: &mut Context) { + fn search( + &mut self, + query: SearchQuery, + mode: SearchMode, + retains_verdict: bool, + cx: &mut Context, + ) { let project_search_turning_into_text_finder = Arc::clone(&self.project_search_turning_into_text_finder); - let search = self.project.update(cx, |project, cx| { + let search = self + .project + .update(cx, |project, cx| project.search(query.clone(), cx)); + self.last_search_query_text = Some(query.as_str().to_string()); + self.search_id += 1; + self.active_query = Some(query); + match mode { + SearchMode::Manual => { + self.record_search_history(cx); + self.phase = SearchPhase::Confirmed; + } + SearchMode::Refresh if self.phase == SearchPhase::Confirmed => {} + SearchMode::OnType | SearchMode::Refresh => self.phase = SearchPhase::Typing, + } + self.reuses_excerpts = true; + self.results_refreshed = false; + self.search_state = SearchState::Running { + activity: SearchActivity::Searching, + previous_completion: if retains_verdict { + self.search_state.completion() + } else { + None + }, + }; + self.pending_search = Some(cx.spawn(async move |project_search, cx| { + consume_search_stream( + project_search, + search, + project_search_turning_into_text_finder, + cx, + ) + .await + })); + cx.notify(); + } + + fn clear(&mut self, cx: &mut Context) { + self.pending_search = None; + self.match_ranges.clear(); + self.excerpts.update(cx, |excerpts, cx| excerpts.clear(cx)); + self.search_state = SearchState::Idle; + self.phase = SearchPhase::Idle; + self.reuses_excerpts = false; + self.results_refreshed = true; + self.active_query = None; + self.last_search_query_text = None; + cx.notify(); + } + + fn record_search_history(&mut self, cx: &mut Context) { + let Some(query) = self.active_query.clone() else { + return; + }; + self.project.update(cx, |project, _| { project .search_history_mut(SearchInputKind::Query) .add(&mut self.search_history_cursor, query.as_str().to_string()); @@ -460,32 +590,7 @@ impl ProjectSearch { .search_history_mut(SearchInputKind::Exclude) .add(&mut self.search_excluded_history_cursor, excluded); } - project.search(query.clone(), cx) }); - self.last_search_query_text = Some(query.as_str().to_string()); - self.search_id += 1; - self.active_query = Some(query); - self.match_ranges.clear(); - self.search_state = SearchState::Running(SearchActivity::Searching); - self.pending_search = Some(cx.spawn(async move |project_search, cx| { - project_search - .update(cx, |project_search, cx| { - project_search.match_ranges.clear(); - project_search - .excerpts - .update(cx, |excerpts, cx| excerpts.clear(cx)); - }) - .ok()?; - - consume_search_stream( - project_search, - search, - project_search_turning_into_text_finder, - cx, - ) - .await - })); - cx.notify(); } // At the point this is called the multibuffer has already been filled with @@ -498,6 +603,8 @@ impl ProjectSearch { let project_search_turning_into_text_finder = Arc::clone(&self.project_search_turning_into_text_finder); + self.reuses_excerpts = false; + self.results_refreshed = true; self.pending_search = Some(cx.spawn(async move |project_search, cx| { consume_search_stream( project_search, @@ -512,16 +619,30 @@ impl ProjectSearch { } /// Drain a search result stream into the project search's multibuffer. +/// +/// When the model is set to reuse excerpts, the previous matches are kept grouped by path and +/// edited in place as the (PathKey-sorted) results stream in: unchanged files keep their excerpts +/// and highlights, replaced files are updated, and files that stopped matching are pruned. The +/// model's `match_ranges` is refreshed per chunk from that grouping, so highlights track the +/// results as they arrive instead of blinking in only once the stream finishes. async fn consume_search_stream( project_search: WeakEntity, search_results: SearchResults, project_search_turning_into_text_finder: Arc, cx: &mut AsyncApp, ) -> Option> { + let reuse_excerpts = project_search + .read_with(cx, |project_search, _| project_search.reuses_excerpts) + .ok()?; // Note: is cancel safe let mut matches = pin!(search_results.rx.clone().ready_chunks(1024)); let mut limit_reached = false; + let mut reused_results = if reuse_excerpts { + Some(project_search.read_with(cx, ReusedResults::new).ok()?) + } else { + None + }; while let Some(results) = matches.next().await { let (buffers_with_ranges, has_reached_limit, search_activity) = cx .background_executor() @@ -552,39 +673,47 @@ async fn consume_search_stream( if let Some(search_activity) = search_activity { project_search .update(cx, |project_search, cx| { - project_search.search_state = SearchState::Running(search_activity); + project_search.search_state = SearchState::Running { + activity: search_activity, + previous_completion: project_search.search_state.completion(), + }; cx.notify(); }) .ok()?; } - let mut new_ranges = project_search - .update(cx, |project_search, cx| { - project_search.excerpts.update(cx, |excerpts, cx| { - buffers_with_ranges - .into_iter() - .map(|(buffer, ranges)| { - excerpts.set_anchored_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - ranges, - multibuffer_context_lines(cx), - cx, - ) - }) - .collect::>() - }) - }) - .ok()?; - while let Some(new_ranges) = new_ranges.next().await { - // `new_ranges.next().await` likely never gets hit while still pending so `async_task` - // will not reschedule, starving other front end tasks, insert a yield point for that here - smol::future::yield_now().await; - project_search + + if let Some(reused_results) = &mut reused_results { + apply_reused_chunk(&project_search, buffers_with_ranges, reused_results, cx).await?; + } else { + let mut new_ranges = project_search .update(cx, |project_search, cx| { - project_search.match_ranges.extend(new_ranges); - cx.notify(); + project_search.excerpts.update(cx, |excerpts, cx| { + buffers_with_ranges + .into_iter() + .map(|(buffer, ranges)| { + excerpts.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + ranges, + multibuffer_context_lines(cx), + cx, + ) + }) + .collect::>() + }) }) .ok()?; + while let Some(new_ranges) = new_ranges.next().await { + // `new_ranges.next().await` likely never gets hit while still pending so `async_task` + // will not reschedule, starving other front end tasks, insert a yield point for that here + smol::future::yield_now().await; + project_search + .update(cx, |project_search, cx| { + project_search.match_ranges.extend(new_ranges); + cx.notify(); + }) + .ok()?; + } } // We do not want to end the task before all the results taken @@ -596,11 +725,22 @@ async fn consume_search_stream( if project_search_turning_into_text_finder.load(Ordering::Relaxed) { project_search_turning_into_text_finder.store(false, Ordering::Relaxed); // reset + if let Some(reused_results) = reused_results { + project_search + .update(cx, |project_search, cx| { + reused_results.finish(project_search, cx); + cx.notify(); + }) + .ok()?; + } return Some(search_results); } project_search .update(cx, |project_search, cx| { + if let Some(reused_results) = reused_results { + reused_results.finish(project_search, cx); + } project_search.search_state = if project_search.match_ranges.is_empty() { SearchState::Completed(SearchCompletion::NoResults) } else { @@ -614,6 +754,218 @@ async fn consume_search_stream( None } +struct ReusedResults { + previous_groups: Peekable)>>, + previous_ranges: Vec>, + suffix_start: usize, + confirmed_ranges: Vec>, + last_seen_path: Option, + reported_out_of_order: bool, +} + +impl ReusedResults { + fn new(project_search: &ProjectSearch, cx: &App) -> Self { + let previous_ranges = project_search.match_ranges.clone(); + let previous_groups = project_search + .excerpts + .read(cx) + .ranges_grouped_by_excerpt_path(&previous_ranges) + .into_iter() + .peekable(); + Self { + previous_groups, + previous_ranges, + suffix_start: 0, + confirmed_ranges: Vec::new(), + last_seen_path: None, + reported_out_of_order: false, + } + } + + fn prune_stale_paths_before( + &mut self, + path_key: Option<&PathKey>, + excerpts: &mut MultiBuffer, + cx: &mut Context, + ) -> bool { + let mut stale_paths = Vec::new(); + while let Some((stale_path, span)) = self + .previous_groups + .next_if(|(previous_path, _)| path_key.is_none_or(|path| previous_path < path)) + { + stale_paths.push(stale_path); + self.suffix_start = span.end; + } + if stale_paths.is_empty() { + return false; + } + excerpts.remove_excerpts_for_paths(stale_paths, cx); + true + } + + fn previous_ranges_for(&mut self, path_key: &PathKey) -> Option> { + let (_, span) = self + .previous_groups + .next_if(|(previous_path, _)| previous_path == path_key)?; + self.suffix_start = span.end; + Some(span) + } + + fn current_match_ranges(&self) -> Vec> { + let suffix = self.previous_ranges.get(self.suffix_start..).unwrap_or(&[]); + let mut match_ranges = Vec::with_capacity(self.confirmed_ranges.len() + suffix.len()); + match_ranges.extend_from_slice(&self.confirmed_ranges); + match_ranges.extend_from_slice(suffix); + match_ranges + } + + fn finish(mut self, project_search: &mut ProjectSearch, cx: &mut Context) { + project_search.excerpts.update(cx, |excerpts, cx| { + self.prune_stale_paths_before(None, excerpts, cx); + }); + project_search.match_ranges = self.confirmed_ranges; + } +} + +async fn apply_reused_chunk( + project_search: &WeakEntity, + buffers_with_ranges: Vec<(Entity, Vec>)>, + reused_results: &mut ReusedResults, + cx: &mut AsyncApp, +) -> Option<()> { + const FOREGROUND_BATCH_SIZE: usize = 64; + let buffers_with_ranges = buffers_with_ranges + .into_iter() + .filter(|(_, ranges)| !ranges.is_empty()) + .collect::>(); + if buffers_with_ranges.is_empty() { + return Some(()); + } + let (context_line_count, chunk) = project_search + .read_with(cx, |_, cx| { + ( + multibuffer_context_lines(cx), + buffers_with_ranges + .into_iter() + .map(|(buffer, ranges)| { + let path_key = PathKey::for_buffer(&buffer, cx); + let buffer_snapshot = buffer.read(cx).snapshot(); + (path_key, buffer, buffer_snapshot, ranges) + }) + .collect::>(), + ) + }) + .ok()?; + let chunk = cx + .background_spawn(async move { + chunk + .into_iter() + .map(|(path_key, buffer, buffer_snapshot, ranges)| { + let point_ranges = ranges.iter().map(|range| range.to_point(&buffer_snapshot)); + let excerpt_ranges = multi_buffer::build_excerpt_ranges( + point_ranges, + context_line_count, + &buffer_snapshot, + ); + (path_key, buffer, buffer_snapshot, excerpt_ranges, ranges) + }) + .collect::>() + }) + .await; + let mut chunk = chunk.into_iter().peekable(); + let mut chunk_changed = false; + while chunk.peek().is_some() { + let batch = chunk + .by_ref() + .take(FOREGROUND_BATCH_SIZE) + .collect::>(); + let batch_changed = project_search + .update(cx, |project_search, cx| { + let batch_changed = project_search.excerpts.update(cx, |excerpts, cx| { + let mut batch_changed = false; + let mut applied = Vec::with_capacity(batch.len()); + for (path_key, buffer, buffer_snapshot, excerpt_ranges, ranges) in batch { + let in_order = reused_results + .last_seen_path + .as_ref() + .is_none_or(|last| *last < path_key); + if in_order { + reused_results.last_seen_path = Some(path_key.clone()); + batch_changed |= reused_results.prune_stale_paths_before( + Some(&path_key), + excerpts, + cx, + ); + } else if !reused_results.reported_out_of_order { + reused_results.reported_out_of_order = true; + log::warn!( + "search results should arrive PathKey-sorted, one result per \ + buffer; updating {path_key:?} without reusing its excerpts" + ); + } + let previous_span = if in_order { + reused_results.previous_ranges_for(&path_key) + } else { + None + }; + excerpts.set_excerpt_ranges_for_path( + path_key, + buffer, + &buffer_snapshot, + excerpt_ranges, + cx, + ); + applied.push((previous_span, ranges, in_order)); + } + let snapshot = excerpts.snapshot(cx); + for (previous_span, ranges, in_order) in applied { + let anchor_ranges = ranges + .into_iter() + .filter_map(|range| snapshot.anchor_range_in_buffer(range)) + .collect::>(); + batch_changed |= previous_span.is_none_or(|span| { + reused_results.previous_ranges.get(span) + != Some(anchor_ranges.as_slice()) + }); + if in_order { + reused_results.confirmed_ranges.extend(anchor_ranges); + } else if let Some(first) = anchor_ranges.first() { + let buffer_id = first.start.buffer_id(); + reused_results + .confirmed_ranges + .retain(|range| range.start.buffer_id() != buffer_id); + let insert_at = + reused_results.confirmed_ranges.partition_point(|range| { + range.start.cmp(&first.start, &snapshot).is_lt() + }); + reused_results + .confirmed_ranges + .splice(insert_at..insert_at, anchor_ranges); + } + } + batch_changed + }); + if batch_changed { + project_search.match_ranges = reused_results.current_match_ranges(); + cx.notify(); + } + batch_changed + }) + .ok()?; + chunk_changed |= batch_changed; + smol::future::yield_now().await; + } + project_search + .update(cx, |project_search, cx| { + let newly_refreshed = !mem::replace(&mut project_search.results_refreshed, true); + if chunk_changed || newly_refreshed { + cx.notify(); + } + }) + .ok()?; + Some(()) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewEvent { UpdateTab, @@ -641,8 +993,17 @@ impl Render for ProjectSearchView { let model = self.entity.read(cx); let heading_text = match model.search_state { - SearchState::Running(SearchActivity::WaitingForScan) => "Loading project…", - SearchState::Running(SearchActivity::Searching) => "Searching…", + SearchState::Running { .. } if model.search_state.no_results_so_far() => { + "No Results" + } + SearchState::Running { + activity: SearchActivity::WaitingForScan, + .. + } => "Loading project…", + SearchState::Running { + activity: SearchActivity::Searching, + .. + } => "Searching…", SearchState::Completed(SearchCompletion::NoResults) => "No Results", _ => "Search All Files", }; @@ -653,7 +1014,7 @@ impl Render for ProjectSearchView { let page_content: Option = match model.search_state { SearchState::Idle => Some(self.landing_text_minor(cx).into_any_element()), - SearchState::Completed(SearchCompletion::NoResults) => Some( + _ if model.search_state.no_results_so_far() => Some( Label::new("No results found in this project for the provided query") .size(LabelSize::Small) .into_any_element(), @@ -934,13 +1295,15 @@ impl ProjectSearchView { fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context) { if self.entity.read(cx).pending_search.is_some() { + self.pending_replace_next = self.entity.read(cx).phase == SearchPhase::Confirmed; return; } + self.pending_replace_next = false; if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text && self.query_editor.read(cx).text(cx) != *last_search_query_text { // search query has changed, restart search and bail - self.search(cx); + self.search(SearchMode::Manual, cx); return; } if self.entity.read(cx).match_ranges.is_empty() { @@ -952,6 +1315,7 @@ impl ProjectSearchView { let query = self.entity.read(cx).active_query.clone(); if let Some(query) = query { + self.confirm_active_search(cx); let query = query.with_replacement(self.replacement(cx)); let mat = self.entity.read(cx).match_ranges.get(active_index).cloned(); @@ -966,18 +1330,15 @@ impl ProjectSearchView { fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context) { if self.entity.read(cx).pending_search.is_some() { - self.pending_replace_all = true; + self.pending_replace_all = self.entity.read(cx).phase == SearchPhase::Confirmed; return; } let query_text = self.query_editor.read(cx).text(cx); let query_is_stale = 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); - if self.entity.read(cx).pending_search.is_none() { - self.pending_replace_all = false; - } + self.search(SearchMode::Manual, cx); + self.pending_replace_all = self.entity.read(cx).pending_search.is_some(); return; } self.pending_replace_all = false; @@ -988,6 +1349,7 @@ impl ProjectSearchView { return; }; let query = query.clone().with_replacement(self.replacement(cx)); + self.confirm_active_search(cx); let match_ranges = self .entity @@ -1077,22 +1439,59 @@ impl ProjectSearchView { editor }); // 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); + subscriptions.push(cx.subscribe_in( + &query_editor, + window, + |this, _, event: &EditorEvent, window, 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); + } + } + + if EditorSettings::get_global(cx).search.search_on_type && !this.is_dirty(cx) { + if this.query_editor.read(cx).is_empty(cx) { + this.debounced_search = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(SEARCH_ON_TYPE_DEBOUNCE) + .await; + this.update_in(cx, |this, window, cx| { + if this.query_editor.read(cx).is_empty(cx) + && !this.is_dirty(cx) + { + this.last_search_signature = None; + this.pending_replace_next = false; + this.pending_replace_all = false; + if this + .panels_with_errors + .remove(&InputPanel::Query) + .is_some() + { + cx.notify(); + } + this.entity.update(cx, |model, cx| model.clear(cx)); + this.results_editor.update(cx, |editor, cx| { + editor.reset_gutter_line_number_width(cx); + editor.scroll(Point::default(), window, cx); + }); + } + }) + .ok(); + })); + } else { + this.schedule_search_on_type(cx); + } } } cx.emit(ViewEvent::EditorEvent(event.clone())) - }), - ); + }, + )); let replacement_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); editor.set_placeholder_text(REPLACE_PLACEHOLDER, window, cx); @@ -1105,9 +1504,15 @@ impl ProjectSearchView { let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx); editor.set_searchable(false); editor.set_in_project_search(true); + editor.enable_sticky_gutter_line_number(cx); editor }); subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))); + subscriptions.push( + cx.on_focus(&results_editor.focus_handle(cx), window, |this, _, cx| { + this.confirm_active_search(cx); + }), + ); subscriptions.push( cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| { @@ -1130,11 +1535,13 @@ impl ProjectSearchView { editor }); // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes - subscriptions.push( - cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| { + subscriptions.push(cx.subscribe( + &included_files_editor, + |this, _, event: &EditorEvent, cx| { + this.schedule_search_on_type_for_filter_edit(event, cx); cx.emit(ViewEvent::EditorEvent(event.clone())) - }), - ); + }, + )); let excluded_files_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -1143,11 +1550,13 @@ impl ProjectSearchView { editor }); // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes - subscriptions.push( - cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| { + subscriptions.push(cx.subscribe( + &excluded_files_editor, + |this, _, event: &EditorEvent, cx| { + this.schedule_search_on_type_for_filter_edit(event, cx); cx.emit(ViewEvent::EditorEvent(event.clone())) - }), - ); + }, + )); let focus_handle = cx.focus_handle(); subscriptions.push(cx.on_focus(&focus_handle, window, |_, window, cx| { @@ -1195,8 +1604,11 @@ impl ProjectSearchView { filters_enabled, replace_enabled: false, pending_replace_all: false, + pending_replace_next: false, included_opened_only: false, regex_language: None, + debounced_search: None, + last_search_signature: None, _subscriptions: subscriptions, }; @@ -1272,7 +1684,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, SearchMode::Manual, false, cx); entity }); let weak_workspace = cx.entity().downgrade(); @@ -1468,23 +1880,130 @@ impl ProjectSearchView { }; if should_search { this.update(cx, |this, cx| { - this.search(cx); + this.search(SearchMode::Manual, cx); })?; } anyhow::Ok(()) }) } - fn search(&mut self, cx: &mut Context) { - let open_buffers = if self.included_opened_only { - self.workspace - .update(cx, |workspace, cx| self.open_buffers(cx, workspace)) - .ok() - } else { - None - }; + fn search(&mut self, mode: SearchMode, cx: &mut Context) { + let open_buffers = self.open_buffers_for_search(cx); + let signature = self.search_signature_for(open_buffers.as_deref(), cx); + self.search_with_signature(mode, signature, open_buffers, cx); + } + + fn search_with_signature( + &mut self, + mode: SearchMode, + signature: SearchSignature, + open_buffers: Option>>, + cx: &mut Context, + ) { if let Some(query) = self.build_search_query(cx, open_buffers) { - self.entity.update(cx, |model, cx| model.search(query, cx)); + self.debounced_search = None; + let same_inputs = self.last_search_signature.as_ref() == Some(&signature); + if !same_inputs { + self.pending_replace_next = false; + self.pending_replace_all = false; + } + self.last_search_signature = Some(signature); + self.entity + .update(cx, |model, cx| model.search(query, mode, same_inputs, cx)); + } + } + + fn confirm_active_search(&mut self, cx: &mut Context) { + let model = self.entity.read(cx); + if model.phase == SearchPhase::Typing { + self.search_id = model.search_id; + self.entity.update(cx, |model, cx| { + model.phase = SearchPhase::Confirmed; + model.record_search_history(cx); + }); + } + } + + /// The signature captures search inputs, not project content; returning to a previously + /// searched query shows the old results until the user confirms with Enter, which always + /// forces a refresh. + fn schedule_search_on_type(&mut self, cx: &mut Context) { + if self.is_dirty(cx) { + return; + } + self.debounced_search = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(SEARCH_ON_TYPE_DEBOUNCE) + .await; + this.update(cx, |this, cx| { + if this.is_dirty(cx) { + return; + } + let open_buffers = this.open_buffers_for_search(cx); + let signature = this.search_signature_for(open_buffers.as_deref(), cx); + if this.last_search_signature.as_ref() != Some(&signature) { + this.search_with_signature(SearchMode::OnType, signature, open_buffers, cx); + } + }) + .ok(); + })); + } + + fn search_signature(&self, cx: &App) -> SearchSignature { + let open_buffers = self.open_buffers_for_search(cx); + self.search_signature_for(open_buffers.as_deref(), cx) + } + + fn open_buffers_for_search(&self, cx: &App) -> Option>> { + self.included_opened_only.then(|| { + self.workspace + .read_with(cx, |workspace, cx| self.open_buffers(cx, workspace)) + .unwrap_or_default() + }) + } + + fn search_signature_for( + &self, + open_buffers: Option<&[Entity]>, + cx: &App, + ) -> SearchSignature { + SearchSignature { + query: self.search_query_text(cx), + included_files: self + .filters_enabled + .then(|| self.included_files_editor.read(cx).text(cx)) + .unwrap_or_default(), + excluded_files: self + .filters_enabled + .then(|| self.excluded_files_editor.read(cx).text(cx)) + .unwrap_or_default(), + options: self.search_options, + filters_enabled: self.filters_enabled, + included_opened_only: self.included_opened_only, + opened_buffers: self.included_opened_only.then(|| { + let mut buffer_ids = open_buffers + .unwrap_or_default() + .iter() + .map(|buffer| buffer.entity_id()) + .collect::>(); + buffer_ids.sort_unstable(); + buffer_ids.dedup(); + buffer_ids + }), + } + } + + fn schedule_search_on_type_for_filter_edit( + &mut self, + event: &EditorEvent, + cx: &mut Context, + ) { + if matches!(event, EditorEvent::Edited { .. }) + && EditorSettings::get_global(cx).search.search_on_type + && self.filters_enabled + && !self.query_editor.read(cx).is_empty(cx) + { + self.schedule_search_on_type(cx); } } @@ -1640,6 +2159,9 @@ impl ProjectSearchView { fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context) { if let Some(index) = self.active_match_index { let match_ranges = self.entity.read(cx).match_ranges.clone(); + if match_ranges.is_empty() { + return; + } if !EditorSettings::get_global(cx).search_wrap && ((direction == Direction::Next && index + 1 >= match_ranges.len()) @@ -1701,6 +2223,7 @@ impl ProjectSearchView { self.entity.update(cx, |search, _| { search.active_query = Some(query.clone()); search.last_search_query_text = Some(query_text.clone()); + search.phase = SearchPhase::Confirmed; // Force `entity_changed` to treat this as a new search so the // first match gets selected and scrolled into view. The text // finder ran its searches via `project.search` directly, so the @@ -1708,6 +2231,7 @@ impl ProjectSearchView { search.search_id += 1; }); self.set_search_editor(SearchInputKind::Query, &query_text, window, cx); + self.last_search_signature = Some(self.search_signature(cx)); self.focus_results_editor(window, cx); } else { self.focus_query_editor(window, cx); @@ -1757,39 +2281,69 @@ 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 phase = model.phase; + let concluded_no_results = model.search_state.no_results_so_far(); + let search_pending = model.pending_search.is_some(); + let results_stale = search_pending && !model.results_refreshed; + let preserve_view = phase == SearchPhase::Typing || results_stale; + let match_ranges = model.match_ranges.clone(); + self.results_editor + .update(cx, |editor, _| editor.hold_scrollbar_range(search_pending)); if match_ranges.is_empty() { self.active_match_index = None; - self.results_editor.update(cx, |editor, cx| { - editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx); - }); + if !results_stale { + self.results_editor.update(cx, |editor, cx| { + editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx); + editor.refit_gutter_line_number_width(cx); + if concluded_no_results { + editor.scroll(Point::default(), window, cx); + } + }); + } } else { self.active_match_index = Some(0); self.update_match_index(cx); - let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id); - let is_new_search = self.search_id != prev_search_id; - self.results_editor.update(cx, |editor, cx| { + // While typing, do not advance `search_id` either: the old results are still on screen, + // so a premature bump would make the eventual confirmed swap look like a stale search + // and skip selecting/scrolling to the first match. + if !preserve_view { + let prev_search_id = + mem::replace(&mut self.search_id, self.entity.read(cx).search_id); + let is_new_search = self.search_id != prev_search_id; if is_new_search { - let range_to_select = match_ranges - .first() - .map(|range| editor.range_for_match(range)); - editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(range_to_select) + self.results_editor.update(cx, |editor, cx| { + let range_to_select = match_ranges + .first() + .map(|range| editor.range_for_match(range)); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(range_to_select) + }); + editor.scroll(Point::default(), window, cx); + editor.refit_gutter_line_number_width(cx); }); - editor.scroll(Point::default(), window, cx); + if phase == SearchPhase::Confirmed + && self.query_editor.focus_handle(cx).is_focused(window) + { + self.focus_results_editor(window, cx); + } + } else if !search_pending && phase == SearchPhase::Confirmed { + self.results_editor + .update(cx, |editor, cx| editor.refit_gutter_line_number_width(cx)); } - }); - if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) { - self.focus_results_editor(window, cx); } } cx.emit(ViewEvent::UpdateTab); cx.notify(); - if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() { - self.replace_all(&ReplaceAll, window, cx); + if self.entity.read(cx).pending_search.is_none() { + if self.pending_replace_all { + self.replace_all(&ReplaceAll, window, cx); + } else if self.pending_replace_next { + self.replace_next(&ReplaceNext, window, cx); + } } } @@ -1847,9 +2401,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_type { + "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") @@ -1986,12 +2544,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_type { + if search_view.query_editor.read(cx).is_empty(cx) { + return; + } + search_view.debounced_search = None; + if search_view.is_dirty(cx) { + search_view + .prompt_to_save_if_dirty_then_search(window, cx) + .detach_and_log_err(cx); + } else { + search_view.search(SearchMode::Manual, cx); + } + } else { search_view .prompt_to_save_if_dirty_then_search(window, cx) .detach_and_log_err(cx); @@ -2064,12 +2638,17 @@ impl ProjectSearchBar { let search_view = this.active_project_search.as_ref()?; search_view.update(cx, |search_view, cx| { search_view.toggle_search_option(option, cx); - search_view - .entity - .read(cx) - .active_query - .is_some() - .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx)) + if search_view.entity.read(cx).active_query.is_none() { + return None; + } + if EditorSettings::get_global(cx).search.search_on_type + && !search_view.is_dirty(cx) + { + search_view.search(SearchMode::Refresh, cx); + None + } else { + Some(search_view.prompt_to_save_if_dirty_then_search(window, cx)) + } }) })?; if let Some(task) = task { @@ -2109,6 +2688,12 @@ impl ProjectSearchBar { search_view .excluded_files_editor .update(cx, |_, cx| cx.notify()); + if EditorSettings::get_global(cx).search.search_on_type + && search_view.entity.read(cx).active_query.is_some() + && !search_view.is_dirty(cx) + { + search_view.search(SearchMode::Refresh, cx); + } window.refresh(); cx.notify(); }); @@ -2129,12 +2714,17 @@ impl ProjectSearchBar { let search_view = this.active_project_search.as_ref()?; search_view.update(cx, |search_view, cx| { search_view.toggle_opened_only(window, cx); - search_view - .entity - .read(cx) - .active_query - .is_some() - .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx)) + if search_view.entity.read(cx).active_query.is_none() { + return None; + } + if EditorSettings::get_global(cx).search.search_on_type + && !search_view.is_dirty(cx) + { + search_view.search(SearchMode::Refresh, cx); + None + } else { + Some(search_view.prompt_to_save_if_dirty_then_search(window, cx)) + } }) })?; if let Some(task) = task { @@ -2339,15 +2929,16 @@ impl Render for ProjectSearchBar { let is_search_underway = project_search.pending_search.is_some(); let color_override = match ( - project_search.search_state, &project_search.active_query, &project_search.last_search_query_text, ) { - ( - SearchState::Completed(SearchCompletion::NoResults), - Some(query), - Some(previous_query), - ) if query.as_str() == previous_query => Some(Color::Error), + (Some(query), Some(previous_query)) + if query.as_str() == previous_query + && project_search.search_state.no_results_so_far() + && project_search.match_ranges.is_empty() => + { + Some(Color::Error) + } _ => None, }; @@ -2800,7 +3391,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(SearchMode::Manual, cx); }); cx.run_until_parked(); } @@ -2808,7 +3399,9 @@ pub fn perform_project_search( #[cfg(test)] pub mod tests { use std::{ + cell::RefCell, path::PathBuf, + rc::Rc, sync::{ Arc, atomic::{self, AtomicUsize}, @@ -2825,7 +3418,7 @@ pub mod tests { use serde_json::json; use settings::{ InlayHintSettingsContent, SearchSettingsContent, SeedQuerySetting, SettingsStore, - ThemeColorsContent, ThemeStyleContent, + SplicingVec, ThemeColorsContent, ThemeStyleContent, }; use util::{path, paths::PathStyle, rel_path::rel_path}; use util_macros::perf; @@ -3551,7 +4144,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -3595,7 +4188,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -3748,7 +4341,7 @@ pub mod tests { .update(cx, |exclude_editor, cx| { exclude_editor.set_text("four.rs", window, cx) }); - search_view.search(cx); + search_view.search(SearchMode::Manual, cx); }); }) .unwrap(); @@ -3778,7 +4371,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -3905,7 +4498,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -3950,7 +4543,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(SearchMode::Manual, cx); }) }) .unwrap(); @@ -4050,7 +4643,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -4207,7 +4800,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -4282,7 +4875,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -4294,7 +4887,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -4305,7 +4898,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(SearchMode::Manual, cx); }) }) .unwrap(); @@ -4468,7 +5061,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -4746,7 +5339,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(SearchMode::Manual, cx); }); }) .unwrap(); @@ -5151,7 +5744,7 @@ pub mod tests { .expect("should open a project search view"); project_search_view.update(&mut cx, |search_view, cx| { assert_eq!(search_view.search_query_text(cx), r"z\.d"); - search_view.search(cx); + search_view.search(SearchMode::Manual, cx); }); cx.run_until_parked(); @@ -5173,7 +5766,7 @@ pub mod tests { }); project_search_view.update(&mut cx, |search_view, cx| { assert_eq!(search_view.search_query_text(cx), "z.d"); - search_view.search(cx); + search_view.search(SearchMode::Manual, cx); }); cx.run_until_parked(); @@ -5434,8 +6027,8 @@ pub mod tests { .unwrap(); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 2, - "We did drop the previous buffer when cleared the old project search results, hence another query was made", + 1, + "Re-searching the same query reuses the excerpts and their buffer, so the cached hints stay valid", ); let singleton_editor = workspace @@ -5462,7 +6055,7 @@ pub mod tests { }); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 2, + 1, "Opening the same buffer again should reuse the cached hints", ); @@ -5485,7 +6078,7 @@ pub mod tests { }); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 3, + 2, "We have edited the buffer and should send a new request", ); @@ -5500,7 +6093,7 @@ pub mod tests { cx.executor().run_until_parked(); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 4, + 3, "We have edited the buffer again and should send a new request again", ); singleton_editor.update(cx, |editor, cx| { @@ -5519,7 +6112,7 @@ pub mod tests { cx.executor().run_until_parked(); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 5, + 4, "After a server refresh request, we should have sent another request", ); @@ -5528,7 +6121,7 @@ pub mod tests { cx.executor().run_until_parked(); assert_eq!( requests_count.load(atomic::Ordering::Acquire), - 5, + 4, "New project search should reuse the cached hints", ); search_view @@ -5979,6 +6572,16 @@ pub mod tests { 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_type = Some(false); + }); + }); }); } @@ -5992,7 +6595,7 @@ pub mod tests { search_view.query_editor.update(cx, |query_editor, cx| { query_editor.set_text(text, window, cx) }); - search_view.search(cx); + search_view.search(SearchMode::Manual, cx); }) .unwrap(); // Ensure editor highlights appear after the search is done @@ -6001,4 +6604,2697 @@ pub mod tests { ); cx.background_executor.run_until_parked(); } + + #[gpui::test] + async fn test_incremental_search_narrows_and_widens(cx: &mut TestAppContext) { + init_test(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;", + }), + ) + .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 expected_one_matches = vec![ + "one", "ONE", "ONE", "ONE", "ONE", "one", "ONE", "one", "ONE", "one", "ONE", + ]; + + // Initial non-incremental search for "ONE" — inserts one excerpt per file. + perform_search(search_view, "ONE", cx); + assert_eq!(match_texts(&search, cx), expected_one_matches); + assert_all_highlights_match_query(&search, "ONE", cx); + + // Narrowing: "ONE" -> "ONER". Only one.rs has ONEROUS. + perform_incremental_search(search_view, "ONER", cx); + assert_eq!(match_texts(&search, cx), vec!["ONER"]); + assert_all_highlights_match_query(&search, "ONER", cx); + + // Continue narrowing: "ONER" -> "ONEROUS". Still one.rs only. + perform_incremental_search(search_view, "ONEROUS", cx); + assert_eq!(match_texts(&search, cx), vec!["ONEROUS"]); + assert_all_highlights_match_query(&search, "ONEROUS", cx); + + // Backspace to "ONER" — still one.rs only. + perform_incremental_search(search_view, "ONER", cx); + assert_eq!(match_texts(&search, cx), vec!["ONER"]); + + // Backspace to "ONE" — all files re-appear. + perform_incremental_search(search_view, "ONE", cx); + assert_eq!(match_texts(&search, cx), expected_one_matches); + assert_all_highlights_match_query(&search, "ONE", cx); + + // Narrow to "ONLY_ONE" — single match in only_one.rs. + perform_incremental_search(search_view, "ONLY_ONE", cx); + assert_eq!(match_texts(&search, cx), vec!["ONLY_ONE"]); + assert_all_highlights_match_query(&search, "ONLY_ONE", cx); + + // Widen back to "ONE" — all files re-appear. + perform_incremental_search(search_view, "ONE", cx); + assert_eq!(match_texts(&search, cx), expected_one_matches); + assert_all_highlights_match_query(&search, "ONE", cx); + } + + #[gpui::test] + async fn test_incremental_search_gutter_width_never_shrinks(cx: &mut TestAppContext) { + init_test(cx); + + let big_file = (1..=12000) + .map(|i| { + if i == 11000 { + format!("line {i}: needle_in_big") + } else { + format!("line {i}: filler") + } + }) + .collect::>() + .join("\n"); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "small.rs": "fn needle_small() {}", + "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) + }); + + perform_search(search_view, "needle_in_big", cx); + assert_eq!(match_count(&search, cx), 1); + assert_eq!(reserved_gutter_digits(search_view, cx), 5); + + perform_incremental_search(search_view, "needle_small", cx); + assert_eq!(match_count(&search, cx), 1); + assert_eq!(reserved_gutter_digits(search_view, cx), 5); + + perform_search(search_view, "needle_small", cx); + assert_eq!(reserved_gutter_digits(search_view, cx), 1); + } + + #[gpui::test] + async fn test_search_on_type_keeps_focus_confirm_shifts_it(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + 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;", + }), + cx, + ) + .await; + + // Typing triggers a debounced incremental search but must not steal focus + // from the query editor. + 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); + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + 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 while typing with search_on_type", + ); + assert!( + !search_view + .results_editor + .focus_handle(cx) + .is_focused(window), + "Results editor should not be focused while typing", + ); + }); + }) + .unwrap(); + + // Confirming the search shifts focus to the results editor. + 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 + .results_editor + .focus_handle(cx) + .is_focused(window), + "Results editor should be focused after confirming", + ); + assert_eq!(search_view.active_match_index, Some(0)); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_confirm_while_reusing_search_pending_defers_focus(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }), + cx, + ) + .await; + + // Run an initial on-type search for "ONE" to completion. + 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); + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + + // Change the query but confirm before the debounce fires: `confirm` starts a reusing + // search that is still pending, so `match_ranges` still holds "ONE" results. + 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("TWO", window, cx); + }); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + search_view.entity.read(cx).pending_search.is_some(), + "Confirming a stale query starts a reusing search that is still pending", + ); + assert!( + search_view.query_editor.focus_handle(cx).is_focused(window), + "Focus must stay on the query editor until the pending search resolves \ + so we never select against the previous query's stale matches", + ); + }); + }) + .unwrap(); + + // Once the confirmed search resolves, focus shifts to the fresh results. + cx.background_executor.run_until_parked(); + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + search_view + .results_editor + .focus_handle(cx) + .is_focused(window), + "Results editor should be focused after the confirmed search completes", + ); + assert_eq!(search_view.active_match_index, Some(0)); + let model = search_view.entity.read(cx); + let snapshot = model.excerpts.read(cx).snapshot(cx); + let match_texts = model + .match_ranges + .iter() + .map(|range| snapshot.text_for_range(range.clone()).collect::()) + .collect::>(); + assert!( + match_texts.iter().all(|text| text == "TWO"), + "Selection and results should reflect the confirmed query, got {match_texts:?}", + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_incremental_search_reuses_unchanged_excerpts(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "aaa.rs": "fn needle() {}", + "bbb.rs": "fn needle_stable() {}", + }), + ) + .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) + }); + + perform_search(search_view, "needle", cx); + assert_eq!(match_texts(&search, cx), vec!["needle", "needle"]); + + let excerpts = search.read_with(cx, |search, _| search.excerpts.clone()); + let (stale_buffer_id, stable_buffer_id) = cx.read(|cx| { + let buffer_id_for_path = |path: &util::rel_path::RelPath| { + excerpts.read(cx).all_buffers_iter().find_map(|buffer| { + let buffer = buffer.read(cx); + (buffer.file()?.path().as_ref() == path).then(|| buffer.remote_id()) + }) + }; + ( + buffer_id_for_path(rel_path("aaa.rs")).expect("aaa.rs buffer expected"), + buffer_id_for_path(rel_path("bbb.rs")).expect("bbb.rs buffer expected"), + ) + }); + let excerpts_for_buffer = |buffer_id, cx: &mut TestAppContext| { + cx.read(|cx| { + excerpts + .read(cx) + .snapshot(cx) + .excerpts_for_buffer(buffer_id) + .collect::>() + }) + }; + let stale_excerpts_before = excerpts_for_buffer(stale_buffer_id, cx); + let stable_excerpts_before = excerpts_for_buffer(stable_buffer_id, cx); + + let updated_paths = Rc::new(RefCell::new(Vec::new())); + let removed_buffers = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&excerpts, { + let updated_paths = updated_paths.clone(); + let removed_buffers = removed_buffers.clone(); + move |_, event: &multi_buffer::Event, _| match event { + multi_buffer::Event::BufferRangesUpdated { path_key, .. } => { + updated_paths.borrow_mut().push(path_key.clone()); + } + multi_buffer::Event::BuffersRemoved { removed_buffer_ids } => { + removed_buffers + .borrow_mut() + .extend(removed_buffer_ids.iter().copied()); + } + _ => {} + } + }) + }); + + // Re-running the same query incrementally must reuse every excerpt as is, + // without editing the multibuffer at all. + perform_incremental_search(search_view, "needle", cx); + assert_eq!(match_texts(&search, cx), vec!["needle", "needle"]); + assert_eq!( + excerpts_for_buffer(stale_buffer_id, cx), + stale_excerpts_before + ); + assert_eq!( + excerpts_for_buffer(stable_buffer_id, cx), + stable_excerpts_before + ); + assert_eq!(*updated_paths.borrow(), Vec::::new()); + assert_eq!(*removed_buffers.borrow(), Vec::::new()); + + // Narrowing incremental search: bbb.rs keeps matching on the same line, so its + // excerpt keeps its context range, while aaa.rs gets pruned as stale. + perform_incremental_search(search_view, "needle_stable", cx); + assert_eq!(match_texts(&search, cx), vec!["needle_stable"]); + assert_eq!(*removed_buffers.borrow(), vec![stale_buffer_id]); + assert_eq!(excerpts_for_buffer(stale_buffer_id, cx), Vec::new()); + assert_eq!( + excerpts_for_buffer(stable_buffer_id, cx) + .into_iter() + .map(|range| range.context) + .collect::>(), + stable_excerpts_before + .into_iter() + .map(|range| range.context) + .collect::>() + ); + } + + #[gpui::test] + async fn test_search_on_type_history_navigation(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + project, + window, + search_bar, + search_view, + cx, + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }), + cx, + ) + .await; + + let read_query_history = |cx: &mut VisualTestContext| { + cx.read(|cx| { + project + .read(cx) + .search_history(SearchInputKind::Query) + .iter() + .map(str::to_string) + .collect::>() + }) + }; + let read_query_text = |cx: &mut VisualTestContext| { + cx.read(|cx| search_view.read(cx).query_editor.read(cx).text(cx)) + }; + + // Typing runs debounced incremental searches that must not touch the history. + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(read_query_history(cx), Vec::::new()); + + // Confirming records the query into the history. + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!(read_query_history(cx), vec!["ONE".to_string()]); + + 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("TWO", window, cx) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(read_query_history(cx), vec!["ONE".to_string()]); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + read_query_history(cx), + vec!["TWO".to_string(), "ONE".to_string()] + ); + + // Up walks back through the confirmed entries. + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.focus_search(window, cx); + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + }) + .unwrap(); + assert_eq!(read_query_text(cx), "ONE"); + + // There is nothing before the first entry. + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.focus_search(window, cx); + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + }) + .unwrap(); + assert_eq!(read_query_text(cx), "ONE"); + + // Down walks forward again. + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.focus_search(window, cx); + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + }) + .unwrap(); + assert_eq!(read_query_text(cx), "TWO"); + + // Let the debounced searches triggered by history navigation run: they must + // not add new history entries or reset the cursor. + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!( + read_query_history(cx), + vec!["TWO".to_string(), "ONE".to_string()] + ); + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.focus_search(window, cx); + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + }) + .unwrap(); + assert_eq!(read_query_text(cx), "ONE"); + } + + #[gpui::test] + async fn test_select_next_match_during_pending_incremental_search(cx: &mut TestAppContext) { + init_test(cx); + + 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;", + }), + ) + .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) + }); + + perform_search(search_view, "ONE", cx); + assert_eq!(match_count(&search, cx), 5); + + search_view + .update(cx, |search_view, _window, cx| { + search_view.search(SearchMode::OnType, cx); + }) + .unwrap(); + search_view + .update(cx, |search_view, window, cx| { + assert_eq!( + search_view.entity.read(cx).match_ranges.len(), + 5, + "previous matches stay in place until the pending incremental search completes" + ); + assert_eq!(search_view.active_match_index, Some(0)); + search_view.select_match(Direction::Next, window, cx); + search_view.select_match(Direction::Prev, window, cx); + assert_eq!( + search_view.active_match_index, + Some(0), + "navigation works against the preserved matches instead of an empty list" + ); + }) + .unwrap(); + + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 5); + } + + #[gpui::test] + async fn test_incremental_search_preserves_scroll_position(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "1.txt": "\n\n\n\n\n A \n\n\n\n\n", + "2.txt": "\n\n\n\n\n A \n\n\n\n\n", + "3.rs": "\n\n\n\n\n A \n\n\n\n\n", + "4.rs": "\n\n\n\n\n A \n\n\n\n\n", + "5.rs": "\n\n\n\n\n A \n\n\n\n\n", + "6.rs": "\n\n\n\n\n A \n\n\n\n\n", + "7.rs": "\n\n\n\n\n A \n\n\n\n\n", + "8.rs": "\n\n\n\n\n A \n\n\n\n\n", + "9.rs": "\n\n\n\n\n A \n\n\n\n\n", + "a.rs": "\n\n\n\n\n A \n\n\n\n\n", + }), + ) + .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, cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + perform_search(search_view, "A", cx); + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert_eq!( + results_editor.scroll_position(cx), + Point::default(), + "a confirmed search scrolls to the top" + ); + results_editor.scroll(Point::new(0., f64::MAX), window, cx); + }); + }) + .unwrap(); + + perform_incremental_search(search_view, "A", cx); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert!( + results_editor.scroll_position(cx).y > 0., + "search-on-type should not scroll the results back to the top" + ); + }); + }) + .unwrap(); + + perform_search(search_view, "A", cx); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert_eq!( + results_editor.scroll_position(cx), + Point::default(), + "confirming the query scrolls back to the top" + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_search_on_type_resets_scroll_after_empty_results(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "1.txt": "\n\n\n\n\n A \n\n\n\n\n", + "2.txt": "\n\n\n\n\n A \n\n\n\n\n", + "3.rs": "\n\n\n\n\n A \n\n\n\n\n", + "4.rs": "\n\n\n\n\n A \n\n\n\n\n", + "5.rs": "\n\n\n\n\n A \n\n\n\n\n", + "6.rs": "\n\n\n\n\n A \n\n\n\n\n", + "7.rs": "\n\n\n\n\n A \n\n\n\n\n", + "8.rs": "\n\n\n\n\n A \n\n\n\n\n", + "9.rs": "\n\n\n\n\n A \n\n\n\n\n", + "a.rs": "\n\n\n\n\n A \n\n\n\n\n", + }), + ) + .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, cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + let type_query = |query: &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(query, window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + }; + + type_query("A", cx); + assert_eq!(match_count(&search, cx), 10); + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.scroll(Point::new(0., f64::MAX), window, cx); + assert!( + results_editor.scroll_position(cx).y > 0., + "results should be long enough to scroll down" + ); + }); + }) + .unwrap(); + + type_query("", cx); + assert_eq!(match_count(&search, cx), 0); + + type_query("A", cx); + assert_eq!(match_count(&search, cx), 10); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert_eq!( + results_editor.scroll_position(cx), + Point::default(), + "erasing the query fully must reset the scroll, \ + a retyped query starts at the top" + ); + }); + }) + .unwrap(); + + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.scroll(Point::new(0., f64::MAX), window, cx); + }); + }) + .unwrap(); + + type_query("NOTHINGmATCHEStHIS", cx); + assert_eq!(match_count(&search, cx), 0); + + type_query("A", cx); + assert_eq!(match_count(&search, cx), 10); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert_eq!( + results_editor.scroll_position(cx), + Point::default(), + "a query with no hits destroys all excerpts and with them the scroll \ + anchor, so widening the query back must start at the top instead of \ + leaving the user stranded past the last result" + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_search_on_type_surfaces_query_errors(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .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) + }); + + search_view + .update(cx, |search_view, window, cx| { + search_view.search_options.insert(SearchOptions::REGEX); + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("(unclosed", window, cx) + }); + search_view.search(SearchMode::OnType, cx); + assert!( + search_view + .panels_with_errors + .contains_key(&InputPanel::Query), + "an invalid regex must surface an error even without confirming, otherwise \ + search-on-type results silently stop updating" + ); + }) + .unwrap(); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("closed", window, cx) + }); + search_view.search(SearchMode::OnType, cx); + assert_eq!( + search_view.panels_with_errors.get(&InputPanel::Query), + None, + "fixing the query clears the error again" + ); + }) + .unwrap(); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("(unclosed", window, cx) + }); + search_view.search(SearchMode::OnType, cx); + }) + .unwrap(); + search_view + .update(cx, |search_view, window, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("", window, cx)); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + search_view + .update(cx, |search_view, _, cx| { + assert_eq!( + search_view.panels_with_errors.get(&InputPanel::Query), + None, + "erasing the query must clear its stale error, \ + an empty input has nothing to be invalid" + ); + assert_eq!(search_view.entity.read(cx).match_ranges.len(), 0); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_no_results_verdict_lifecycle(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .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 no_results_so_far = |cx: &mut TestAppContext| { + search.read_with(cx, |search, _| search.search_state.no_results_so_far()) + }; + + assert!(!no_results_so_far(cx), "an idle search has no verdict yet"); + + perform_search(search_view, "sOMETHINGtHATsURELYdOESnOTeXIST", cx); + assert_eq!(match_count(&search, cx), 0); + assert!(no_results_so_far(cx)); + + search_view + .update(cx, |search_view, _, cx| { + search_view.search(SearchMode::OnType, cx); + assert!( + search_view.entity.read(cx).pending_search.is_some(), + "the re-search should be pending" + ); + }) + .unwrap(); + assert!( + no_results_so_far(cx), + "a pending re-search must hold the previous no-results verdict instead of \ + flickering through an indeterminate state" + ); + cx.background_executor.run_until_parked(); + assert!(no_results_so_far(cx)); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXISTeITHER", window, cx) + }); + search_view.search(SearchMode::OnType, cx); + }) + .unwrap(); + assert!( + !no_results_so_far(cx), + "typing a different query must drop the stale no-results verdict: the new query \ + may well have matches, so asserting 'No Results' for it would be a lie" + ); + cx.background_executor.run_until_parked(); + assert!( + no_results_so_far(cx), + "the new query has no matches either, so the verdict returns once the search settles" + ); + + perform_incremental_search(search_view, "ONE", cx); + assert!(!no_results_so_far(cx)); + assert!(match_count(&search, cx) > 0); + } + + #[gpui::test] + async fn test_search_on_type_confirm_after_filter_change_researches(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const ONE_IN_TWO: usize = 1;", + }), + cx, + ) + .await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 2); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.toggle_filters(cx); + search_view + .excluded_files_editor + .update(cx, |editor, cx| editor.set_text("one.rs", window, cx)); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + assert_eq!(matched_file_names(&search, cx), vec!["two.rs"]); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view + .excluded_files_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 2); + assert_eq!(matched_file_names(&search, cx), vec!["one.rs", "two.rs"]); + } + + #[gpui::test] + async fn test_search_on_type_confirm_after_erasing_query_researches(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = 2;", + }), + cx, + ) + .await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + let type_query = |query: &str, cx: &mut VisualTestContext| { + 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(query, window, cx) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + }; + + type_query("ONE", cx); + assert_eq!(match_count(&search, cx), 1); + + type_query("", cx); + assert_eq!(match_count(&search, cx), 0); + + 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_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + search_view + .results_editor + .focus_handle(cx) + .is_focused(window), + "Confirming a retyped query must behave like any confirmed search", + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_toggle_option_cancels_pending_debounced_search(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = 2;", + }), + cx, + ) + .await; + + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + let initial_search_id = cx.read(|cx| search_view.read(cx).entity.read(cx).search_id); + + 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("TWO", window, cx) + }); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + let search_id_after_toggle = cx.read(|cx| search_view.read(cx).entity.read(cx).search_id); + assert_eq!(search_id_after_toggle, initial_search_id + 1); + + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE * 2); + cx.background_executor.run_until_parked(); + assert_eq!( + cx.read(|cx| search_view.read(cx).entity.read(cx).search_id), + search_id_after_toggle, + ); + } + + #[gpui::test] + async fn test_option_toggle_keeps_confirmed_phase_and_defers_replace_all( + cx: &mut TestAppContext, + ) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + project, + window, + search_bar, + search_view, + cx, + } = &mut setup_search_bar_test(json!({ "one.rs": "one ONE" }), cx).await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + let read_query_history = |cx: &mut VisualTestContext| { + cx.read(|cx| { + project + .read(cx) + .search_history(SearchInputKind::Query) + .iter() + .map(str::to_string) + .collect::>() + }) + }; + + 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 + .replacement_editor + .update(cx, |editor, cx| editor.set_text("x", window, cx)); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 2); + assert_eq!(read_query_history(cx), vec!["one".to_string()]); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + assert_eq!( + search.read_with(cx, |search, _| search.phase), + SearchPhase::Confirmed, + "an option toggle refreshes the confirmed search without demoting it to a typing one" + ); + assert_eq!( + read_query_history(cx), + vec!["one".to_string()], + "refreshing on an option toggle must not duplicate the history entry" + ); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.search(SearchMode::Refresh, cx); + assert!(search_view.entity.read(cx).pending_search.is_some()); + assert_eq!(search_view.entity.read(cx).phase, SearchPhase::Confirmed); + search_view.replace_all(&ReplaceAll, window, cx); + assert!( + search_view.pending_replace_all, + "a replacement during a pending refresh of a confirmed search must be \ + deferred, not dropped" + ); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + let buffer_text = search.read_with(cx, |search, cx| { + search + .excerpts + .read(cx) + .all_buffers_iter() + .next() + .map(|buffer| buffer.read(cx).text()) + }); + assert_eq!( + buffer_text, + Some("x ONE".to_string()), + "the deferred replacement runs against the refreshed results once they settle" + ); + } + + #[gpui::test] + async fn test_opened_only_search_is_ordered_and_reuses_excerpts(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "aaa.rs": "fn needle_first() {}", + "bbb.rs": "fn needle_second() {}", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer_bbb = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/bbb.rs"), cx) + }) + .await + .unwrap(); + let buffer_aaa = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/aaa.rs"), cx) + }) + .await + .unwrap(); + let buffer_untitled = project.update(cx, |project, cx| { + project.create_local_buffer("fn needle_untitled() {}", None, true, cx) + }); + let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); + let build_query = || { + SearchOptions::NONE + .build_query( + "needle", + PathMatcher::default(), + PathMatcher::default(), + false, + Some(vec![ + buffer_bbb.clone(), + buffer_untitled.clone(), + buffer_aaa.clone(), + ]), + ) + .unwrap() + }; + + search.update(cx, |search, cx| { + search.search(build_query(), SearchMode::Manual, false, cx); + }); + cx.run_until_parked(); + let text = search.read_with(cx, |search, cx| { + search.excerpts.read(cx).snapshot(cx).text() + }); + assert_eq!( + text, + "fn needle_untitled() {}\nfn needle_first() {}\nfn needle_second() {}" + ); + assert_eq!(match_count(&search, cx), 3); + + let removed_buffers = Rc::new(RefCell::new(Vec::new())); + let excerpts = search.read_with(cx, |search, _| search.excerpts.clone()); + let _subscription = cx.update(|cx| { + cx.subscribe(&excerpts, { + let removed_buffers = removed_buffers.clone(); + move |_, event: &multi_buffer::Event, _| { + if let multi_buffer::Event::BuffersRemoved { removed_buffer_ids } = event { + removed_buffers + .borrow_mut() + .extend(removed_buffer_ids.iter().copied()); + } + } + }) + }); + + search.update(cx, |search, cx| { + search.search(build_query(), SearchMode::OnType, true, cx); + }); + cx.run_until_parked(); + assert_eq!( + search.read_with(cx, |search, cx| search + .excerpts + .read(cx) + .snapshot(cx) + .text()), + text, + ); + assert_eq!(match_count(&search, cx), 3); + assert_eq!(*removed_buffers.borrow(), Vec::::new()); + } + + #[gpui::test] + async fn test_opened_only_search_deduplicates_buffers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "one.rs": "fn needle() {}" })) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); + + let query = SearchOptions::NONE + .build_query( + "needle", + PathMatcher::default(), + PathMatcher::default(), + false, + Some(vec![buffer.clone(), buffer.clone(), buffer]), + ) + .unwrap(); + search.update(cx, |search, cx| { + search.search(query, SearchMode::Manual, false, cx); + }); + cx.run_until_parked(); + + assert_eq!( + match_count(&search, cx), + 1, + "a buffer open in multiple panes must be searched once, not once per pane" + ); + assert_eq!(match_texts(&search, cx), vec!["needle"]); + } + + #[gpui::test] + async fn test_pruning_stale_excerpts_keeps_scroll_at_seam(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.txt": "\n\n\n\n\nAA\n\n\n\n\n", + "b.txt": "\n\n\n\n\n A \n\n\n\n\n", + "c.txt": "\n\n\n\n\n A \n\n\n\n\n", + "d.txt": "\n\n\n\n\n A \n\n\n\n\n", + "e.txt": "\n\n\n\n\n A \n\n\n\n\n", + "f.txt": "\n\n\n\n\n A \n\n\n\n\n", + "g.txt": "\n\n\n\n\n A \n\n\n\n\n", + "h.txt": "\n\n\n\n\n A \n\n\n\n\n", + "i.txt": "\n\n\n\n\n A \n\n\n\n\n", + "j.txt": "\n\n\n\n\n A \n\n\n\n\n", + }), + ) + .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, cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + perform_search(search_view, "A", cx); + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.scroll(Point::new(0., f64::MAX), window, cx); + assert!( + results_editor.scroll_position(cx).y > 0., + "results should be long enough to scroll down" + ); + }); + }) + .unwrap(); + + perform_incremental_search(search_view, "AA", cx); + assert_eq!(match_count(&search, cx), 1); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert!( + results_editor.scroll_position(cx).y > 0., + "pruning the excerpt under the scroll anchor must not reset the scroll \ + to the top" + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_erase_and_retype_within_debounce_keeps_results(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "1.txt": "\n\n\n\n\n A \n\n\n\n\n", + "2.txt": "\n\n\n\n\n A \n\n\n\n\n", + "3.rs": "\n\n\n\n\n A \n\n\n\n\n", + "4.rs": "\n\n\n\n\n A \n\n\n\n\n", + "5.rs": "\n\n\n\n\n A \n\n\n\n\n", + "6.rs": "\n\n\n\n\n A \n\n\n\n\n", + "7.rs": "\n\n\n\n\n A \n\n\n\n\n", + "8.rs": "\n\n\n\n\n A \n\n\n\n\n", + "9.rs": "\n\n\n\n\n A \n\n\n\n\n", + "a.rs": "\n\n\n\n\n A \n\n\n\n\n", + }), + ) + .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, cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("A", window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 10); + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.scroll(Point::new(0., f64::MAX), window, cx); + assert!( + results_editor.scroll_position(cx).y > 0., + "results should be long enough to scroll down" + ); + }); + }) + .unwrap(); + + search_view + .update(cx, |search_view, window, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("", window, cx)); + }) + .unwrap(); + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("A", window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + + assert_eq!(match_count(&search, cx), 10); + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + assert!( + results_editor.scroll_position(cx).y > 0., + "erasing and retyping the query within the debounce must not clear the \ + results pane in between, so the scroll position survives" + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_confirm_from_filter_editor_keeps_focus_there(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }), + cx, + ) + .await; + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.toggle_filters(cx); + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("ONE", window, cx) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert!(cx.read(|cx| !search_view.read(cx).entity.read(cx).match_ranges.is_empty())); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + let filter_handle = search_view.included_files_editor.focus_handle(cx); + window.focus(&filter_handle, cx); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + assert!( + search_view + .included_files_editor + .focus_handle(cx) + .is_focused(window), + "confirming from a filter editor must not yank focus to the results" + ); + assert!( + !search_view + .results_editor + .focus_handle(cx) + .is_focused(window), + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_toggling_filters_panel_researches_on_type(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const ONE_IN_TWO: usize = 1;", + }), + cx, + ) + .await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.toggle_filters(cx); + search_view + .excluded_files_editor + .update(cx, |editor, cx| editor.set_text("one.rs", window, cx)); + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("ONE", window, cx) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + assert_eq!(matched_file_names(&search, cx), vec!["two.rs"]); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_filters(window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + match_count(&search, cx), + 2, + "disabling the filters panel must re-run the search without the exclusions, \ + without waiting for Enter" + ); + assert_eq!(matched_file_names(&search, cx), vec!["one.rs", "two.rs"]); + } + + #[gpui::test] + async fn test_focusing_results_records_history(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + project, + window, + search_view, + cx, + .. + } = &mut setup_search_bar_test(json!({ "one.rs": "const ONE: usize = 1;" }), cx).await; + + let read_query_history = |cx: &mut VisualTestContext| { + cx.read(|cx| { + project + .read(cx) + .search_history(SearchInputKind::Query) + .iter() + .map(str::to_string) + .collect::>() + }) + }; + + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(read_query_history(cx), Vec::::new()); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view.search(SearchMode::OnType, cx); + assert!( + search_view.entity.read(cx).pending_search.is_some(), + "the re-search should be pending" + ); + let results_handle = search_view.results_editor.focus_handle(cx); + window.focus(&results_handle, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + read_query_history(cx), + vec!["ONE".to_string()], + "engaging with the results while the search is still pending must record it in \ + the history" + ); + cx.read(|cx| { + let search_view = search_view.read(cx); + let model = search_view.entity.read(cx); + assert_eq!( + search_view.search_id, model.search_id, + "confirming by focus must sync the search id so the settled search does not \ + yank the selection away from where the user clicked" + ); + assert_eq!(model.phase, SearchPhase::Confirmed); + }); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + let query_handle = search_view.query_editor.focus_handle(cx); + window.focus(&query_handle, cx); + let results_handle = search_view.results_editor.focus_handle(cx); + window.focus(&results_handle, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + read_query_history(cx), + vec!["ONE".to_string()], + "re-focusing the results must not duplicate the history entry" + ); + } + + #[gpui::test] + async fn test_confirm_ignores_filter_text_while_filters_are_disabled(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test(json!({ "one.rs": "const ONE: usize = 1;" }), cx).await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + let search_id_after_typing = cx.read(|cx| search_view.read(cx).entity.read(cx).search_id); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + search_view + .excluded_files_editor + .update(cx, |editor, cx| editor.set_text("one.rs", window, cx)); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + cx.read(|cx| search_view.read(cx).entity.read(cx).search_id), + search_id_after_typing + 1, + "confirming re-runs the search so it can pick up changed files" + ); + assert_eq!( + match_count(&search, cx), + 1, + "filter text edited while the filters panel is disabled must not be applied" + ); + assert_eq!(matched_file_names(&search, cx), vec!["one.rs"]); + } + + #[gpui::test] + async fn test_confirm_researches_the_same_query(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + project, + window, + search_bar, + search_view, + cx, + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = 2;", + }), + cx, + ) + .await; + let search = cx.read(|cx| search_view.read(cx).entity.clone()); + + 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) + }); + }); + }) + .unwrap(); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/two.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "const ONE_MORE: usize = 1;\n")], None, cx) + }); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + match_count(&search, cx), + 2, + "confirming an unchanged query must re-run the search and pick up new matches" + ); + assert_eq!(matched_file_names(&search, cx), vec!["one.rs", "two.rs"]); + } + + #[gpui::test] + async fn test_debounced_search_skips_unchanged_query(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + window, + search_bar, + search_view, + cx, + .. + } = &mut setup_search_bar_test(json!({ "one.rs": "const ONE: usize = 1;" }), cx).await; + + let type_query = |query: &str, cx: &mut VisualTestContext| { + 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(query, window, cx) + }); + }); + }) + .unwrap(); + }; + let read_search_id = |cx: &mut VisualTestContext| { + cx.read(|cx| search_view.read(cx).entity.read(cx).search_id) + }; + + type_query("ONE", cx); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + let search_id_after_typing = read_search_id(cx); + + type_query("ON", cx); + type_query("ONE", cx); + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!( + read_search_id(cx), + search_id_after_typing, + "a debounced search whose inputs ended up unchanged must be skipped, \ + the results on screen are already correct" + ); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.confirm(&Confirm, window, cx); + }); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + read_search_id(cx), + search_id_after_typing + 1, + "Enter stays a forced refresh even for an unchanged query" + ); + } + + #[gpui::test] + async fn test_replace_next_defers_while_search_is_pending(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const ONE_TWO: usize = 2;", + }), + ) + .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) + }); + + perform_search(search_view, "ONE", cx); + assert_eq!(match_count(&search, cx), 2); + + let buffer_text = |cx: &mut TestAppContext| { + search.read_with(cx, |search, cx| { + search + .excerpts + .read(cx) + .all_buffers_iter() + .find_map(|buffer| { + let buffer = buffer.read(cx); + (buffer.file()?.path().as_ref() == rel_path("one.rs")) + .then(|| buffer.text()) + }) + }) + }; + + search_view + .update(cx, |search_view, window, cx| { + search_view + .replacement_editor + .update(cx, |editor, cx| editor.set_text("NEW", window, cx)); + search_view.search(SearchMode::OnType, cx); + search_view.replace_next(&ReplaceNext, window, cx); + assert!( + !search_view.pending_replace_next, + "a replacement during an unconfirmed on-type search must be dropped, not \ + deferred, so it cannot fire against results the user never confirmed" + ); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + buffer_text(cx), + Some("const ONE: usize = 1;".to_string()), + "no replacement must run for the dropped request" + ); + + search_view + .update(cx, |search_view, window, cx| { + search_view.search(SearchMode::Manual, cx); + search_view.replace_next(&ReplaceNext, window, cx); + assert!(search_view.pending_replace_next); + }) + .unwrap(); + assert_eq!( + buffer_text(cx), + Some("const ONE: usize = 1;".to_string()), + "the replacement must wait for the pending search instead of silently doing nothing" + ); + + cx.background_executor.run_until_parked(); + assert_eq!( + buffer_text(cx), + Some("const NEW: usize = 1;".to_string()), + "the deferred replacement runs once the confirmed search settles" + ); + } + + #[gpui::test] + async fn test_search_on_type_pauses_while_results_are_dirty(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + 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 = 2;", + }), + ) + .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 type_query = |query: &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(query, window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + }; + + type_query("ONE", cx); + assert_eq!(match_count(&search, cx), 1); + let search_id_after_typing = search.read_with(cx, |search, _| search.search_id); + + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.insert("edited", window, cx); + }); + assert!(search_view.is_dirty(cx)); + }) + .unwrap(); + + type_query("TWO", cx); + assert_eq!( + search.read_with(cx, |search, _| search.search_id), + search_id_after_typing, + "search-on-type must pause while the results hold unsaved edits, otherwise it \ + silently replaces the dirty excerpts without any save prompt" + ); + assert_eq!(match_count(&search, cx), 1); + + type_query("", cx); + assert_eq!( + match_count(&search, cx), + 1, + "erasing the query must not clear dirty results either" + ); + + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.undo(&editor::actions::Undo, window, cx); + }); + assert!(!search_view.is_dirty(cx)); + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("TWO", window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!( + match_texts(&search, cx), + vec!["TWO"], + "once the results are clean again, search-on-type resumes" + ); + } + + #[gpui::test] + async fn test_dirtying_results_within_erase_debounce_keeps_them(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .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) + }); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("ONE", window, cx) + }); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!(match_count(&search, cx), 1); + + search_view + .update(cx, |search_view, window, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("", window, cx)); + }) + .unwrap(); + search_view + .update(cx, |search_view, window, cx| { + search_view.results_editor.update(cx, |results_editor, cx| { + results_editor.insert("edited", window, cx); + }); + assert!(search_view.is_dirty(cx)); + }) + .unwrap(); + cx.executor() + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + assert_eq!( + match_count(&search, cx), + 1, + "results made dirty after the erase was scheduled but before the debounce fired \ + must survive, the clear task has to re-check for unsaved edits when it runs" + ); + assert!( + search.read_with(cx, |search, _| search.active_query.is_some()), + "the model must not be cleared while it shows dirty excerpts" + ); + } + + #[gpui::test] + async fn test_incremental_search_merges_chunks_larger_than_a_foreground_batch( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let mut files = serde_json::Map::new(); + for index in 0..70 { + files.insert( + format!("file_{index:03}.txt"), + json!(format!("needle {index:03}")), + ); + } + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), serde_json::Value::Object(files)) + .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, cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + perform_search(search_view, "needle", cx); + assert_eq!(match_count(&search, cx), 70); + + perform_incremental_search(search_view, "needle", cx); + assert_eq!( + match_count(&search, cx), + 70, + "a reused chunk larger than one foreground batch must merge fully" + ); + assert_eq!( + matched_file_names(&search, cx).len(), + 70, + "every file must keep its excerpt across the batched reuse" + ); + + perform_incremental_search(search_view, "needle 000", cx); + assert_eq!( + matched_file_names(&search, cx), + vec!["file_000.txt"], + "narrowing must prune the stale files across all foreground batches" + ); + } + + #[gpui::test] + async fn test_scroll_range_held_while_search_is_pending(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .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 scroll_range_held = |cx: &mut TestAppContext| { + search_view + .read_with(cx, |search_view, cx| { + search_view + .results_editor + .read(cx) + .is_scrollbar_range_held() + }) + .unwrap() + }; + + assert_eq!( + scroll_range_held(cx), + Some(false), + "the results editor opts into scroll range holds on creation and settles while idle" + ); + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("ONE", window, cx) + }); + search_view.search(SearchMode::OnType, cx); + }) + .unwrap(); + assert_eq!( + scroll_range_held(cx), + Some(true), + "the scroll range must be held for the whole pending search, so the \ + scrollbars cannot blink while excerpts churn" + ); + + cx.background_executor.run_until_parked(); + assert_eq!( + scroll_range_held(cx), + Some(false), + "the scroll range settles again once the search completes" + ); + } + + #[gpui::test] + async fn test_incremental_search_prunes_excerpts_without_recorded_matches( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "aaa.rs": "fn needle() {}", + "zzz.rs": "fn unrelated() {}", + }), + ) + .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) + }); + + perform_search(search_view, "needle", cx); + assert_eq!(matched_file_names(&search, cx), vec!["aaa.rs"]); + + let orphan_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/zzz.rs"), cx) + }) + .await + .unwrap(); + search.update(cx, |search, cx| { + search.excerpts.update(cx, |excerpts, cx| { + excerpts.set_excerpts_for_path( + PathKey::for_buffer(&orphan_buffer, cx), + orphan_buffer.clone(), + vec![text::Point::new(0, 0)..text::Point::new(0, 1)], + 2, + cx, + ); + }); + }); + assert_eq!(matched_file_names(&search, cx), vec!["aaa.rs", "zzz.rs"]); + + perform_incremental_search(search_view, "needle", cx); + assert_eq!( + matched_file_names(&search, cx), + vec!["aaa.rs"], + "an excerpt present in the multibuffer but absent from the recorded matches, as \ + left behind by a cancelled search, must be pruned by the next reusing search" + ); + assert_eq!(match_texts(&search, cx), vec!["needle"]); + } + + #[gpui::test] + async fn test_search_with_open_excluded_file_stays_path_key_sorted(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(SplicingVec::from(vec!["**/mmm.rs".to_string()])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "aaa.rs": "fn needle_aa() {}", + "mmm.rs": "fn needle_mm() {}", + "zzz.rs": "fn needle_zz() {}", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let _excluded_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/mmm.rs"), cx) + }) + .await + .unwrap(); + project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().expect("worktree expected"); + assert_eq!( + worktree + .read(cx) + .entry_for_path(rel_path("mmm.rs")) + .map(|entry| entry.id), + None, + "mmm.rs must have no worktree entry for this test to be meaningful" + ); + }); + + 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) + }); + search_view + .update(cx, |search_view, _, _| { + search_view.search_options.insert(SearchOptions::REGEX); + }) + .unwrap(); + + perform_search(search_view, "needle_[a-z]+", cx); + assert_eq!( + match_texts(&search, cx), + vec!["needle_aa", "needle_mm", "needle_zz"], + "the match for the open excluded file must land between the scanned files, in \ + PathKey order" + ); + + let updated_paths = Rc::new(RefCell::new(Vec::new())); + let removed_buffers = Rc::new(RefCell::new(Vec::new())); + let excerpts = search.read_with(cx, |search, _| search.excerpts.clone()); + let _subscription = cx.update(|cx| { + cx.subscribe(&excerpts, { + let updated_paths = updated_paths.clone(); + let removed_buffers = removed_buffers.clone(); + move |_, event: &multi_buffer::Event, _| match event { + multi_buffer::Event::BufferRangesUpdated { path_key, .. } => { + updated_paths.borrow_mut().push(path_key.clone()); + } + multi_buffer::Event::BuffersRemoved { removed_buffer_ids } => { + removed_buffers + .borrow_mut() + .extend(removed_buffer_ids.iter().copied()); + } + _ => {} + } + }) + }); + + perform_incremental_search(search_view, "needle_[a-z]+", cx); + assert_eq!( + match_texts(&search, cx), + vec!["needle_aa", "needle_mm", "needle_zz"] + ); + assert_eq!( + *removed_buffers.borrow(), + Vec::::new(), + "results arriving in PathKey order must reuse every excerpt instead of pruning \ + files ahead of the excluded file's early result" + ); + assert_eq!(*updated_paths.borrow(), Vec::::new()); + } + + #[gpui::test] + async fn test_reusing_search_tolerates_out_of_order_results(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "aaa.rs": "fn needle_aa() {}", + "zzz.rs": "fn needle_zz() {}", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); + let query = SearchOptions::REGEX + .build_query( + "needle_[a-z]+", + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + .unwrap(); + search.update(cx, |search, cx| { + search.search(query, SearchMode::Manual, false, cx); + }); + cx.run_until_parked(); + assert_eq!(match_texts(&search, cx), vec!["needle_aa", "needle_zz"]); + + let buffer_for = |name: &str, cx: &mut TestAppContext| { + search + .read_with(cx, |search, cx| { + search.excerpts.read(cx).all_buffers_iter().find(|buffer| { + buffer + .read(cx) + .file() + .is_some_and(|file| file.path().as_ref() == rel_path(name)) + }) + }) + .expect("buffer expected for a matched file") + }; + let buffer_aaa = buffer_for("aaa.rs", cx); + let buffer_zzz = buffer_for("zzz.rs", cx); + let match_range = |buffer: &Entity, cx: &mut TestAppContext| { + buffer.read_with(cx, |buffer, _| { + buffer.snapshot().anchor_before(3)..buffer.snapshot().anchor_after(12) + }) + }; + let range_aaa = match_range(&buffer_aaa, cx); + let range_zzz = match_range(&buffer_zzz, cx); + + let mut reused_results = search.read_with(cx, |search, cx| ReusedResults::new(search, cx)); + let weak_search = search.downgrade(); + let mut async_cx = cx.to_async(); + apply_reused_chunk( + &weak_search, + vec![(buffer_zzz.clone(), vec![range_zzz.clone()])], + &mut reused_results, + &mut async_cx, + ) + .await + .expect("the search entity is alive"); + apply_reused_chunk( + &weak_search, + vec![(buffer_aaa, vec![range_aaa])], + &mut reused_results, + &mut async_cx, + ) + .await + .expect("the search entity is alive"); + apply_reused_chunk( + &weak_search, + vec![(buffer_zzz, vec![range_zzz])], + &mut reused_results, + &mut async_cx, + ) + .await + .expect("the search entity is alive"); + search.update(cx, |search, cx| reused_results.finish(search, cx)); + + assert_eq!(matched_file_names(&search, cx), vec!["aaa.rs", "zzz.rs"]); + assert_eq!( + match_texts(&search, cx), + vec!["needle_aa", "needle_zz"], + "a result that arrives out of PathKey order must be spliced into its sorted \ + position instead of corrupting the match order, and a duplicate result for an \ + already confirmed buffer must replace its ranges instead of doubling them" + ); + } + + #[gpui::test] + async fn test_deploy_search_with_query_searches_on_type(cx: &mut TestAppContext) { + init_test(cx); + enable_search_on_type(cx); + + let SearchBarTest { + project, + window, + search_view, + cx, + .. + } = &mut setup_search_bar_test( + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }), + cx, + ) + .await; + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &DeploySearch { + query: Some("ONE".to_string()), + ..Default::default() + }, + window, + cx, + ); + }); + cx.read(|cx| { + let model = search_view.read(cx).entity.read(cx); + assert!( + model.pending_search.is_none(), + "deploying with a query must only schedule the debounced search" + ); + assert_eq!(model.match_ranges.len(), 0); + }); + + cx.background_executor + .advance_clock(SEARCH_ON_TYPE_DEBOUNCE + Duration::from_millis(50)); + cx.background_executor.run_until_parked(); + + window + .update(cx, |_, window, cx| { + search_view.update(cx, |search_view, cx| { + let model = search_view.entity.read(cx); + assert!( + !model.match_ranges.is_empty(), + "the seeded query must search on type after the debounce" + ); + assert_eq!( + model.phase, + SearchPhase::Typing, + "a deploy-seeded search stays unconfirmed until the user engages with it" + ); + assert!( + search_view.query_editor.focus_handle(cx).is_focused(window), + "deploying must keep the focus in the query editor" + ); + }); + }) + .unwrap(); + + let query_history = cx.read(|cx| { + project + .read(cx) + .search_history(SearchInputKind::Query) + .iter() + .map(str::to_string) + .collect::>() + }); + assert_eq!( + query_history, + Vec::::new(), + "an unconfirmed deploy-seeded search must not enter the history" + ); + } + + 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(SearchMode::OnType, cx); + }) + .unwrap(); + cx.executor().advance_clock( + editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100), + ); + cx.background_executor.run_until_parked(); + } + + fn match_count(search: &Entity, cx: &mut TestAppContext) -> usize { + search.read_with(cx, |search, _| search.match_ranges.len()) + } + + fn match_texts(search: &Entity, cx: &mut TestAppContext) -> Vec { + search.read_with(cx, |search, cx| { + let snapshot = search.excerpts.read(cx).snapshot(cx); + search + .match_ranges + .iter() + .map(|range| snapshot.text_for_range(range.clone()).collect::()) + .collect() + }) + } + + fn matched_file_names(search: &Entity, cx: &mut TestAppContext) -> Vec { + let mut file_names = search.read_with(cx, |search, cx| { + search + .excerpts + .read(cx) + .all_buffers_iter() + .filter_map(|buffer| Some(buffer.read(cx).file()?.path().file_name()?.to_string())) + .collect::>() + }); + file_names.sort(); + file_names + } + + fn assert_all_highlights_match_query( + search: &Entity, + query: &str, + cx: &mut TestAppContext, + ) { + let match_texts = match_texts(search, cx); + assert_eq!( + match_texts.len(), + match_count(search, 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:?}" + ); + } + } + + fn reserved_gutter_digits( + search_view: WindowHandle, + cx: &mut TestAppContext, + ) -> usize { + search_view + .read_with(cx, |search_view, cx| { + search_view + .results_editor + .read(cx) + .min_gutter_line_number_digits() + .unwrap_or(0) + }) + .unwrap() + } + + fn enable_search_on_type(cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .editor + .search + .get_or_insert_default() + .search_on_type = Some(true); + }); + }); + }); + } + + fn active_search_view( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity { + 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") + } + + struct SearchBarTest { + project: Entity, + window: WindowHandle, + search_bar: Entity, + search_view: Entity, + cx: VisualTestContext, + } + + async fn setup_search_bar_test( + files: serde_json::Value, + cx: &mut TestAppContext, + ) -> SearchBarTest { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), files).await; + let project = Project::test(fs, [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 mut cx = VisualTestContext::from_window(window.into(), cx); + let search_bar = window.build_entity(&mut cx, |_, _| ProjectSearchBar::new()); + workspace.update_in(&mut cx, { + let search_bar = search_bar.clone(); + |workspace, window, cx| { + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx); + } + }); + let search_view = active_search_view(&workspace, &mut cx); + SearchBarTest { + project, + window, + search_bar, + search_view, + cx, + } + } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f662654a7b..1480418946 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -377,6 +377,7 @@ impl VsCodeSettings { fn search_content(&self) -> Option { skip_default(SearchSettingsContent { include_ignored: self.read_bool("search.useIgnoreFiles"), + search_on_type: self.read_bool("search.searchOnType"), ..Default::default() }) } diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 6481da236d..aa965976f9 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -1001,6 +1001,8 @@ pub struct SearchSettingsContent { pub regex: Option, /// Whether to center the cursor on each search match when navigating. pub center_on_match: Option, + /// Start searching as you type in project search, without pressing Enter. + pub search_on_type: Option, } #[with_fallible_options] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ead651f5dd..a2e566f081 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3578,7 +3578,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 { @@ -3719,6 +3719,30 @@ fn search_and_files_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Search on Type", + description: "Start searching as you type in project search, without pressing Enter.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("editor.search.search_on_type"), + pick: |settings_content| { + settings_content + .editor + .search + .as_ref() + .and_then(|search| search.search_on_type.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .editor + .search + .get_or_insert_default() + .search_on_type = 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 a60621008b..f5d527dbe1 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3894,7 +3894,8 @@ Non-negative `integer` values "case_sensitive": false, "include_ignored": false, "regex": false, - "center_on_match": false + "center_on_match": false, + "search_on_type": true } } ``` @@ -3937,6 +3938,12 @@ Non-negative `integer` values - Setting: `center_on_match` - Default: `false` +### Search On Type + +- Description: Start searching as you type in project search, without pressing Enter. +- Setting: `search_on_type` +- Default: `true` + ## Search Wrap - Description: If `search_wrap` is disabled, search results do not wrap around the end of the file diff --git a/typos.toml b/typos.toml index 0a0635ea55..9e3bb541df 100644 --- a/typos.toml +++ b/typos.toml @@ -110,6 +110,8 @@ extend-ignore-re = [ "caf…", '\[elete\]', "GHIzJ", + # Intentionally non-matching query in project search tests + "NOTHINGmATCHEStHIS", ] check-filename = true