Skip to content

Commit d59d1a1

Browse files
authored
editor: Preserve active search match on panel open (longbridge#2953)
## Description Preserve the active editor search occurrence when Find is closed and reopened with the same query. An unchanged query now keeps the matcher’s current index and is not re-anchored to the viewport. New queries and case-sensitivity changes still rebuild and anchor the search normally. This also prevents the styled search panel’s initial query synchronization from resetting the occurrence. ## Screenshot Before https://github.com/user-attachments/assets/4f334ada-27f8-4a17-8ad3-5b768aff058b After https://github.com/user-attachments/assets/3ef1f5ae-1c56-4829-9f49-87513ef5a039 ## How to test 1. Search for a term with at least three matches. 2. Navigate to the second match and close Find. 3. Reopen Find without changing the query. 4. Confirm the counter remains at `2/3` and Next advances to `3/3`. ```sh cargo test -p gpui-base input::search::tests cargo test -p gpui-component input::overlay::tests ``` AI assistance: Codex generated the implementation and regression tests; I reviewed changes. ## Checklist - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and followed the guidelines. - [ ] Reviewed the changes in this PR and confirmed AI-generated code (if any) is accurate. - [ ] Passed `cargo run` for story tests related to the changes. - [x] Platform-specific performance testing is not applicable; this is platform-independent search state synchronization.
1 parent 9d9bd9b commit d59d1a1

2 files changed

Lines changed: 80 additions & 13 deletions

File tree

‎crates/base/src/input/editor/search.rs‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ impl SearchSession {
5454
}
5555

5656
pub(crate) fn update_query(&mut self, query: impl Into<String>, case_insensitive: bool) {
57-
self.query = query.into();
57+
let query = query.into();
58+
if self.query == query && self.case_insensitive == case_insensitive {
59+
return;
60+
}
61+
62+
self.query = query;
5863
self.case_insensitive = case_insensitive;
5964
self.matcher.update_query(&self.query, case_insensitive);
6065
}
@@ -78,19 +83,25 @@ impl<M: InputModeKind> InputBaseState<M> {
7883
self.search_session
7984
.open(replace_mode, self.is_replaceable());
8085
let selected = self.selected_text().to_string();
81-
if !selected.is_empty() {
82-
self.search_session.query = selected;
83-
}
84-
self.search_session.anchor_offset = self
85-
.last_layout
86-
.as_ref()
87-
.map(|layout| layout.visible_range_offset.start);
88-
self.search_session.matcher.update_query(
89-
&self.search_session.query,
90-
self.search_session.case_insensitive,
91-
);
86+
let query = if selected.is_empty() {
87+
self.search_session.query.clone()
88+
} else {
89+
selected
90+
};
91+
let query_changed = query != self.search_session.query;
92+
// A retained query resumes its previous occurrence. Only a new query
93+
// is anchored to the current viewport.
94+
self.search_session.anchor_offset = if query_changed {
95+
self.last_layout
96+
.as_ref()
97+
.map(|layout| layout.visible_range_offset.start)
98+
} else {
99+
None
100+
};
101+
let case_insensitive = self.search_session.case_insensitive;
102+
self.search_session.update_query(query, case_insensitive);
92103
self.search_session.matcher.update(&self.text);
93-
if let Some(anchor) = self.search_session.anchor_offset {
104+
if query_changed && let Some(anchor) = self.search_session.anchor_offset {
94105
self.search_session.matcher.update_cursor_by_offset(anchor);
95106
}
96107
cx.notify();
@@ -404,6 +415,23 @@ mod tests {
404415
assert_eq!(matcher.next(), Some(5..10));
405416
}
406417

418+
#[test]
419+
fn identical_query_keeps_the_current_match() {
420+
let mut session = SearchSession::default();
421+
session.update_query("foo", true);
422+
session.matcher.update(&Rope::from("foo bar foo baz foo"));
423+
session.matcher.update_cursor_by_offset(12);
424+
assert_eq!(session.matcher.current_match_index(), 2);
425+
426+
// Reopening Find and the styled search panel's initial query echo both
427+
// update the session with the same query. Neither should reset the
428+
// previously active occurrence.
429+
session.update_query("foo", true);
430+
431+
assert_eq!(session.matcher.current_match_index(), 2);
432+
assert_eq!(session.matcher.label(), "3/3");
433+
}
434+
407435
#[test]
408436
fn replacement_keeps_current_match_index_on_next_match() {
409437
let mut matcher = SearchMatcher::new();

‎crates/component/src/input/overlay.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,45 @@ mod tests {
504504
});
505505
}
506506

507+
/// Reopening search must preserve navigation already owned by Base.
508+
///
509+
/// Closing and reopening Find keeps the previous occurrence in browsers.
510+
/// Neither Base reopening the session nor the styled panel echoing its
511+
/// retained query may reset the current match to zero.
512+
#[gpui::test]
513+
fn reopening_search_panel_preserves_the_previous_match(cx: &mut gpui::TestAppContext) {
514+
cx.update(crate::init);
515+
let (probe, cx) = cx.add_window_view(|window, cx| OverlayProbe {
516+
state: cx.new(|cx| crate::input::EditorState::new(window, cx).searchable(true)),
517+
});
518+
let state = probe.read_with(cx, |probe, _| probe.state.clone());
519+
520+
cx.update(|window, cx| {
521+
state.update(cx, |state, cx| {
522+
state.set_value("foo bar foo baz foo", window, cx);
523+
state.open_search(false, cx);
524+
state.set_search_query("foo", true, cx);
525+
assert_eq!(state.next_search_match(cx), Some(8..11));
526+
assert_eq!(state.search_session().matcher.current_match_index(), 1);
527+
state.close_search(cx);
528+
state.open_search(false, cx);
529+
assert_eq!(state.search_session().matcher.current_match_index(), 1);
530+
});
531+
532+
let mut host = InputOverlayHost::new(state.clone(), window, cx);
533+
host.sync(&state, window, cx);
534+
535+
state.read_with(cx, |state, _| {
536+
assert_eq!(state.search_session().matcher.current_match_index(), 1);
537+
assert_eq!(state.search_session().matcher.label(), "2/3");
538+
});
539+
540+
state.update(cx, |state, cx| {
541+
assert_eq!(state.next_search_match(cx), Some(16..19));
542+
});
543+
});
544+
}
545+
507546
/// A frame that changed nothing must not rebuild the popovers.
508547
///
509548
/// Sync runs every frame, so the change check has to be cheap and stable.

0 commit comments

Comments
 (0)