diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 60f781973b..19fd9dec76 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -920,18 +920,17 @@ struct ActionFetchReady { } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum GutterLineNumberWidth { - #[default] - Dynamic, - Sticky { - min_digits: usize, - }, +pub struct SearchResultsStatus { + pub pending: bool, + pub results_stale: bool, + pub query_confirmed: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq)] -struct ScrollRangeHold { - held: bool, - settled: Option, +pub struct SearchResultsHold { + pub status: SearchResultsStatus, + pub min_line_number_digits: usize, + settled_scroll_range: Option, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -1006,8 +1005,7 @@ pub struct Editor { enable_runnables: bool, enable_code_lens: bool, enable_mouse_wheel_zoom: bool, - gutter_line_number_width: GutterLineNumberWidth, - scroll_range_hold: Option, + search_results_hold: Option, show_line_numbers: Option, use_relative_line_numbers: Option, show_git_diff_gutter: Option, @@ -1238,7 +1236,7 @@ pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, offset_content: bool, - gutter_line_number_width: GutterLineNumberWidth, + sticky_line_number_digits: usize, show_line_numbers: Option, number_deleted_lines: bool, show_git_diff_gutter: Option, @@ -2337,8 +2335,7 @@ 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, + search_results_hold: None, show_line_numbers: (!full_mode).then_some(false), use_relative_line_numbers: None, disable_expand_excerpt_buttons: !full_mode, @@ -3078,7 +3075,9 @@ 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, + sticky_line_number_digits: self + .search_results_hold + .map_or(0, |hold| hold.min_line_number_digits), show_line_numbers: self.show_line_numbers, number_deleted_lines: self.number_deleted_lines, show_git_diff_gutter: self.show_git_diff_gutter, @@ -3138,21 +3137,20 @@ 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 }; + pub fn set_search_results_status( + &mut self, + status: SearchResultsStatus, + cx: &mut Context, + ) { + let hold = self.search_results_hold.get_or_insert_default(); + let previous = mem::replace(&mut hold.status, status); + let results_became_current = previous.results_stale && !status.results_stale; + let search_settled = previous.pending && !status.pending; + let allow_shrink = !status.results_stale + && (self.buffer.read(cx).read(cx).is_empty() + || status.query_confirmed && (results_became_current || search_settled)); + self.fit_gutter_line_number_width(allow_shrink, cx); + if previous != status { cx.notify(); } } @@ -3160,51 +3158,29 @@ impl Editor { /// 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 { + fn fit_gutter_line_number_width(&mut self, allow_shrink: bool, cx: &mut Context) { + let Some(hold) = &mut self.search_results_hold else { return; }; - let digits = self.widest_line_number_digits(cx); - if digits != min_digits { - self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits }; + let digits = { + let snapshot = self.buffer.read(cx).read(cx); + if snapshot.is_empty() { + 0 + } else { + (snapshot.widest_line_number().max(1).ilog10() + 1) as usize + } + }; + if hold.min_line_number_digits < digits + || allow_shrink && hold.min_line_number_digits != digits + { + hold.min_line_number_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) + pub fn search_results_hold(&self) -> Option { + self.search_results_hold } fn frozen_scroll_range( @@ -3214,17 +3190,17 @@ impl Editor { current_editor_width: Pixels, current_editor_bounds_size: Size, ) -> Option { - let hold = self.scroll_range_hold.as_mut()?; + let hold = self.search_results_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); + if !hold.status.pending && !is_rewrapping { + hold.settled_scroll_range = Some(current); return None; } - let settled = hold.settled.get_or_insert(current); + let settled = hold.settled_scroll_range.get_or_insert(current); if settled.editor_bounds_size != current_editor_bounds_size { *settled = current; } @@ -9851,7 +9827,7 @@ impl Editor { } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; - self.latch_gutter_line_number_width(cx); + self.fit_gutter_line_number_width(false, cx); self.refresh_active_diagnostics(cx); self.refresh_code_actions_for_selection(window, cx); self.refresh_single_line_folds(window, cx); @@ -11960,13 +11936,9 @@ 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 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); + .max(self.sticky_line_number_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) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 4a47f5beeb..faf68af6ff 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -45725,7 +45725,7 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - cx.update_editor(|editor, _, _| { + cx.update_editor(|editor, _, cx| { let bounds = size(px(1800.), px(900.)); let settled = |width: f32, height: f32, editor_width: f32, bounds: Size| { Some(SettledScrollRange { @@ -45734,7 +45734,13 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test editor_bounds_size: bounds, }) }; - editor.hold_scrollbar_range(true); + editor.set_search_results_status( + SearchResultsStatus { + pending: true, + ..SearchResultsStatus::default() + }, + cx, + ); assert_eq!( editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds), settled(1780., 100., 1786., bounds), @@ -45765,7 +45771,7 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test 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); + editor.set_search_results_status(SearchResultsStatus::default(), cx); assert_eq!( editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds), None, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 918d2214da..e8cd1b4e25 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -9058,7 +9058,8 @@ impl Element for EditorElement { cx, ); - let frozen_scroll_state = if self.editor.read(cx).scroll_range_hold.is_some() { + let frozen_scroll_state = if self.editor.read(cx).search_results_hold.is_some() + { let is_rewrapping = self.editor.read(cx).display_map.read(cx).is_rewrapping(cx); self.editor.update(cx, |editor, _| { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 87a5fc1fdf..51be4def1a 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -14,7 +14,7 @@ use anyhow::Context as _; use collections::HashMap; use editor::{ Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey, - SelectionEffects, + SearchResultsStatus, SelectionEffects, actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll}, items::active_match_index, multibuffer_context_lines, @@ -1530,7 +1530,6 @@ impl ProjectSearchView { } 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); }); } @@ -1557,7 +1556,6 @@ 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))); @@ -1978,9 +1976,23 @@ impl ProjectSearchView { model.phase = SearchPhase::Confirmed; model.record_search_history(cx); }); + self.sync_search_results_status(cx); } } + fn sync_search_results_status(&self, cx: &mut Context) { + let model = self.entity.read(cx); + let pending = model.pending_search.is_some(); + let status = SearchResultsStatus { + pending, + results_stale: pending && !model.results_refreshed, + query_confirmed: model.phase == SearchPhase::Confirmed, + }; + self.results_editor.update(cx, |editor, cx| { + editor.set_search_results_status(status, 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. @@ -2345,15 +2357,29 @@ impl ProjectSearchView { 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)); + self.sync_search_results_status(cx); + + if phase == SearchPhase::Typing + && let Some(first_match) = match_ranges.first() + { + self.results_editor.update(cx, |editor, cx| { + let range_to_select = editor.range_for_match(first_match); + if editor.selections.newest_anchor().range() != range_to_select { + editor.change_selections( + SelectionEffects::no_scroll().nav_history(false), + window, + cx, + |s| s.select_ranges([range_to_select]), + ); + } + }); + } if match_ranges.is_empty() { self.active_match_index = None; 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); } @@ -2378,16 +2404,12 @@ impl ProjectSearchView { s.select_ranges(range_to_select) }); editor.scroll(Point::default(), window, cx); - editor.refit_gutter_line_number_width(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)); } } } @@ -3491,7 +3513,7 @@ pub mod tests { }; use super::*; - use editor::{DisplayPoint, display_map::DisplayRow}; + use editor::{DisplayPoint, ToPoint, display_map::DisplayRow}; use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle}; use language::{FakeLspAdapter, Point as BufferPoint, rust_lang}; use pretty_assertions::assert_eq; @@ -9041,7 +9063,8 @@ pub mod tests { search_view .results_editor .read(cx) - .is_scrollbar_range_held() + .search_results_hold() + .map(|hold| hold.status.pending) }) .unwrap() }; @@ -9407,6 +9430,102 @@ pub mod tests { ); } + #[gpui::test] + async fn test_on_type_search_keeps_selection_on_first_match(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let mut files = serde_json::Map::new(); + for ix in 0..50 { + files.insert( + format!("file_{ix:03}.txt"), + json!(format!("line with needle {ix}\nmore needle text {ix}")), + ); + } + 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.clone(), workspace.downgrade(), cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + let selection_head = |cx: &mut TestAppContext| { + search_view + .update(cx, |search_view, _, cx| { + search_view.results_editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + editor.selections.newest::(&snapshot).head() + }) + }) + .unwrap() + }; + let first_match_head = |cx: &mut TestAppContext| { + search_view + .update(cx, |search_view, _, cx| { + let first_match = search_view + .entity + .read(cx) + .match_ranges + .first() + .expect("the query matches the fixture") + .clone(); + search_view.results_editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + editor + .range_for_match(&first_match) + .end + .to_point(snapshot.buffer_snapshot()) + }) + }) + .unwrap() + }; + + search_view + .update(cx, |search_view, window, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("needle", window, cx) + }); + search_view.search(SearchMode::Manual, cx); + }) + .unwrap(); + cx.background_executor.run_until_parked(); + assert_eq!( + selection_head(cx), + first_match_head(cx), + "a confirmed search selects the first match" + ); + + perform_incremental_search(search_view, "needle 4", cx); + cx.background_executor.run_until_parked(); + assert_eq!( + selection_head(cx), + first_match_head(cx), + "an on-type search must keep the selection on the first match too: a stale \ + anchor resolves to an arbitrary position, and relative line numbers measured \ + against it renumber unchanged excerpts on every keystroke" + ); + assert_ne!( + selection_head(cx), + BufferPoint::zero(), + "the fixture is arranged so the first match does not sit at the buffer start, \ + otherwise this test cannot tell a real selection from a collapsed stale anchor" + ); + + perform_incremental_search(search_view, "needle 41", cx); + cx.background_executor.run_until_parked(); + assert_eq!( + selection_head(cx), + first_match_head(cx), + "narrowing again keeps following the first match" + ); + } + fn perform_incremental_search( search_view: WindowHandle, text: impl Into>, @@ -9483,8 +9602,8 @@ pub mod tests { search_view .results_editor .read(cx) - .min_gutter_line_number_digits() - .unwrap_or(0) + .search_results_hold() + .map_or(0, |hold| hold.min_line_number_digits) }) .unwrap() }