diff --git a/crates/base/src/input/editor/search.rs b/crates/base/src/input/editor/search.rs index f5c5742cbc..25c96dfe50 100644 --- a/crates/base/src/input/editor/search.rs +++ b/crates/base/src/input/editor/search.rs @@ -52,7 +52,12 @@ impl SearchSession { } pub(crate) fn update_query(&mut self, query: impl Into, case_insensitive: bool) { - self.query = query.into(); + let query = query.into(); + if self.query == query && self.case_insensitive == case_insensitive { + return; + } + + self.query = query; self.case_insensitive = case_insensitive; self.matcher.update_query(&self.query, case_insensitive); } @@ -76,19 +81,25 @@ impl InputBaseState { self.search_session .open(replace_mode, self.is_replaceable()); let selected = self.selected_text().to_string(); - if !selected.is_empty() { - self.search_session.query = selected; - } - self.search_session.anchor_offset = self - .last_layout - .as_ref() - .map(|layout| layout.visible_range_offset.start); - self.search_session.matcher.update_query( - &self.search_session.query, - self.search_session.case_insensitive, - ); + let query = if selected.is_empty() { + self.search_session.query.clone() + } else { + selected + }; + let query_changed = query != self.search_session.query; + // A retained query resumes its previous occurrence. Only a new query + // is anchored to the current viewport. + self.search_session.anchor_offset = if query_changed { + self.last_layout + .as_ref() + .map(|layout| layout.visible_range_offset.start) + } else { + None + }; + let case_insensitive = self.search_session.case_insensitive; + self.search_session.update_query(query, case_insensitive); self.search_session.matcher.update(&self.text); - if let Some(anchor) = self.search_session.anchor_offset { + if query_changed && let Some(anchor) = self.search_session.anchor_offset { self.search_session.matcher.update_cursor_by_offset(anchor); } cx.notify(); @@ -404,6 +415,23 @@ mod tests { assert_eq!(matcher.next(), Some(5..10)); } + #[test] + fn identical_query_keeps_the_current_match() { + let mut session = SearchSession::default(); + session.update_query("foo", true); + session.matcher.update(&Rope::from("foo bar foo baz foo")); + session.matcher.update_cursor_by_offset(12); + assert_eq!(session.matcher.current_match_index(), 2); + + // Reopening Find and the styled search panel's initial query echo both + // update the session with the same query. Neither should reset the + // previously active occurrence. + session.update_query("foo", true); + + assert_eq!(session.matcher.current_match_index(), 2); + assert_eq!(session.matcher.label(), "3/3"); + } + #[test] fn replacement_keeps_current_match_index_on_next_match() { let mut matcher = SearchMatcher::new(); diff --git a/crates/component/src/input/overlay.rs b/crates/component/src/input/overlay.rs index d1a45fef27..4ce4ab2781 100644 --- a/crates/component/src/input/overlay.rs +++ b/crates/component/src/input/overlay.rs @@ -504,6 +504,45 @@ mod tests { }); } + /// Reopening search must preserve navigation already owned by Base. + /// + /// Closing and reopening Find keeps the previous occurrence in browsers. + /// Neither Base reopening the session nor the styled panel echoing its + /// retained query may reset the current match to zero. + #[gpui::test] + fn reopening_search_panel_preserves_the_previous_match(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let (probe, cx) = cx.add_window_view(|window, cx| OverlayProbe { + state: cx.new(|cx| crate::input::EditorState::new(window, cx).searchable(true)), + }); + let state = probe.read_with(cx, |probe, _| probe.state.clone()); + + cx.update(|window, cx| { + state.update(cx, |state, cx| { + state.set_value("foo bar foo baz foo", window, cx); + state.open_search(false, cx); + state.set_search_query("foo", true, cx); + assert_eq!(state.next_search_match(cx), Some(8..11)); + assert_eq!(state.search_session().matcher.current_match_index(), 1); + state.close_search(cx); + state.open_search(false, cx); + assert_eq!(state.search_session().matcher.current_match_index(), 1); + }); + + let mut host = InputOverlayHost::new(state.clone(), window, cx); + host.sync(&state, window, cx); + + state.read_with(cx, |state, _| { + assert_eq!(state.search_session().matcher.current_match_index(), 1); + assert_eq!(state.search_session().matcher.label(), "2/3"); + }); + + state.update(cx, |state, cx| { + assert_eq!(state.next_search_match(cx), Some(16..19)); + }); + }); + } + /// A frame that changed nothing must not rebuild the popovers. /// /// Sync runs every frame, so the change check has to be cheap and stable.