From 13e51664e0ed5ee61b96a957ac3704cf77b061e3 Mon Sep 17 00:00:00 2001 From: Alex S Date: Fri, 4 Sep 2026 22:35:58 +0800 Subject: [PATCH 1/2] editor: preserve active search match on panel open --- crates/base/src/input/editor/search.rs | 24 +++++++++++++++++- crates/component/src/input/overlay.rs | 34 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/base/src/input/editor/search.rs b/crates/base/src/input/editor/search.rs index f5c5742cbc..2dd2d26ab9 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); } @@ -404,6 +409,23 @@ mod tests { assert_eq!(matcher.next(), Some(5..10)); } + #[test] + fn identical_query_keeps_the_match_anchored_by_the_open_search_session() { + 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); + + // The styled search panel echoes its input value back into Base when it + // opens. That echo must not reset the match chosen from the editor's + // visible-range anchor. + 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..f29be8aa39 100644 --- a/crates/component/src/input/overlay.rs +++ b/crates/component/src/input/overlay.rs @@ -504,6 +504,40 @@ mod tests { }); } + /// Building the styled panel must not reset navigation already owned by Base. + /// + /// The panel writes its initial input value back through `set_search_query`. + /// If an identical query is treated as a new query, that echo resets the + /// current match to zero and the panel counter drifts from the editor's + /// active highlight and scroll position. + #[gpui::test] + fn opening_search_panel_preserves_the_base_match_index(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.next_search_match(cx), Some(16..19)); + assert_eq!(state.search_session().matcher.current_match_index(), 2); + }); + + 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(), 2); + assert_eq!(state.search_session().matcher.label(), "3/3"); + }); + }); + } + /// A frame that changed nothing must not rebuild the popovers. /// /// Sync runs every frame, so the change check has to be cheap and stable. From d6bce97cd7f9293f4a3c08d8aa8fd388945b3138 Mon Sep 17 00:00:00 2001 From: Alex S Date: Sat, 5 Sep 2026 09:40:08 +0800 Subject: [PATCH 2/2] editor: preserve search occurrence on reopen --- crates/base/src/input/editor/search.rs | 38 +++++++++++++++----------- crates/component/src/input/overlay.rs | 25 ++++++++++------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/crates/base/src/input/editor/search.rs b/crates/base/src/input/editor/search.rs index 2dd2d26ab9..25c96dfe50 100644 --- a/crates/base/src/input/editor/search.rs +++ b/crates/base/src/input/editor/search.rs @@ -81,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(); @@ -410,16 +416,16 @@ mod tests { } #[test] - fn identical_query_keeps_the_match_anchored_by_the_open_search_session() { + 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); - // The styled search panel echoes its input value back into Base when it - // opens. That echo must not reset the match chosen from the editor's - // visible-range anchor. + // 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); diff --git a/crates/component/src/input/overlay.rs b/crates/component/src/input/overlay.rs index f29be8aa39..4ce4ab2781 100644 --- a/crates/component/src/input/overlay.rs +++ b/crates/component/src/input/overlay.rs @@ -504,14 +504,13 @@ mod tests { }); } - /// Building the styled panel must not reset navigation already owned by Base. + /// Reopening search must preserve navigation already owned by Base. /// - /// The panel writes its initial input value back through `set_search_query`. - /// If an identical query is treated as a new query, that echo resets the - /// current match to zero and the panel counter drifts from the editor's - /// active highlight and scroll position. + /// 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 opening_search_panel_preserves_the_base_match_index(cx: &mut gpui::TestAppContext) { + 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)), @@ -524,16 +523,22 @@ mod tests { 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.next_search_match(cx), Some(16..19)); - assert_eq!(state.search_session().matcher.current_match_index(), 2); + 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(), 2); - assert_eq!(state.search_session().matcher.label(), "3/3"); + 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)); }); }); }