From cc690e20a0b601c9eac0d1ba8177b3c2e37ddad9 Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Sat, 5 Sep 2026 10:31:52 +0400 Subject: [PATCH 1/2] list: restore valid selection after search completes Search can replace results after the previous render cached its row counts. Validate the preferred cursor against current results at search start and completion, falling back to the first nonempty section. Resolve the preferred item by its committed value without committing the search cursor. --- crates/component/src/list/delegate.rs | 8 + crates/component/src/list/list.rs | 161 +++++++++++++++++- .../component/src/searchable_list/adapter.rs | 5 + website/docs/components/list.md | 9 + 4 files changed, 175 insertions(+), 8 deletions(-) diff --git a/crates/component/src/list/delegate.rs b/crates/component/src/list/delegate.rs index 81e3d49aa9..e5cb515727 100644 --- a/crates/component/src/list/delegate.rs +++ b/crates/component/src/list/delegate.rs @@ -21,6 +21,14 @@ pub trait ListDelegate: Sized + 'static { Task::ready(()) } + /// Return the preferred keyboard cursor for the current search results. + /// + /// Called after search starts and again when its task completes. An absent or out-of-range + /// index falls back to the first item in the first non-empty section, or no selection. + fn preferred_selected_index(&self, _: &App) -> Option { + None + } + /// Return the number of sections in the list, default is 1. /// /// Min value is 1. diff --git a/crates/component/src/list/list.rs b/crates/component/src/list/list.rs index 732b8da91e..4fd60d5596 100644 --- a/crates/component/src/list/list.rs +++ b/crates/component/src/list/list.rs @@ -289,18 +289,13 @@ where fn start_search(&mut self, query: String, window: &mut Window, cx: &mut Context) { self.set_searching(true, window, cx); let search = self.delegate.perform_search(&query, window, cx); - - if self.rows_cache.len() > 0 { - self._set_selected_index(Some(IndexPath::default()), window, cx); - } else { - self._set_selected_index(None, window, cx); - } + self.restore_search_cursor(window, cx); self._search_task = cx.spawn_in(window, async move |this, window| { search.await; - _ = this.update_in(window, |this, _, _| { - this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + _ = this.update_in(window, |this, window, cx| { + this.restore_search_cursor(window, cx); this.last_query = Some(query); }); @@ -315,6 +310,23 @@ where }); } + fn restore_search_cursor(&mut self, window: &mut Window, cx: &mut Context) { + // Search results can invalidate row indices. Restore the delegate's preferred item only + // when it still exists; otherwise select the first item in the first non-empty section. + let sections_count = self.delegate.sections_count(cx).max(1); + let preferred = self.delegate.preferred_selected_index(cx).filter(|index| { + index.section < sections_count + && index.row < self.delegate.items_count(index.section, cx) + }); + let first = (0..sections_count) + .find(|section| self.delegate.items_count(*section, cx) > 0) + .map(|section| IndexPath::default().section(section)); + + self.deferred_scroll_to_index = None; + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + self._set_selected_index(preferred.or(first), window, cx); + } + fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context) { self.query_input .update(cx, |input, cx| input.set_loading(searching, window, cx)); @@ -778,3 +790,136 @@ where .child(self.state.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::list::ListItem; + use gpui::TestAppContext; + + struct SearchDelegate { + counts: Vec, + preferred: Option, + } + + impl ListDelegate for SearchDelegate { + type Item = ListItem; + + fn perform_search( + &mut self, + query: &str, + _: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query == "sync" { + self.counts = vec![0, 2]; + self.preferred = None; + return Task::ready(()); + } + let query = query.to_owned(); + cx.spawn(async move |list, cx| { + list.update(cx, |list, _| { + let delegate = &mut list.delegate; + delegate.counts = if query == "empty" { + vec![0, 0] + } else { + vec![0, 3] + }; + delegate.preferred = + Some(IndexPath::new(if query == "preferred" { 2 } else { 99 }).section(1)); + }) + .unwrap(); + }) + } + + fn preferred_selected_index(&self, _: &App) -> Option { + self.preferred + } + fn sections_count(&self, _: &App) -> usize { + self.counts.len() + } + fn items_count(&self, section: usize, _: &App) -> usize { + self.counts[section] + } + fn set_selected_index( + &mut self, + _: Option, + _: &mut Window, + _: &mut Context>, + ) { + } + fn render_item( + &mut self, + _: IndexPath, + _: &mut Window, + _: &mut Context>, + ) -> Option { + None + } + } + + #[gpui::test] + fn search_restores_cursor_after_results_arrive(cx: &mut TestAppContext) { + cx.update(crate::init); + let window = cx.add_empty_window(); + let list = window.update(|window, cx| { + cx.new(|cx| { + ListState::new( + SearchDelegate { + counts: vec![1], + preferred: None, + }, + window, + cx, + ) + }) + }); + for (query, expected) in [ + ("preferred", Some(IndexPath::new(2).section(1))), + ("invalid", Some(IndexPath::new(0).section(1))), + ("empty", None), + ("sync", Some(IndexPath::new(0).section(1))), + ] { + window + .update(|window, cx| list.update(cx, |list, cx| list.set_query(query, window, cx))); + window.run_until_parked(); + window.update(|_, cx| { + let list = list.read(cx); + assert_eq!(list.selected_index(), expected); + assert_eq!( + list.deferred_scroll_to_index.map(|(index, _)| index), + expected + ); + }); + } + } + + #[gpui::test] + fn filtering_restores_the_committed_item_by_value(cx: &mut TestAppContext) { + use crate::select::{SearchableVec, SelectState}; + + cx.update(crate::init); + let window = cx.add_empty_window(); + let (select, list) = window.update(|window, cx| { + let select = cx.new(|cx| { + SelectState::new( + SearchableVec::new(vec!["Rust", "Go", "C++"]), + Some(IndexPath::new(1)), + window, + cx, + ) + }); + let list = select.read(cx).state.list.clone(); + (select, list) + }); + for (query, row) in [("Go", 0), ("Rust", 0), ("", 1)] { + window + .update(|window, cx| list.update(cx, |list, cx| list.set_query(query, window, cx))); + window.run_until_parked(); + window.update(|_, cx| { + assert_eq!(list.read(cx).selected_index(), Some(IndexPath::new(row))); + assert_eq!(select.read(cx).selected_value(), Some(&"Go")); + }); + } + } +} diff --git a/crates/component/src/searchable_list/adapter.rs b/crates/component/src/searchable_list/adapter.rs index 1c846ea1f0..88baab4ad4 100644 --- a/crates/component/src/searchable_list/adapter.rs +++ b/crates/component/src/searchable_list/adapter.rs @@ -161,6 +161,11 @@ impl ListDelegate for SearchableListAdapter self.delegate.perform_search(query, window, cx) } + fn preferred_selected_index(&self, _: &App) -> Option { + let selected_value = self.selection_snapshot.first()?.1.value(); + self.delegate.position(selected_value) + } + fn set_selected_index( &mut self, ix: Option, diff --git a/website/docs/components/list.md b/website/docs/components/list.md index c5b8c5d0f1..4c8e5991bc 100644 --- a/website/docs/components/list.md +++ b/website/docs/components/list.md @@ -491,3 +491,12 @@ impl ListDelegate for ContactListDelegate { } } ``` + +## Selection after search + +After search starts and again when its task completes, the list validates +`ListDelegate::preferred_selected_index`. Return an index in the current results +to restore a particular keyboard cursor. The default returns `None`; an absent or +invalid preference selects the first item in the first non-empty section. Empty +results clear the cursor. This does not commit a selection in `Select` or +`SearchableList`: their committed values remain separate from the keyboard cursor. From b3389dfde1b2b019d9cb2f19d449b49c1dfbf4ac Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Sat, 5 Sep 2026 16:47:09 +0400 Subject: [PATCH 2/2] test(list): name search cursor checks explicitly --- crates/component/src/list/list.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/component/src/list/list.rs b/crates/component/src/list/list.rs index 4fd60d5596..631e504260 100644 --- a/crates/component/src/list/list.rs +++ b/crates/component/src/list/list.rs @@ -792,7 +792,7 @@ where } #[cfg(test)] -mod tests { +mod search_cursor_tests { use super::*; use crate::list::ListItem; use gpui::TestAppContext;