From a1cf972821c4bc2fa890b12b4d7c52bbfb7bfc78 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Wed, 18 Feb 2026 23:23:23 +0100 Subject: [PATCH 01/15] Add stable_id support for match restoration - Implement match_stable_id and find_match_by_stable_id for Outline - Generate stable_id as "text:depth" for outline items - Implement stable_id lookups for Project Symbols - Implement stable_id support in Icon Theme Selector and Theme Selector - Use stable_id to preserve and restore manual selections in Picker --- crates/outline/src/outline.rs | 16 ++++ crates/picker/src/picker.rs | 93 +++++++++++++++++-- crates/project_symbols/src/project_symbols.rs | 29 ++++++ .../theme_selector/src/icon_theme_selector.rs | 71 +++++--------- crates/theme_selector/src/theme_selector.rs | 36 +++---- 5 files changed, 170 insertions(+), 75 deletions(-) diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 454f6f0b578ce2..3ef58af6d7e640 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -343,6 +343,22 @@ impl PickerDelegate for OutlineViewDelegate { Task::ready(()) } + fn match_stable_id(&self, ix: usize) -> Option { + let mat = self.matches.get(ix)?; + let outline_item = self.outline.items.get(mat.candidate_id)?; + Some(format!("{}:{}", outline_item.text, outline_item.depth)) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|mat| { + if let Some(outline_item) = self.outline.items.get(mat.candidate_id) { + format!("{}:{}", outline_item.text, outline_item.depth) == stable_id + } else { + false + } + }) + } + fn confirm( &mut self, _: bool, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index e87ec3415cf6d7..abd278f5afe9e1 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -76,6 +76,8 @@ pub struct Picker { picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, + /// Tracks the stable ID of a manually selected item to preserve it across match updates. + manually_selected_stable_id: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -135,6 +137,20 @@ pub trait PickerDelegate: Sized + 'static { fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { Some("No matches".into()) } + + /// Returns a stable identifier for the match at the given index. + /// If implemented, the picker will try to preserve manual selections + /// across match updates by finding the same item again. + fn match_stable_id(&self, _ix: usize) -> Option { + None + } + + /// Finds the index of a match with the given stable identifier. + /// Used in conjunction with `match_stable_id` to restore selections. + fn find_match_by_stable_id(&self, _stable_id: &str) -> Option { + None + } + fn update_matches( &mut self, query: String, @@ -342,6 +358,7 @@ impl Picker { is_modal: true, picker_bounds: Rc::new(Cell::new(None)), item_bounds: Rc::new(RefCell::new(HashMap::default())), + manually_selected_stable_id: None, }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. @@ -411,11 +428,43 @@ impl Picker { /// view. /// /// If some effect is bound to `selected_index_changed`, it will be executed. + /// + /// This method is for programmatic selection changes. For user-driven selections + /// that should be preserved across match updates, use `select_index_sticky` instead. pub fn set_selected_index( + &mut self, + ix: usize, + fallback_direction: Option, + scroll_to_index: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index_impl(ix, fallback_direction, scroll_to_index, false, window, cx); + } + + /// Selects an index with "sticky" behavior - the selection will be preserved across + /// match updates if the selected item still matches the search query. + /// + /// Use this for user-driven selections (keyboard navigation, mouse clicks) where you want + /// the user's choice to be maintained as they continue typing. For programmatic selections + /// that should not persist, use `set_selected_index` instead. + pub fn select_index_sticky( + &mut self, + ix: usize, + fallback_direction: Option, + scroll_to_index: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index_impl(ix, fallback_direction, scroll_to_index, true, window, cx); + } + + fn set_selected_index_impl( &mut self, mut ix: usize, fallback_direction: Option, scroll_to_index: bool, + is_manual_selection: bool, window: &mut Window, cx: &mut Context, ) { @@ -457,6 +506,11 @@ impl Picker { self.delegate.set_selected_index(ix, window, cx); let current_index = self.delegate.selected_index(); + // Track manually selected item to preserve across match updates + if is_manual_selection { + self.manually_selected_stable_id = self.delegate.match_stable_id(current_index); + } + if previous_index != current_index { if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) { action(window, cx); @@ -485,7 +539,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == count - 1 { 0 } else { index + 1 }; - self.set_selected_index(ix, Some(Direction::Down), true, window, cx); + self.select_index_sticky(ix, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -512,7 +566,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == 0 { count - 1 } else { index - 1 }; - self.set_selected_index(ix, Some(Direction::Up), true, window, cx); + self.select_index_sticky(ix, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -529,7 +583,7 @@ impl Picker { ) { let count = self.delegate.match_count(); if count > 0 { - self.set_selected_index(0, Some(Direction::Down), true, window, cx); + self.select_index_sticky(0, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -537,7 +591,7 @@ impl Picker { fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); if count > 0 { - self.set_selected_index(count - 1, Some(Direction::Up), true, window, cx); + self.select_index_sticky(count - 1, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -546,7 +600,7 @@ impl Picker { let count = self.delegate.match_count(); let index = self.delegate.selected_index(); let new_index = if index + 1 == count { 0 } else { index + 1 }; - self.set_selected_index(new_index, Some(Direction::Down), true, window, cx); + self.select_index_sticky(new_index, Some(Direction::Down), true, window, cx); cx.notify(); } @@ -622,7 +676,7 @@ impl Picker { if !self.delegate.can_select(ix, window, cx) { return; } - self.set_selected_index(ix, None, false, window, cx); + self.select_index_sticky(ix, None, false, window, cx); self.do_confirm(secondary, window, cx) } @@ -717,7 +771,32 @@ impl Picker { state.reset(self.delegate.match_count()); } - let index = self.delegate.selected_index(); + // Try to restore manually selected item + let match_count = self.delegate.match_count(); + let index = if let Some(stable_id) = &self.manually_selected_stable_id { + if let Some(ix) = self.delegate.find_match_by_stable_id(stable_id) { + // Found the manually selected item, restore selection + self.delegate.set_selected_index(ix, window, cx); + ix + } else { + // Item no longer in results, clear manual selection and reset to first item + self.manually_selected_stable_id = None; + let ix = 0.min(match_count.saturating_sub(1)); + if match_count > 0 { + self.delegate.set_selected_index(ix, window, cx); + } + ix + } + } else { + // No manual selection - clamp current index to valid range + let current_index = self.delegate.selected_index(); + let ix = current_index.min(match_count.saturating_sub(1)); + if match_count > 0 && current_index != ix { + self.delegate.set_selected_index(ix, window, cx); + } + ix + }; + self.scroll_to_item_index(index); self.pending_update_matches = None; if let Some(secondary) = self.confirm_on_update.take() { diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index d62935ab3819d2..af340a8eceebd3 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -224,6 +224,35 @@ impl PickerDelegate for ProjectSymbolsDelegate { }) } + fn match_stable_id(&self, ix: usize) -> Option { + let mat = self.matches.get(ix)?; + let symbol = self.symbols.get(mat.candidate_id)?; + let path_str = match &symbol.path { + SymbolLocation::InProject(path) => format!("{:?}:{:?}", path.worktree_id, path.path), + SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), + }; + Some(format!( + "{}:{}:{:?}", + path_str, symbol.name, symbol.range.start + )) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|mat| { + if let Some(symbol) = self.symbols.get(mat.candidate_id) { + let path_str = match &symbol.path { + SymbolLocation::InProject(path) => { + format!("{:?}:{:?}", path.worktree_id, path.path) + } + SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), + }; + format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start) == stable_id + } else { + false + } + }) + } + fn render_match( &self, ix: usize, diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 2ea3436d43cd2d..f8c36857bd8669 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -56,7 +56,6 @@ pub(crate) struct IconThemeSelectorDelegate { matches: Vec, original_theme: IconThemeName, selection_completed: bool, - selected_theme: Option, selected_index: usize, selector: WeakEntity, } @@ -92,7 +91,7 @@ impl IconThemeSelectorDelegate { .cmp(&b.appearance.is_light()) .then(a.name.cmp(&b.name)) }); - let matches = themes + let matches: Vec = themes .iter() .map(|meta| StringMatch { candidate_id: 0, @@ -101,37 +100,27 @@ impl IconThemeSelectorDelegate { string: meta.name.to_string(), }) .collect(); - let mut this = Self { + let selected_index = matches + .iter() + .position(|mat| mat.string == original_theme.0.as_ref()) + .unwrap_or(0); + + Self { fs, themes, matches, - original_theme: original_theme.clone(), - selected_index: 0, - selected_theme: None, + original_theme, + selected_index, selection_completed: false, selector, - }; - - this.select_if_matching(&original_theme.0); - this - } - - fn show_selected_theme( - &mut self, - cx: &mut Context>, - ) -> Option { - let mat = self.matches.get(self.selected_index)?; - let name = IconThemeName(mat.string.clone().into()); - Self::set_icon_theme(name.clone(), cx); - Some(name) + } } - fn select_if_matching(&mut self, theme_name: &str) { - self.selected_index = self - .matches - .iter() - .position(|mat| mat.string == theme_name) - .unwrap_or(self.selected_index); + fn show_selected_theme(&mut self, cx: &mut Context>) { + if let Some(mat) = self.matches.get(self.selected_index) { + let name = IconThemeName(mat.string.clone().into()); + Self::set_icon_theme(name, cx); + } } fn set_icon_theme(name: IconThemeName, cx: &mut App) { @@ -208,7 +197,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { cx: &mut Context>, ) { self.selected_index = ix; - self.selected_theme = self.show_selected_theme(cx); + self.show_selected_theme(cx); } fn update_matches( @@ -250,31 +239,21 @@ impl PickerDelegate for IconThemeSelectorDelegate { .await }; - this.update(cx, |this, cx| { + this.update(cx, |this, _cx| { this.delegate.matches = matches; - if query.is_empty() && this.delegate.selected_theme.is_none() { - this.delegate.selected_index = this - .delegate - .selected_index - .min(this.delegate.matches.len().saturating_sub(1)); - } else if let Some(selected) = this.delegate.selected_theme.as_ref() { - this.delegate.selected_index = this - .delegate - .matches - .iter() - .enumerate() - .find(|(_, mtch)| mtch.string.as_str() == selected.0.as_ref()) - .map(|(ix, _)| ix) - .unwrap_or_default(); - } else { - this.delegate.selected_index = 0; - } - this.delegate.selected_theme = this.delegate.show_selected_theme(cx); }) .log_err(); }) } + fn match_stable_id(&self, ix: usize) -> Option { + self.matches.get(ix).map(|m| m.string.clone()) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|m| m.string == stable_id) + } + fn render_match( &self, ix: usize, diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 74b242dd0b7c3a..548bd2d9e179b0 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -126,7 +126,6 @@ struct ThemeSelectorDelegate { /// The currently selected new theme. new_theme: Arc, selection_completed: bool, - selected_theme: Option>, selected_index: usize, selector: WeakEntity, } @@ -188,7 +187,6 @@ impl ThemeSelectorDelegate { new_theme: original_theme, // Start with the original theme. selected_index, selection_completed: false, - selected_theme: None, selector, } } @@ -394,7 +392,7 @@ impl PickerDelegate for ThemeSelectorDelegate { cx: &mut Context>, ) { self.selected_index = ix; - self.selected_theme = self.show_selected_theme(cx); + self.show_selected_theme(cx); } fn update_matches( @@ -436,31 +434,25 @@ impl PickerDelegate for ThemeSelectorDelegate { .await }; - this.update(cx, |this, cx| { + this.update(cx, |this, _cx| { this.delegate.matches = matches; - if query.is_empty() && this.delegate.selected_theme.is_none() { - this.delegate.selected_index = this - .delegate - .selected_index - .min(this.delegate.matches.len().saturating_sub(1)); - } else if let Some(selected) = this.delegate.selected_theme.as_ref() { - this.delegate.selected_index = this - .delegate - .matches - .iter() - .enumerate() - .find(|(_, mtch)| mtch.string == selected.name) - .map(|(ix, _)| ix) - .unwrap_or_default(); - } else { - this.delegate.selected_index = 0; - } - this.delegate.selected_theme = this.delegate.show_selected_theme(cx); }) .log_err(); }) } + fn match_stable_id(&self, ix: usize) -> Option { + self.matches + .get(ix) + .map(|m| self.themes[m.candidate_id].name.to_string()) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches + .iter() + .position(|m| self.themes[m.candidate_id].name == stable_id) + } + fn render_match( &self, ix: usize, From 8c8519f16a88f334fd5ae0e6d19c2ae5a776d510 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Wed, 18 Feb 2026 23:23:23 +0100 Subject: [PATCH 02/15] Add tests --- crates/picker/src/picker.rs | 896 +++++++++++++++++++++++++++++++++++- 1 file changed, 894 insertions(+), 2 deletions(-) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index abd278f5afe9e1..9e8ddb5f478e71 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -506,7 +506,6 @@ impl Picker { self.delegate.set_selected_index(ix, window, cx); let current_index = self.delegate.selected_index(); - // Track manually selected item to preserve across match updates if is_manual_selection { self.manually_selected_stable_id = self.delegate.match_stable_id(current_index); } @@ -781,7 +780,9 @@ impl Picker { } else { // Item no longer in results, clear manual selection and reset to first item self.manually_selected_stable_id = None; - let ix = 0.min(match_count.saturating_sub(1)); + let current_index = self.delegate.selected_index(); + let ix = current_index.min(match_count.saturating_sub(1)); + if match_count > 0 { self.delegate.set_selected_index(ix, window, cx); } @@ -1273,3 +1274,894 @@ impl Render for Picker { } } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use settings::SettingsStore; + + struct TestItem { + id: String, + text: String, + } + + struct TestDelegate { + items: Vec, + matches: Vec, + selected_index: usize, + } + + impl TestDelegate { + fn new(items: Vec<(&str, &str)>) -> Self { + let items: Vec = items + .into_iter() + .map(|(id, text)| TestItem { + id: id.to_string(), + text: text.to_string(), + }) + .collect(); + let matches: Vec = (0..items.len()).collect(); + Self { + items, + matches, + selected_index: 0, + } + } + } + + impl PickerDelegate for TestDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search...".into() + } + + fn match_stable_id(&self, ix: usize) -> Option { + self.matches + .get(ix) + .and_then(|&item_ix| self.items.get(item_ix)) + .map(|item| item.id.clone()) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|&item_ix| { + self.items + .get(item_ix) + .is_some_and(|item| item.id == stable_id) + }) + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.matches = (0..self.items.len()).collect(); + } else { + self.matches = self + .items + .iter() + .enumerate() + .filter(|(_, item)| item.text.to_lowercase().contains(&query.to_lowercase())) + .map(|(ix, _)| ix) + .collect(); + } + Task::ready(()) + } + + fn confirm( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let item_ix = self.matches.get(ix)?; + let item = self.items.get(*item_ix)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(Label::new(item.text.clone())), + ) + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + }); + } + + #[gpui::test] + fn test_selection_preserved_when_query_changes(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![ + ("a", "apple"), + ("b", "box"), + ("c", "cherry"), + ("d", "door"), + ]), + window, + cx, + ) + }); + + // Initial state: first item selected + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.selected_index(), 0); + assert_eq!(picker.delegate.match_count(), 4); + }) + .unwrap(); + + // Navigate to third item (cherry) + picker + .update(cx, |picker, window, cx| { + picker.select_index_sticky(2, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), 2); + }) + .unwrap(); + + // Type a query that still includes cherry (contains "r") + picker + .update(cx, |picker, window, cx| { + picker.update_matches("r".to_string(), window, cx); + }) + .unwrap(); + + // Cherry should still be selected (it matches "r" and has stable_id "c") + picker + .update(cx, |picker, _window, _cx| { + // "r" matches: cherry (c), door (d) + assert_eq!(picker.delegate.match_count(), 2); + // cherry should still be selected - find its new index + let cherry_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "c") + .unwrap(); + assert_eq!(picker.delegate.selected_index(), cherry_index); + }) + .unwrap(); + } + + #[gpui::test] + fn test_selection_reset_when_item_no_longer_matches(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![ + ("a", "apple"), + ("b", "box"), + ("c", "cherry"), + ("d", "door"), + ]), + window, + cx, + ) + }); + + // Navigate to box (index 1) + picker + .update(cx, |picker, window, cx| { + picker.select_index_sticky(1, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), 1); + }) + .unwrap(); + + // Type a query that excludes box + picker + .update(cx, |picker, window, cx| { + picker.update_matches("apple".to_string(), window, cx); + }) + .unwrap(); + + // Box is no longer in results, selection should reset + picker + .update(cx, |picker, _window, _cx| { + // Only "apple" matches + assert_eq!(picker.delegate.match_count(), 1); + // Selection should be clamped to valid range + assert_eq!(picker.delegate.selected_index(), 0); + }) + .unwrap(); + } + + #[gpui::test] + fn test_selection_preserved_when_deleting_query_characters(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![ + ("a", "apple"), + ("b", "box"), + ("c", "cherry"), + ("d", "door"), + ]), + window, + cx, + ) + }); + + // Type a query + picker + .update(cx, |picker, window, cx| { + picker.update_matches("o".to_string(), window, cx); + }) + .unwrap(); + + // "o" matches: box, door (2 items) + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 2); + }) + .unwrap(); + + // Navigate to door (last item in filtered list) + picker + .update(cx, |picker, window, cx| { + let door_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "d") + .unwrap(); + picker.select_index_sticky(door_index, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), door_index); + }) + .unwrap(); + + // Delete the query (back to empty) + picker + .update(cx, |picker, window, cx| { + picker.update_matches("".to_string(), window, cx); + }) + .unwrap(); + + // Door should still be selected + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 4); + let door_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "d") + .unwrap(); + assert_eq!(picker.delegate.selected_index(), door_index); + }) + .unwrap(); + } + + #[gpui::test] + fn test_programmatic_selection_not_sticky(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![ + ("a", "apple"), + ("b", "box"), + ("c", "cherry"), + ("d", "door"), + ]), + window, + cx, + ) + }); + + // Use programmatic selection (not sticky) + picker + .update(cx, |picker, window, cx| { + picker.set_selected_index(2, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), 2); + }) + .unwrap(); + + // Type a query - since selection was programmatic, it should NOT be preserved + picker + .update(cx, |picker, window, cx| { + picker.update_matches("o".to_string(), window, cx); + }) + .unwrap(); + + // Selection should be clamped but not restored to cherry + picker + .update(cx, |picker, _window, _cx| { + // "o" matches: box, door (2 items) + assert_eq!(picker.delegate.match_count(), 2); + // Index 2 would be out of bounds, so it's clamped to 1 + assert!(picker.delegate.selected_index() <= 1); + }) + .unwrap(); + } + + struct BestMatchDelegate { + items: Vec, + matches: Vec, + selected_index: usize, + } + + impl BestMatchDelegate { + fn new(items: Vec<(&str, &str)>) -> Self { + let items: Vec = items + .into_iter() + .map(|(id, text)| TestItem { + id: id.to_string(), + text: text.to_string(), + }) + .collect(); + let matches: Vec = (0..items.len()).collect(); + Self { + items, + matches, + selected_index: 0, + } + } + } + + impl PickerDelegate for BestMatchDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search...".into() + } + + fn match_stable_id(&self, ix: usize) -> Option { + self.matches + .get(ix) + .and_then(|&item_ix| self.items.get(item_ix)) + .map(|item| item.id.clone()) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|&item_ix| { + self.items + .get(item_ix) + .is_some_and(|item| item.id == stable_id) + }) + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.matches = (0..self.items.len()).collect(); + } else { + self.matches = self + .items + .iter() + .enumerate() + .filter(|(_, item)| item.text.to_lowercase().contains(&query.to_lowercase())) + .map(|(ix, _)| ix) + .collect(); + } + + // This mimics OutlineViewDelegate behavior: always select "best" match + // (in this case, just pick the first match) + if !self.matches.is_empty() { + self.selected_index = 0; + } + + Task::ready(()) + } + + fn confirm( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let item_ix = self.matches.get(ix)?; + let item = self.items.get(*item_ix)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(Label::new(item.text.clone())), + ) + } + } + + #[gpui::test] + fn test_selection_preserved_when_query_shortened(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + TestDelegate::new(vec![ + ("a", "somethingNotifier"), + ("b", "anotherNotifier"), + ("c", "notifyHandler"), + ]), + window, + cx, + ) + }); + + // Type initial query "otif" - matches all 3 + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otif".to_string(), window, cx); + }) + .unwrap(); + + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 3); + }) + .unwrap(); + + // Narrow down to "otifier" - only matches somethingNotifier and anotherNotifier + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otifier".to_string(), window, cx); + }) + .unwrap(); + + picker + .update(cx, |picker, _window, _cx| { + // "otifier" matches: somethingNotifier, anotherNotifier (not "notifyHandler") + assert_eq!(picker.delegate.match_count(), 2); + }) + .unwrap(); + + // Select somethingNotifier (first item) + picker + .update(cx, |picker, window, cx| { + let something_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "a") + .unwrap(); + picker.select_index_sticky(something_index, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), something_index); + }) + .unwrap(); + + // Delete "ier" - query becomes "otif", now matches all 3 again + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otif".to_string(), window, cx); + }) + .unwrap(); + + // somethingNotifier should still be selected, NOT notifyHandler + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 3); + let something_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "a") + .unwrap(); + assert_eq!( + picker.delegate.selected_index(), + something_index, + "Expected somethingNotifier to remain selected, but selection changed" + ); + }) + .unwrap(); + } + + struct ReorderingDelegate { + items: Vec, + matches: Vec, + selected_index: usize, + } + + impl ReorderingDelegate { + fn new(items: Vec<(&str, &str)>) -> Self { + let items: Vec = items + .into_iter() + .map(|(id, text)| TestItem { + id: id.to_string(), + text: text.to_string(), + }) + .collect(); + let matches: Vec = (0..items.len()).collect(); + Self { + items, + matches, + selected_index: 0, + } + } + } + + impl PickerDelegate for ReorderingDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search...".into() + } + + fn match_stable_id(&self, ix: usize) -> Option { + self.matches + .get(ix) + .and_then(|&item_ix| self.items.get(item_ix)) + .map(|item| item.id.clone()) + } + + fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + self.matches.iter().position(|&item_ix| { + self.items + .get(item_ix) + .is_some_and(|item| item.id == stable_id) + }) + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.matches = (0..self.items.len()).collect(); + } else { + self.matches = self + .items + .iter() + .enumerate() + .filter(|(_, item)| item.text.to_lowercase().contains(&query.to_lowercase())) + .map(|(ix, _)| ix) + .collect(); + + // Simulate fuzzy matching that returns results in a different order + // based on "score" - reverse the order for shorter queries + if query.len() <= 4 { + self.matches.reverse(); + } + } + + // Always select "best" match (first in list) + if !self.matches.is_empty() { + self.selected_index = 0; + } + + Task::ready(()) + } + + fn confirm( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let item_ix = self.matches.get(ix)?; + let item = self.items.get(*item_ix)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(Label::new(item.text.clone())), + ) + } + } + + #[gpui::test] + fn test_selection_preserved_when_match_order_changes(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + ReorderingDelegate::new(vec![ + ("a", "somethingNotifier"), + ("b", "anotherNotifier"), + ("c", "notifyHandler"), + ]), + window, + cx, + ) + }); + + // Type longer query "otifier" - matches somethingNotifier, anotherNotifier + // With length > 4, order is normal: [0, 1] (somethingNotifier first) + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otifier".to_string(), window, cx); + }) + .unwrap(); + + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 2); + // Normal order: somethingNotifier (0), anotherNotifier (1) + assert_eq!(picker.delegate.matches, vec![0, 1]); + }) + .unwrap(); + + // Select somethingNotifier (index 0 in matches) + picker + .update(cx, |picker, window, cx| { + picker.select_index_sticky(0, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), 0); + }) + .unwrap(); + + // Type shorter query "otif" - matches all 3, but order is REVERSED + // With length <= 4, order becomes: [2, 1, 0] (notifyHandler first!) + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otif".to_string(), window, cx); + }) + .unwrap(); + + // somethingNotifier should still be selected even though: + // 1. The delegate tried to set selected_index to 0 (which is now notifyHandler) + // 2. The match order changed + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 3); + // Reversed order: notifyHandler (2), anotherNotifier (1), somethingNotifier (0) + assert_eq!(picker.delegate.matches, vec![2, 1, 0]); + + // somethingNotifier (item 0) should still be selected, which is now at match index 2 + let something_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "a") + .unwrap(); + assert_eq!(something_index, 2); // It's at position 2 now + + assert_eq!( + picker.delegate.selected_index(), + something_index, + "Expected somethingNotifier to remain selected at new index, but selection is at {}", + picker.delegate.selected_index() + ); + }) + .unwrap(); + } + + #[gpui::test] + fn test_selection_preserved_when_query_shortened_with_best_match_delegate( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + BestMatchDelegate::new(vec![ + ("a", "somethingNotifier"), + ("b", "anotherNotifier"), + ("c", "notifyHandler"), + ]), + window, + cx, + ) + }); + + // Type initial query "otif" - matches all 3 + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otif".to_string(), window, cx); + }) + .unwrap(); + + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 3); + }) + .unwrap(); + + // Narrow down to "otifier" - only matches somethingNotifier and anotherNotifier + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otifier".to_string(), window, cx); + }) + .unwrap(); + + picker + .update(cx, |picker, _window, _cx| { + // "otifier" matches: somethingNotifier, anotherNotifier (not "notifyHandler") + assert_eq!(picker.delegate.match_count(), 2); + }) + .unwrap(); + + // Select somethingNotifier (first item) + picker + .update(cx, |picker, window, cx| { + let something_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "a") + .unwrap(); + picker.select_index_sticky(something_index, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), something_index); + }) + .unwrap(); + + // Delete "ier" - query becomes "otif", now matches all 3 again + // The BestMatchDelegate will try to set selection to 0 (first match) + // but the picker should restore it to somethingNotifier via stable ID + picker + .update(cx, |picker, window, cx| { + picker.update_matches("otif".to_string(), window, cx); + }) + .unwrap(); + + // somethingNotifier should still be selected, NOT notifyHandler + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.match_count(), 3); + let something_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "a") + .unwrap(); + assert_eq!( + picker.delegate.selected_index(), + something_index, + "Expected somethingNotifier to remain selected, but selection changed" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn test_delegate_that_sets_selection_in_update_matches(cx: &mut TestAppContext) { + init_test(cx); + + let picker = cx.add_window(|window, cx| { + Picker::uniform_list( + BestMatchDelegate::new(vec![ + ("a", "apple"), + ("b", "box"), + ("c", "cherry"), + ("d", "door"), + ]), + window, + cx, + ) + }); + + // Initial state: first item selected + picker + .update(cx, |picker, _window, _cx| { + assert_eq!(picker.delegate.selected_index(), 0); + assert_eq!(picker.delegate.match_count(), 4); + }) + .unwrap(); + + // Navigate to cherry (index 2) using sticky selection + picker + .update(cx, |picker, window, cx| { + picker.select_index_sticky(2, None, true, window, cx); + assert_eq!(picker.delegate.selected_index(), 2); + }) + .unwrap(); + + // Type a query that still includes cherry + // The delegate will set selected_index to 0 (best match), but the picker + // should restore it to cherry via stable ID + picker + .update(cx, |picker, window, cx| { + picker.update_matches("r".to_string(), window, cx); + }) + .unwrap(); + + // Cherry should still be selected even though delegate tried to select first match + picker + .update(cx, |picker, _window, _cx| { + // "r" matches: cherry (c), door (d) + assert_eq!(picker.delegate.match_count(), 2); + // cherry should still be selected - find its new index + let cherry_index = picker + .delegate + .matches + .iter() + .position(|&ix| picker.delegate.items[ix].id == "c") + .unwrap(); + assert_eq!(picker.delegate.selected_index(), cherry_index); + }) + .unwrap(); + } +} From 12160e88399c056ca92b081762823f933b6cdeaf Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Wed, 18 Feb 2026 23:23:23 +0100 Subject: [PATCH 03/15] Use StableId trait for picker item identity PickerDelegate now uses a StableId associated type instead of String for stable item identification. This enables structured, efficient comparison and avoids string formatting for item identity. All picker implementations updated to use StableId, with () or SharedString as appropriate. --- .../src/agent_configuration/tool_picker.rs | 1 + crates/agent_ui/src/config_options.rs | 1 + .../agent_ui/src/language_model_selector.rs | 1 + crates/agent_ui/src/model_selector.rs | 1 + crates/agent_ui/src/profile_selector.rs | 1 + crates/agent_ui/src/slash_command_picker.rs | 1 + .../src/collab_panel/channel_modal.rs | 1 + .../src/collab_panel/contact_finder.rs | 1 + crates/command_palette/src/command_palette.rs | 1 + crates/debugger_ui/src/attach_modal.rs | 1 + crates/debugger_ui/src/new_process_modal.rs | 1 + crates/dev_container/src/lib.rs | 2 + .../src/encoding_selector.rs | 1 + .../src/extension_version_selector.rs | 1 + crates/file_finder/src/file_finder.rs | 1 + crates/git_ui/src/branch_picker.rs | 1 + crates/git_ui/src/picker_prompt.rs | 1 + crates/git_ui/src/repository_selector.rs | 1 + crates/git_ui/src/stash_picker.rs | 1 + crates/git_ui/src/worktree_picker.rs | 1 + .../src/language_selector.rs | 1 + .../src/line_ending_selector.rs | 1 + crates/onboarding/src/base_keymap_picker.rs | 1 + .../open_path_prompt/src/open_path_prompt.rs | 1 + crates/outline/src/outline.rs | 46 +- crates/picker/src/picker.rs | 408 +++++++++--------- crates/picker/src/stable_id.rs | 21 + crates/project_symbols/src/project_symbols.rs | 14 +- crates/recent_projects/src/recent_projects.rs | 1 + crates/recent_projects/src/remote_servers.rs | 1 + crates/repl/src/components/kernel_options.rs | 1 + crates/rules_library/src/rules_library.rs | 1 + .../src/settings_profile_selector.rs | 1 + .../settings_ui/src/components/font_picker.rs | 1 + .../src/components/icon_theme_picker.rs | 1 + .../src/components/ollama_model_picker.rs | 1 + .../src/components/theme_picker.rs | 1 + crates/snippets_ui/src/snippets_ui.rs | 1 + crates/storybook/src/stories/picker.rs | 1 + crates/tab_switcher/src/tab_switcher.rs | 1 + crates/tasks_ui/src/modal.rs | 1 + .../theme_selector/src/icon_theme_selector.rs | 11 +- crates/theme_selector/src/theme_selector.rs | 9 +- .../src/toolchain_selector.rs | 1 + crates/vim/src/state.rs | 2 + 45 files changed, 323 insertions(+), 227 deletions(-) create mode 100644 crates/picker/src/stable_id.rs diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index be6fcb5bd2b5ee..84ae5d19c3f5bc 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -154,6 +154,7 @@ impl ToolPickerDelegate { impl PickerDelegate for ToolPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_items.len() diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 6ec2595202490c..7c3c034a1d3d5a 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -479,6 +479,7 @@ impl ConfigOptionPickerDelegate { impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index e6e72b3197b410..91412328bb4453 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -441,6 +441,7 @@ impl ModelMatcher { impl PickerDelegate for LanguageModelPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 89ed3e490b33ca..6fb71182b37c1f 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -198,6 +198,7 @@ impl ModelPickerDelegate { impl PickerDelegate for ModelPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 926549c22f88bc..043a8f4741814a 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -416,6 +416,7 @@ impl ProfilePickerDelegate { impl PickerDelegate for ProfilePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { "Search profiles…".into() diff --git a/crates/agent_ui/src/slash_command_picker.rs b/crates/agent_ui/src/slash_command_picker.rs index 0c3cf37599887f..7409f8c74f5bb8 100644 --- a/crates/agent_ui/src/slash_command_picker.rs +++ b/crates/agent_ui/src/slash_command_picker.rs @@ -73,6 +73,7 @@ where impl PickerDelegate for SlashCommandDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.filtered_commands.len() diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 3b3d974f3e50a9..9e399f97e004de 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -257,6 +257,7 @@ pub struct ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search collaborator by username...".into() diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 09543962a29def..8e10c94944c215 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -70,6 +70,7 @@ impl Focusable for ContactFinder { impl PickerDelegate for ContactFinderDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.potential_contacts.len() diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index d13360a7c5403d..896d7ea6733eb3 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -368,6 +368,7 @@ impl CommandPaletteDelegate { impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 6e537ae0c6e1db..56e5305290fbce 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -136,6 +136,7 @@ impl ModalView for AttachModal {} impl PickerDelegate for AttachModalDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 5b028671ed512a..83bc04a3500bd6 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1206,6 +1206,7 @@ impl DebugDelegate { impl PickerDelegate for DebugDelegate { type ListItem = ui::ListItem; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 7fcacf8004bef6..4dd8a2f606fc9c 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -238,6 +238,7 @@ impl TemplatePickerDelegate { impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.matching_indices.len() @@ -421,6 +422,7 @@ impl FeaturePickerDelegate { impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.matching_indices.len() diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index 3954bf29a30a09..feb5c2284e86e3 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -220,6 +220,7 @@ fn available_encodings() -> Vec<&'static Encoding> { impl PickerDelegate for EncodingSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Reopen with encoding...".into() diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 6dd45954a71282..85628d453ca72b 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -91,6 +91,7 @@ impl ExtensionVersionSelectorDelegate { impl PickerDelegate for ExtensionVersionSelectorDelegate { type ListItem = ui::ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select extension version...".into() diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a1e64964ff578e..59ac5d7fcc4687 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1319,6 +1319,7 @@ fn full_path_budget( impl PickerDelegate for FileFinderDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project files...".into() diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d1ab60b9042fb0..dc1053cd7a7712 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -561,6 +561,7 @@ impl BranchListDelegate { impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 14daedda61ecc7..6b89422bc21734 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -117,6 +117,7 @@ impl PickerPromptDelegate { impl PickerDelegate for PickerPromptDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.prompt.clone() diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 463540de90ce20..4dd06fbfcf7a8a 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -158,6 +158,7 @@ impl RepositorySelectorDelegate { impl PickerDelegate for RepositorySelectorDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.filtered_repositories.len() diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index e736dd806a3570..d5545070cba091 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -348,6 +348,7 @@ impl StashListDelegate { impl PickerDelegate for StashListDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a stash…".into() diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 6c35e7c99ffb8f..b7c8305466d4b5 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -600,6 +600,7 @@ async fn open_remote_worktree( impl PickerDelegate for WorktreeListDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select worktree…".into() diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 17a39d4979a132..963b49b17c3781 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -197,6 +197,7 @@ impl LanguageSelectorDelegate { impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a language…".into() diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 504c327a349c97..3c5ce272a21a6c 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -114,6 +114,7 @@ impl LineEndingSelectorDelegate { impl PickerDelegate for LineEndingSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a line ending…".into() diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 63a2894a93504b..fc99949837e0c4 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -101,6 +101,7 @@ impl BaseKeymapSelectorDelegate { impl PickerDelegate for BaseKeymapSelectorDelegate { type ListItem = ui::ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a base keymap...".into() diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index fa609a63be1101..4c15571ccbe1a6 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -251,6 +251,7 @@ impl OpenPathPrompt { impl PickerDelegate for OpenPathDelegate { type ListItem = ui::ListItem; + type StableId = (); fn match_count(&self) -> usize { let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state { diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 3ef58af6d7e640..a3082e44c74181 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -10,18 +10,37 @@ use editor::{MultiBufferOffset, RowHighlightOptions, SelectionEffects}; use fuzzy::StringMatch; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, - ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, - rems, + ParentElement, Point, Render, SharedString, Styled, StyledText, Task, TextStyle, WeakEntity, + Window, div, rems, }; use language::{Outline, OutlineItem}; use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate}; +use picker::{Picker, PickerDelegate, stable_id::StableId}; use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{DismissDecision, ModalView}; +/// Stable identifier for outline items, used to preserve manual selections +/// across match updates in the outline picker. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +struct OutlineStableId { + text: SharedString, + depth: usize, +} + +impl OutlineStableId { + fn new(text: impl Into, depth: usize) -> Self { + Self { + text: text.into(), + depth, + } + } +} + +impl StableId for OutlineStableId {} + pub fn init(cx: &mut App) { cx.observe_new(OutlineView::register).detach(); zed_actions::outline::TOGGLE_OUTLINE @@ -252,6 +271,7 @@ impl OutlineViewDelegate { impl PickerDelegate for OutlineViewDelegate { type ListItem = ListItem; + type StableId = OutlineStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search buffer symbols...".into() @@ -343,19 +363,23 @@ impl PickerDelegate for OutlineViewDelegate { Task::ready(()) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { let mat = self.matches.get(ix)?; let outline_item = self.outline.items.get(mat.candidate_id)?; - Some(format!("{}:{}", outline_item.text, outline_item.depth)) + Some(OutlineStableId::new( + outline_item.text.clone(), + outline_item.depth, + )) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &OutlineStableId) -> Option { self.matches.iter().position(|mat| { - if let Some(outline_item) = self.outline.items.get(mat.candidate_id) { - format!("{}:{}", outline_item.text, outline_item.depth) == stable_id - } else { - false - } + self.outline + .items + .get(mat.candidate_id) + .is_some_and(|item| { + item.text == stable_id.text.as_ref() && item.depth == stable_id.depth + }) }) } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 9e8ddb5f478e71..e837afc98ef9a5 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1,8 +1,10 @@ mod head; pub mod highlighted_match_with_paths; pub mod popover_menu; +pub mod stable_id; use anyhow::Result; +use stable_id::StableId; use gpui::{ Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle, @@ -77,7 +79,7 @@ pub struct Picker { /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, /// Tracks the stable ID of a manually selected item to preserve it across match updates. - manually_selected_stable_id: Option, + manually_selected_stable_id: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -91,6 +93,7 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; + type StableId: StableId; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; @@ -141,13 +144,13 @@ pub trait PickerDelegate: Sized + 'static { /// Returns a stable identifier for the match at the given index. /// If implemented, the picker will try to preserve manual selections /// across match updates by finding the same item again. - fn match_stable_id(&self, _ix: usize) -> Option { + fn match_stable_id(&self, _ix: usize) -> Option { None } /// Finds the index of a match with the given stable identifier. /// Used in conjunction with `match_stable_id` to restore selections. - fn find_match_by_stable_id(&self, _stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, _stable_id: &Self::StableId) -> Option { None } @@ -938,15 +941,16 @@ impl Picker { mod tests { use super::*; use gpui::TestAppContext; + use settings::SettingsStore; use std::cell::Cell; - struct TestDelegate { + struct SelectabilityDelegate { items: Vec, selected_index: usize, confirmed_index: Rc>>, } - impl TestDelegate { + impl SelectabilityDelegate { fn new(items: Vec) -> Self { Self { items, @@ -956,8 +960,9 @@ mod tests { } } - impl PickerDelegate for TestDelegate { + impl PickerDelegate for SelectabilityDelegate { type ListItem = ui::ListItem; + type StableId = (); fn match_count(&self) -> usize { self.items.len() @@ -1025,7 +1030,7 @@ mod tests { } } - fn init_test(cx: &mut TestAppContext) { + fn init_selectability_test(cx: &mut TestAppContext) { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); @@ -1036,11 +1041,11 @@ mod tests { #[gpui::test] async fn test_clicking_non_selectable_item_does_not_confirm(cx: &mut TestAppContext) { - init_test(cx); + init_selectability_test(cx); let confirmed_index = Rc::new(Cell::new(None)); let (picker, cx) = cx.add_window_view(|window, cx| { - let mut delegate = TestDelegate::new(vec![true, false, true]); + let mut delegate = SelectabilityDelegate::new(vec![true, false, true]); delegate.confirmed_index = confirmed_index.clone(); Picker::uniform_list(delegate, window, cx) }); @@ -1069,10 +1074,14 @@ mod tests { #[gpui::test] async fn test_keyboard_navigation_skips_non_selectable_items(cx: &mut TestAppContext) { - init_test(cx); + init_selectability_test(cx); let (picker, cx) = cx.add_window_view(|window, cx| { - Picker::uniform_list(TestDelegate::new(vec![true, false, true]), window, cx) + Picker::uniform_list( + SelectabilityDelegate::new(vec![true, false, true]), + window, + cx, + ) }); picker.update(cx, |picker, _cx| { @@ -1101,185 +1110,7 @@ mod tests { ); }); } -} -impl EventEmitter for Picker {} -impl ModalView for Picker {} - -impl Render for Picker { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; - - let aside = self.delegate.documentation_aside(window, cx); - - let editor_position = self.delegate.editor_position(); - let picker_bounds = self.picker_bounds.clone(); - let menu = v_flex() - .key_context("Picker") - .size_full() - .when_some(self.width, |el, width| el.w(width)) - .overflow_hidden() - .child( - canvas( - move |bounds, _window, _cx| { - picker_bounds.set(Some(bounds)); - }, - |_bounds, _state, _window, _cx| {}, - ) - .size_full() - .absolute() - .top_0() - .left_0(), - ) - // This is a bit of a hack to remove the modal styling when we're rendering the `Picker` - // as a part of a modal rather than the entire modal. - // - // We should revisit how the `Picker` is styled to make it more composable. - .when(self.is_modal, |this| this.elevation_3(cx)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::editor_move_down)) - .on_action(cx.listener(Self::editor_move_up)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::secondary_confirm)) - .on_action(cx.listener(Self::confirm_completion)) - .on_action(cx.listener(Self::confirm_input)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::Start { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }) - .when(self.delegate.match_count() > 0, |el| { - el.child( - v_flex() - .id("element-container") - .relative() - .flex_grow() - .when_some(self.max_height, |div, max_h| div.max_h(max_h)) - .overflow_hidden() - .children(self.delegate.render_header(window, cx)) - .child(self.render_element_container(cx)) - .when(self.show_scrollbar, |this| { - let base_scrollbar_config = - Scrollbars::new(ScrollAxes::Vertical).width_sm(); - - this.map(|this| match &self.element_container { - ElementContainer::List(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - ElementContainer::UniformList(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - }) - }), - ) - }) - .when(self.delegate.match_count() == 0, |el| { - el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { - el.child( - v_flex().flex_grow().py_2().child( - ListItem::new("empty_state") - .inset(true) - .spacing(ListItemSpacing::Sparse) - .disabled(true) - .child(Label::new(text).color(Color::Muted)), - ), - ) - }) - }) - .children(self.delegate.render_footer(window, cx)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::End { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }); - - let Some(aside) = aside else { - return menu; - }; - - let render_aside = |aside: DocumentationAside, cx: &mut Context| { - WithRemSize::new(ui_font_size) - .occlude() - .elevation_2(cx) - .w_full() - .p_2() - .overflow_hidden() - .when(is_wide_window, |this| this.max_w_96()) - .when(!is_wide_window, |this| this.max_w_48()) - .child((aside.render)(cx)) - }; - - if is_wide_window { - let aside_index = self.delegate.documentation_aside_index(); - let picker_bounds = self.picker_bounds.get(); - let item_bounds = - aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); - - let item_position = match (picker_bounds, item_bounds) { - (Some(picker_bounds), Some(item_bounds)) => { - let relative_top = item_bounds.origin.y - picker_bounds.origin.y; - let height = item_bounds.size.height; - Some((relative_top, height)) - } - _ => None, - }; - - div() - .relative() - .child(menu) - // Only render the aside once we have bounds to avoid flicker - .when_some(item_position, |this, (top, height)| { - this.child( - h_flex() - .absolute() - .when(aside.side == DocumentationSide::Left, |el| { - el.right_full().mr_1() - }) - .when(aside.side == DocumentationSide::Right, |el| { - el.left_full().ml_1() - }) - .top(top) - .h(height) - .child(render_aside(aside, cx)), - ) - }) - } else { - v_flex() - .w_full() - .gap_1() - .justify_end() - .child(render_aside(aside, cx)) - .child(menu) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use settings::SettingsStore; struct TestItem { id: String, @@ -1312,6 +1143,7 @@ mod tests { impl PickerDelegate for TestDelegate { type ListItem = ListItem; + type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1334,18 +1166,18 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| item.id.clone()) + .map(|item| SharedString::from(item.id.clone())) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { self.matches.iter().position(|&item_ix| { self.items .get(item_ix) - .is_some_and(|item| item.id == stable_id) + .is_some_and(|item| item.id == stable_id.as_ref()) }) } @@ -1643,6 +1475,7 @@ mod tests { impl PickerDelegate for BestMatchDelegate { type ListItem = ListItem; + type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1665,18 +1498,18 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| item.id.clone()) + .map(|item| SharedString::from(item.id.clone())) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { self.matches.iter().position(|&item_ix| { self.items .get(item_ix) - .is_some_and(|item| item.id == stable_id) + .is_some_and(|item| item.id == stable_id.as_ref()) }) } @@ -1845,6 +1678,7 @@ mod tests { impl PickerDelegate for ReorderingDelegate { type ListItem = ListItem; + type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1867,18 +1701,18 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| item.id.clone()) + .map(|item| SharedString::from(item.id.clone())) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { self.matches.iter().position(|&item_ix| { self.items .get(item_ix) - .is_some_and(|item| item.id == stable_id) + .is_some_and(|item| item.id == stable_id.as_ref()) }) } @@ -2165,3 +1999,175 @@ mod tests { .unwrap(); } } + +impl EventEmitter for Picker {} +impl ModalView for Picker {} + +impl Render for Picker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let window_size = window.viewport_size(); + let rem_size = window.rem_size(); + let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; + + let aside = self.delegate.documentation_aside(window, cx); + + let editor_position = self.delegate.editor_position(); + let picker_bounds = self.picker_bounds.clone(); + let menu = v_flex() + .key_context("Picker") + .size_full() + .when_some(self.width, |el, width| el.w(width)) + .overflow_hidden() + .child( + canvas( + move |bounds, _window, _cx| { + picker_bounds.set(Some(bounds)); + }, + |_bounds, _state, _window, _cx| {}, + ) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + // This is a bit of a hack to remove the modal styling when we're rendering the `Picker` + // as a part of a modal rather than the entire modal. + // + // We should revisit how the `Picker` is styled to make it more composable. + .when(self.is_modal, |this| this.elevation_3(cx)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::secondary_confirm)) + .on_action(cx.listener(Self::confirm_completion)) + .on_action(cx.listener(Self::confirm_input)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::Start { + Some(self.delegate.render_editor(&editor.clone(), window, cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), + }) + .when(self.delegate.match_count() > 0, |el| { + el.child( + v_flex() + .id("element-container") + .relative() + .flex_grow() + .when_some(self.max_height, |div, max_h| div.max_h(max_h)) + .overflow_hidden() + .children(self.delegate.render_header(window, cx)) + .child(self.render_element_container(cx)) + .when(self.show_scrollbar, |this| { + let base_scrollbar_config = + Scrollbars::new(ScrollAxes::Vertical).width_sm(); + + this.map(|this| match &self.element_container { + ElementContainer::List(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + ElementContainer::UniformList(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + }) + }), + ) + }) + .when(self.delegate.match_count() == 0, |el| { + el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { + el.child( + v_flex().flex_grow().py_2().child( + ListItem::new("empty_state") + .inset(true) + .spacing(ListItemSpacing::Sparse) + .disabled(true) + .child(Label::new(text).color(Color::Muted)), + ), + ) + }) + }) + .children(self.delegate.render_footer(window, cx)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::End { + Some(self.delegate.render_editor(&editor.clone(), window, cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), + }); + + let Some(aside) = aside else { + return menu; + }; + + let render_aside = |aside: DocumentationAside, cx: &mut Context| { + WithRemSize::new(ui_font_size) + .occlude() + .elevation_2(cx) + .w_full() + .p_2() + .overflow_hidden() + .when(is_wide_window, |this| this.max_w_96()) + .when(!is_wide_window, |this| this.max_w_48()) + .child((aside.render)(cx)) + }; + + if is_wide_window { + let aside_index = self.delegate.documentation_aside_index(); + let picker_bounds = self.picker_bounds.get(); + let item_bounds = + aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); + + let item_position = match (picker_bounds, item_bounds) { + (Some(picker_bounds), Some(item_bounds)) => { + let relative_top = item_bounds.origin.y - picker_bounds.origin.y; + let height = item_bounds.size.height; + Some((relative_top, height)) + } + _ => None, + }; + + div() + .relative() + .child(menu) + // Only render the aside once we have bounds to avoid flicker + .when_some(item_position, |this, (top, height)| { + this.child( + h_flex() + .absolute() + .when(aside.side == DocumentationSide::Left, |el| { + el.right_full().mr_1() + }) + .when(aside.side == DocumentationSide::Right, |el| { + el.left_full().ml_1() + }) + .top(top) + .h(height) + .child(render_aside(aside, cx)), + ) + }) + } else { + v_flex() + .w_full() + .gap_1() + .justify_end() + .child(render_aside(aside, cx)) + .child(menu) + } + } +} diff --git a/crates/picker/src/stable_id.rs b/crates/picker/src/stable_id.rs new file mode 100644 index 00000000000000..220e43e1bcf3a9 --- /dev/null +++ b/crates/picker/src/stable_id.rs @@ -0,0 +1,21 @@ +use std::{fmt::Debug, hash::Hash}; + +use gpui::SharedString; + +/// Identifies a picker item in a stable way across match updates. +/// +/// Implementations should be cheap to clone and compare, as stable IDs +/// are used to preserve manual selections when the picker's matches are updated. +/// +/// # Performance Considerations +/// +/// - Cloning should be cheap (e.g., using reference-counted strings like `SharedString`) +/// - Equality comparison should avoid allocations +/// - Consider using structured data instead of formatted strings for comparison +pub trait StableId: Clone + Eq + Hash + Debug + Send + 'static {} + +/// Unit type implements StableId as a no-op default for delegates that don't need stable IDs +impl StableId for () {} + +/// SharedString implements StableId for string-based stable identifiers +impl StableId for SharedString {} diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index af340a8eceebd3..b1960fdacf9a31 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -107,6 +107,8 @@ impl ProjectSymbolsDelegate { impl PickerDelegate for ProjectSymbolsDelegate { type ListItem = ListItem; + type StableId = SharedString; + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project symbols...".into() } @@ -224,20 +226,17 @@ impl PickerDelegate for ProjectSymbolsDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { let mat = self.matches.get(ix)?; let symbol = self.symbols.get(mat.candidate_id)?; let path_str = match &symbol.path { SymbolLocation::InProject(path) => format!("{:?}:{:?}", path.worktree_id, path.path), SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), }; - Some(format!( - "{}:{}:{:?}", - path_str, symbol.name, symbol.range.start - )) + Some(format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start).into()) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { self.matches.iter().position(|mat| { if let Some(symbol) = self.symbols.get(mat.candidate_id) { let path_str = match &symbol.path { @@ -246,7 +245,8 @@ impl PickerDelegate for ProjectSymbolsDelegate { } SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), }; - format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start) == stable_id + format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start) + == stable_id.as_ref() } else { false } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 548e08eccb49c1..3644de1c1310f3 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -694,6 +694,7 @@ impl RecentProjectsDelegate { impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index b094ff6c5bc549..f5c9226753a3a8 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -202,6 +202,7 @@ impl DevContainerPickerDelegate { impl PickerDelegate for DevContainerPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.matching_candidates.len() diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index b6d4f39c0ccb75..eaa1d314106074 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -205,6 +205,7 @@ impl KernelPickerDelegate { impl PickerDelegate for KernelPickerDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 73bf5fdd8fcaaf..66b018511b3c1a 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -199,6 +199,7 @@ impl EventEmitter for Picker {} impl PickerDelegate for RulePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 7ca91e3767efb6..8a3260e4c667dc 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -148,6 +148,7 @@ impl SettingsProfileSelectorDelegate { impl PickerDelegate for SettingsProfileSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc { "Select a settings profile...".into() diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index 564d98c6d2d9a7..f75a2e80ccfff8 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -55,6 +55,7 @@ impl FontPickerDelegate { impl PickerDelegate for FontPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_fonts.len() diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index f369a8207dc334..ab0cf7b9d3e049 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -58,6 +58,7 @@ impl IconThemePickerDelegate { impl PickerDelegate for IconThemePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_themes.len() diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index 268bf196bce3d0..2c19deb7aefafc 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -61,6 +61,7 @@ impl OllamaModelPickerDelegate { impl PickerDelegate for OllamaModelPickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_models.len() diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index a1f1339a7ad128..697e27e1444158 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -53,6 +53,7 @@ impl ThemePickerDelegate { impl PickerDelegate for ThemePickerDelegate { type ListItem = AnyElement; + type StableId = (); fn match_count(&self) -> usize { self.filtered_themes.len() diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index c881d5276e6f96..88cf0cb23fcc61 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -198,6 +198,7 @@ impl ScopeSelectorDelegate { impl PickerDelegate for ScopeSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc { "Select snippet scope...".into() diff --git a/crates/storybook/src/stories/picker.rs b/crates/storybook/src/stories/picker.rs index fa65fd085dc158..61e797ea96eaa5 100644 --- a/crates/storybook/src/stories/picker.rs +++ b/crates/storybook/src/stories/picker.rs @@ -32,6 +32,7 @@ impl Delegate { impl PickerDelegate for Delegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.candidates.len() diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 0fb13c85d21797..5a08a7d385a546 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -710,6 +710,7 @@ impl TabSwitcherDelegate { impl PickerDelegate for TabSwitcherDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search all tabs…".into() diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 6b4fc21ef3ede0..d920b8ea51b10e 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -248,6 +248,7 @@ const MAX_TAGS_LINE_LEN: usize = 30; impl PickerDelegate for TasksModalDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index f8c36857bd8669..e520f360be20c1 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -134,6 +134,7 @@ impl IconThemeSelectorDelegate { impl PickerDelegate for IconThemeSelectorDelegate { type ListItem = ui::ListItem; + type StableId = SharedString; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Icon Theme...".into() @@ -246,12 +247,14 @@ impl PickerDelegate for IconThemeSelectorDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { - self.matches.get(ix).map(|m| m.string.clone()) + fn match_stable_id(&self, ix: usize) -> Option { + self.matches.get(ix).map(|m| m.string.clone().into()) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { - self.matches.iter().position(|m| m.string == stable_id) + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + self.matches + .iter() + .position(|m| m.string == stable_id.as_ref()) } fn render_match( diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 548bd2d9e179b0..81ab2fb7b43e79 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -334,6 +334,7 @@ fn retain_original_opposing_theme( impl PickerDelegate for ThemeSelectorDelegate { type ListItem = ui::ListItem; + type StableId = SharedString; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Theme...".into() @@ -441,16 +442,16 @@ impl PickerDelegate for ThemeSelectorDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { self.matches .get(ix) - .map(|m| self.themes[m.candidate_id].name.to_string()) + .map(|m| self.themes[m.candidate_id].name.to_string().into()) } - fn find_match_by_stable_id(&self, stable_id: &str) -> Option { + fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { self.matches .iter() - .position(|m| self.themes[m.candidate_id].name == stable_id) + .position(|m| self.themes[m.candidate_id].name == stable_id.as_ref()) } fn render_match( diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index f7b451e876cb94..cf029d8e486d1a 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -897,6 +897,7 @@ impl ToolchainSelectorDelegate { impl PickerDelegate for ToolchainSelectorDelegate { type ListItem = ListItem; + type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone() diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0244a14c83b422..d302d8dcc103c4 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1194,6 +1194,7 @@ pub struct RegistersViewDelegate { impl PickerDelegate for RegistersViewDelegate { type ListItem = Div; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() @@ -1408,6 +1409,7 @@ pub struct MarksViewDelegate { impl PickerDelegate for MarksViewDelegate { type ListItem = Div; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() From 4207127d32fd985c24f57c8d9ea42008cc58a3e6 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Wed, 18 Feb 2026 23:23:23 +0100 Subject: [PATCH 04/15] Add stable id support for picker items Preserves manual selections across match updates by introducing BranchStableId and implementing StableId for branch picker items. --- crates/git_ui/src/branch_picker.rs | 49 ++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index dc1053cd7a7712..4ead64eda03e8d 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -10,7 +10,7 @@ use gpui::{ InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; -use picker::{Picker, PickerDelegate, PickerEditorPosition}; +use picker::{Picker, PickerDelegate, PickerEditorPosition, stable_id::StableId}; use project::git_store::Repository; use project::project_settings::ProjectSettings; use settings::Settings; @@ -362,6 +362,18 @@ impl Entry { } } +/// Stable identifier for branch picker items, used to preserve manual selections +/// across match updates in the branch picker. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub enum BranchStableId { + Branch(SharedString), + NewUrl(String), + NewBranch(String), + NewRemoteName { name: String, url: SharedString }, +} + +impl StableId for BranchStableId {} + #[derive(Clone, Copy, PartialEq)] enum BranchFilter { /// Show both local and remote branches. @@ -561,7 +573,7 @@ impl BranchListDelegate { impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; - type StableId = (); + type StableId = BranchStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { @@ -784,6 +796,39 @@ impl PickerDelegate for BranchListDelegate { .log_err(); }) } + fn match_stable_id(&self, ix: usize) -> Option { + match self.matches.get(ix)? { + Entry::Branch { branch, .. } => Some(BranchStableId::Branch(branch.ref_name.clone())), + Entry::NewUrl { url } => Some(BranchStableId::NewUrl(url.clone())), + Entry::NewBranch { name } => Some(BranchStableId::NewBranch(name.clone())), + Entry::NewRemoteName { name, url } => Some(BranchStableId::NewRemoteName { + name: name.clone(), + url: url.clone(), + }), + } + } + + fn find_match_by_stable_id(&self, stable_id: &BranchStableId) -> Option { + self.matches + .iter() + .position(|entry| match (entry, stable_id) { + (Entry::Branch { branch, .. }, BranchStableId::Branch(ref_name)) => { + &branch.ref_name == ref_name + } + (Entry::NewUrl { url }, BranchStableId::NewUrl(stable_url)) => url == stable_url, + (Entry::NewBranch { name }, BranchStableId::NewBranch(stable_name)) => { + name == stable_name + } + ( + Entry::NewRemoteName { name, url }, + BranchStableId::NewRemoteName { + name: stable_name, + url: stable_url, + }, + ) => name == stable_name && url == stable_url, + _ => false, + }) + } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { let Some(entry) = self.matches.get(self.selected_index()) else { From 09b904371980f30e7f11987326f8171dbae34001 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Thu, 19 Feb 2026 00:22:44 +0100 Subject: [PATCH 05/15] Add stable id support to picker delegate Implement stable id matching for project picker to enable consistent selection and navigation. --- crates/recent_projects/src/recent_projects.rs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 3644de1c1310f3..db782937ba116a 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -30,6 +30,7 @@ use gpui::{ use picker::{ Picker, PickerDelegate, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, + stable_id::StableId, }; use project::{Worktree, git_store::Repository}; pub use remote_connections::RemoteSettings; @@ -692,9 +693,18 @@ impl RecentProjectsDelegate { } } impl EventEmitter for RecentProjectsDelegate {} + +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub enum RecentProjectsStableId { + OpenFolder(WorktreeId), + RecentProject(WorkspaceId), +} + +impl StableId for RecentProjectsStableId {} + impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; - type StableId = (); + type StableId = RecentProjectsStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() @@ -738,6 +748,43 @@ impl PickerDelegate for RecentProjectsDelegate { self.filtered_entries.len() } + fn match_stable_id(&self, ix: usize) -> Option { + let entry = self.filtered_entries.get(ix)?; + match entry { + ProjectPickerEntry::OpenFolder { index, .. } => { + let folder = self.open_folders.get(*index)?; + Some(RecentProjectsStableId::OpenFolder(folder.worktree_id)) + } + ProjectPickerEntry::RecentProject(mat) => { + let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; + Some(RecentProjectsStableId::RecentProject(*workspace_id)) + } + ProjectPickerEntry::Header(_) => None, + } + } + + fn find_match_by_stable_id(&self, stable_id: &Self::StableId) -> Option { + self.filtered_entries + .iter() + .position(|entry| match (entry, stable_id) { + ( + ProjectPickerEntry::OpenFolder { index, .. }, + RecentProjectsStableId::OpenFolder(worktree_id), + ) => self + .open_folders + .get(*index) + .is_some_and(|folder| folder.worktree_id == *worktree_id), + ( + ProjectPickerEntry::RecentProject(mat), + RecentProjectsStableId::RecentProject(workspace_id), + ) => self + .workspaces + .get(mat.candidate_id) + .is_some_and(|(id, _, _, _)| id == workspace_id), + _ => false, + }) + } + fn selected_index(&self) -> usize { self.selected_index } From 43428018bf7fe6829c5ee415796f3e7117002425 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Thu, 19 Feb 2026 19:25:32 +0100 Subject: [PATCH 06/15] add missing implementations --- crates/recent_projects/src/wsl_picker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 7f2a69eb68cb93..9983bd2cb76bf2 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -74,6 +74,7 @@ impl EventEmitter for Picker {} impl picker::PickerDelegate for WslPickerDelegate { type ListItem = ListItem; + type StableId = (); fn match_count(&self) -> usize { self.matches.len() From ebae5da619bc6b6ccb7e988fbe457bedd2e5fc98 Mon Sep 17 00:00:00 2001 From: Hendrik Sollich Date: Thu, 26 Feb 2026 23:14:00 +0100 Subject: [PATCH 07/15] Add stable id support for command palette --- crates/command_palette/src/command_palette.rs | 22 +++++++++++++++++-- crates/picker/src/picker.rs | 1 - 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 896d7ea6733eb3..ea3b580626f7d9 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -19,7 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; -use picker::Direction; +use picker::{Direction, stable_id::StableId}; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -366,9 +366,13 @@ impl CommandPaletteDelegate { } } +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct CommandPaletteStableId(SharedString); +impl StableId for CommandPaletteStableId {} + impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; - type StableId = (); + type StableId = CommandPaletteStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() @@ -515,6 +519,20 @@ impl PickerDelegate for CommandPaletteDelegate { }) } + fn match_stable_id(&self, ix: usize) -> Option { + let candidate_id = self.matches.get(ix)?.candidate_id; + let name = self.commands.get(candidate_id)?.name.clone(); + Some(CommandPaletteStableId(name.into())) + } + + fn find_match_by_stable_id(&self, stable_id: &CommandPaletteStableId) -> Option { + self.matches.iter().position(|m| { + self.commands + .get(m.candidate_id) + .is_some_and(|cmd| cmd.name == stable_id.0.as_ref()) + }) + } + fn finalize_update_matches( &mut self, query: String, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index e837afc98ef9a5..488e104475ebde 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1111,7 +1111,6 @@ mod tests { }); } - struct TestItem { id: String, text: String, From cad8cce6898b02fbe30b0bc615baa12884d8ce41 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 14:31:09 +0200 Subject: [PATCH 08/15] Remove a redundant trait --- crates/command_palette/src/command_palette.rs | 3 +-- crates/git_ui/src/branch_picker.rs | 4 +--- crates/outline/src/outline.rs | 4 +--- crates/picker/src/picker.rs | 4 +--- crates/picker/src/stable_id.rs | 21 ------------------- crates/recent_projects/src/recent_projects.rs | 3 --- 6 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 crates/picker/src/stable_id.rs diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index ea3b580626f7d9..12744d89a46172 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -19,7 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; -use picker::{Direction, stable_id::StableId}; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -368,7 +368,6 @@ impl CommandPaletteDelegate { #[derive(Clone, Eq, PartialEq, Hash, Debug)] pub struct CommandPaletteStableId(SharedString); -impl StableId for CommandPaletteStableId {} impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 4ead64eda03e8d..c96a6ef684f3f2 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -10,7 +10,7 @@ use gpui::{ InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; -use picker::{Picker, PickerDelegate, PickerEditorPosition, stable_id::StableId}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::Repository; use project::project_settings::ProjectSettings; use settings::Settings; @@ -372,8 +372,6 @@ pub enum BranchStableId { NewRemoteName { name: String, url: SharedString }, } -impl StableId for BranchStableId {} - #[derive(Clone, Copy, PartialEq)] enum BranchFilter { /// Show both local and remote branches. diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index a3082e44c74181..03ad38f40165e1 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -15,7 +15,7 @@ use gpui::{ }; use language::{Outline, OutlineItem}; use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate, stable_id::StableId}; +use picker::{Picker, PickerDelegate}; use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*}; @@ -39,8 +39,6 @@ impl OutlineStableId { } } -impl StableId for OutlineStableId {} - pub fn init(cx: &mut App) { cx.observe_new(OutlineView::register).detach(); zed_actions::outline::TOGGLE_OUTLINE diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 488e104475ebde..d15a28cd74e684 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1,10 +1,8 @@ mod head; pub mod highlighted_match_with_paths; pub mod popover_menu; -pub mod stable_id; use anyhow::Result; -use stable_id::StableId; use gpui::{ Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle, @@ -93,7 +91,7 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; - type StableId: StableId; + type StableId: Clone + Eq + std::hash::Hash + std::fmt::Debug + Send + 'static; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; diff --git a/crates/picker/src/stable_id.rs b/crates/picker/src/stable_id.rs deleted file mode 100644 index 220e43e1bcf3a9..00000000000000 --- a/crates/picker/src/stable_id.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::{fmt::Debug, hash::Hash}; - -use gpui::SharedString; - -/// Identifies a picker item in a stable way across match updates. -/// -/// Implementations should be cheap to clone and compare, as stable IDs -/// are used to preserve manual selections when the picker's matches are updated. -/// -/// # Performance Considerations -/// -/// - Cloning should be cheap (e.g., using reference-counted strings like `SharedString`) -/// - Equality comparison should avoid allocations -/// - Consider using structured data instead of formatted strings for comparison -pub trait StableId: Clone + Eq + Hash + Debug + Send + 'static {} - -/// Unit type implements StableId as a no-op default for delegates that don't need stable IDs -impl StableId for () {} - -/// SharedString implements StableId for string-based stable identifiers -impl StableId for SharedString {} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index db782937ba116a..e9aa6c18ade49f 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -30,7 +30,6 @@ use gpui::{ use picker::{ Picker, PickerDelegate, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, - stable_id::StableId, }; use project::{Worktree, git_store::Repository}; pub use remote_connections::RemoteSettings; @@ -700,8 +699,6 @@ pub enum RecentProjectsStableId { RecentProject(WorkspaceId), } -impl StableId for RecentProjectsStableId {} - impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; type StableId = RecentProjectsStableId; From 1b26643e579005348fb24b76ab02f57944e0a918 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 14:42:10 +0200 Subject: [PATCH 09/15] Post-merge fixes --- crates/recent_projects/src/recent_projects.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 02df6dc8689ed0..788b8c34b93003 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -762,6 +762,7 @@ impl EventEmitter for RecentProjectsDelegate {} pub enum RecentProjectsStableId { OpenFolder(WorktreeId), RecentProject(WorkspaceId), + OpenProject { candidate_id: usize }, } impl PickerDelegate for RecentProjectsDelegate { @@ -804,6 +805,11 @@ impl PickerDelegate for RecentProjectsDelegate { Some(RecentProjectsStableId::RecentProject(*workspace_id)) } ProjectPickerEntry::Header(_) => None, + ProjectPickerEntry::OpenProject(string_match) => { + Some(RecentProjectsStableId::OpenProject { + candidate_id: string_match.candidate_id, + }) + } } } From 22e597a4dfa688a5cdb9e8d85a42cdc15929b864 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 14:47:58 +0200 Subject: [PATCH 10/15] Remove redundant trait bounds --- crates/picker/src/picker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 10934f98569811..db52115d8c1bf5 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -91,7 +91,7 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; - type StableId: Clone + Eq + std::hash::Hash + std::fmt::Debug + Send + 'static; + type StableId; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; From 65fdf21d5174bc1444a88ecfe1ebdab4ac894d26 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 14:59:50 +0200 Subject: [PATCH 11/15] Properly handle a few more ids --- crates/project_symbols/Cargo.toml | 1 + crates/project_symbols/src/project_symbols.rs | 41 ++++++++++--------- crates/recent_projects/src/recent_projects.rs | 16 +++++--- .../theme_selector/src/icon_theme_selector.rs | 4 +- crates/theme_selector/src/theme_selector.rs | 4 +- 5 files changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index 83e3cb587d46a5..17461328b5ea71 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true editor.workspace = true fuzzy.workspace = true gpui.workspace = true +language.workspace = true ordered-float.workspace = true picker.workspace = true project.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index b1960fdacf9a31..4856091548b814 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -4,6 +4,7 @@ use gpui::{ App, Context, DismissEvent, Entity, HighlightStyle, ParentElement, StyledText, Task, TextStyle, WeakEntity, Window, relative, rems, }; +use language::{PointUtf16, Unclipped}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; @@ -105,9 +106,15 @@ impl ProjectSymbolsDelegate { } } +pub struct ProjectSymbolStableId { + path: SymbolLocation, + symbol_name: String, + symbol_range_start: Unclipped, +} + impl PickerDelegate for ProjectSymbolsDelegate { type ListItem = ListItem; - type StableId = SharedString; + type StableId = ProjectSymbolStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project symbols...".into() @@ -226,30 +233,24 @@ impl PickerDelegate for ProjectSymbolsDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option { let mat = self.matches.get(ix)?; let symbol = self.symbols.get(mat.candidate_id)?; - let path_str = match &symbol.path { - SymbolLocation::InProject(path) => format!("{:?}:{:?}", path.worktree_id, path.path), - SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), - }; - Some(format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start).into()) + Some(ProjectSymbolStableId { + path: symbol.path.clone(), + symbol_name: symbol.name.clone(), + symbol_range_start: symbol.range.start, + }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &ProjectSymbolStableId) -> Option { self.matches.iter().position(|mat| { - if let Some(symbol) = self.symbols.get(mat.candidate_id) { - let path_str = match &symbol.path { - SymbolLocation::InProject(path) => { - format!("{:?}:{:?}", path.worktree_id, path.path) - } - SymbolLocation::OutsideProject { abs_path, .. } => format!("{:?}", abs_path), - }; - format!("{}:{}:{:?}", path_str, symbol.name, symbol.range.start) - == stable_id.as_ref() - } else { - false - } + let Some(symbol) = self.symbols.get(mat.candidate_id) else { + return false; + }; + stable_id.path == symbol.path + && stable_id.symbol_name == symbol.name + && stable_id.symbol_range_start == symbol.range.start }) } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 788b8c34b93003..ced0ace29727fc 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -762,7 +762,7 @@ impl EventEmitter for RecentProjectsDelegate {} pub enum RecentProjectsStableId { OpenFolder(WorktreeId), RecentProject(WorkspaceId), - OpenProject { candidate_id: usize }, + OpenProject(WorkspaceId), } impl PickerDelegate for RecentProjectsDelegate { @@ -805,10 +805,9 @@ impl PickerDelegate for RecentProjectsDelegate { Some(RecentProjectsStableId::RecentProject(*workspace_id)) } ProjectPickerEntry::Header(_) => None, - ProjectPickerEntry::OpenProject(string_match) => { - Some(RecentProjectsStableId::OpenProject { - candidate_id: string_match.candidate_id, - }) + ProjectPickerEntry::OpenProject(mat) => { + let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; + Some(RecentProjectsStableId::OpenProject(*workspace_id)) } } } @@ -831,6 +830,13 @@ impl PickerDelegate for RecentProjectsDelegate { .workspaces .get(mat.candidate_id) .is_some_and(|(id, _, _, _)| id == workspace_id), + ( + ProjectPickerEntry::OpenProject(mat), + RecentProjectsStableId::OpenProject(workspace_id), + ) => self + .workspaces + .get(mat.candidate_id) + .is_some_and(|(id, _, _, _)| id == workspace_id), _ => false, }) } diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index d78ef1b0f6513c..c2f3d36a51fcca 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -91,7 +91,7 @@ impl IconThemeSelectorDelegate { .cmp(&b.appearance.is_light()) .then(a.name.cmp(&b.name)) }); - let matches: Vec = themes + let matches = themes .iter() .map(|meta| StringMatch { candidate_id: 0, @@ -99,7 +99,7 @@ impl IconThemeSelectorDelegate { positions: Default::default(), string: meta.name.to_string(), }) - .collect(); + .collect::>(); let selected_index = matches .iter() .position(|mat| mat.string == original_theme.0.as_ref()) diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 61f5bd5cf444ee..08ac664490790f 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -162,7 +162,7 @@ impl ThemeSelectorDelegate { .then(a.name.cmp(&b.name)) }); - let matches: Vec = themes + let matches = themes .iter() .map(|meta| StringMatch { candidate_id: 0, @@ -170,7 +170,7 @@ impl ThemeSelectorDelegate { positions: Default::default(), string: meta.name.to_string(), }) - .collect(); + .collect::>(); // The current theme is likely in this list, so default to first showing that. let selected_index = matches From da5b783ba7a1734a27ac4550d4ee6d12cf6e2dd4 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 15:18:37 +0200 Subject: [PATCH 12/15] Even less intrisive stable id concept --- .../src/agent_configuration/tool_picker.rs | 1 - crates/agent_ui/src/config_options.rs | 1 - .../agent_ui/src/language_model_selector.rs | 1 - crates/agent_ui/src/model_selector.rs | 1 - crates/agent_ui/src/profile_selector.rs | 1 - crates/agent_ui/src/slash_command_picker.rs | 1 - .../src/collab_panel/channel_modal.rs | 1 - .../src/collab_panel/contact_finder.rs | 1 - crates/command_palette/src/command_palette.rs | 9 +++-- crates/debugger_ui/src/attach_modal.rs | 1 - crates/debugger_ui/src/new_process_modal.rs | 1 - crates/dev_container/src/lib.rs | 2 - .../src/encoding_selector.rs | 1 - .../src/extension_version_selector.rs | 1 - crates/file_finder/src/file_finder.rs | 1 - crates/git_ui/src/branch_picker.rs | 20 +++++----- crates/git_ui/src/picker_prompt.rs | 1 - crates/git_ui/src/repository_selector.rs | 1 - crates/git_ui/src/stash_picker.rs | 1 - crates/git_ui/src/worktree_picker.rs | 1 - .../src/language_selector.rs | 1 - .../src/line_ending_selector.rs | 1 - crates/onboarding/src/base_keymap_picker.rs | 1 - .../open_path_prompt/src/open_path_prompt.rs | 1 - crates/outline/src/outline.rs | 11 ++--- crates/picker/src/picker.rs | 40 +++++++++---------- crates/project_symbols/src/project_symbols.rs | 12 +++--- crates/recent_projects/src/recent_projects.rs | 17 +++++--- crates/recent_projects/src/remote_servers.rs | 1 - crates/recent_projects/src/wsl_picker.rs | 1 - crates/repl/src/components/kernel_options.rs | 1 - crates/rules_library/src/rules_library.rs | 1 - .../src/settings_profile_selector.rs | 1 - .../settings_ui/src/components/font_picker.rs | 1 - .../src/components/icon_theme_picker.rs | 1 - .../src/components/ollama_model_picker.rs | 1 - .../src/components/theme_picker.rs | 1 - crates/snippets_ui/src/snippets_ui.rs | 1 - crates/storybook/src/stories/picker.rs | 1 - crates/tab_switcher/src/tab_switcher.rs | 1 - crates/tasks_ui/src/modal.rs | 1 - .../theme_selector/src/icon_theme_selector.rs | 12 +++--- crates/theme_selector/src/theme_selector.rs | 16 ++++---- .../src/toolchain_selector.rs | 1 - crates/vim/src/state.rs | 2 - 45 files changed, 73 insertions(+), 103 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 84ae5d19c3f5bc..be6fcb5bd2b5ee 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -154,7 +154,6 @@ impl ToolPickerDelegate { impl PickerDelegate for ToolPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_items.len() diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index ee9386305cf7fd..b8cf7e5d57921c 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -476,7 +476,6 @@ impl ConfigOptionPickerDelegate { impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 91412328bb4453..e6e72b3197b410 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -441,7 +441,6 @@ impl ModelMatcher { impl PickerDelegate for LanguageModelPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 6fb71182b37c1f..89ed3e490b33ca 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -198,7 +198,6 @@ impl ModelPickerDelegate { impl PickerDelegate for ModelPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 230794e7264738..1bad3c45e4dece 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -414,7 +414,6 @@ impl ProfilePickerDelegate { impl PickerDelegate for ProfilePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { "Search profiles…".into() diff --git a/crates/agent_ui/src/slash_command_picker.rs b/crates/agent_ui/src/slash_command_picker.rs index 7409f8c74f5bb8..0c3cf37599887f 100644 --- a/crates/agent_ui/src/slash_command_picker.rs +++ b/crates/agent_ui/src/slash_command_picker.rs @@ -73,7 +73,6 @@ where impl PickerDelegate for SlashCommandDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.filtered_commands.len() diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9e399f97e004de..3b3d974f3e50a9 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -257,7 +257,6 @@ pub struct ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search collaborator by username...".into() diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 8e10c94944c215..09543962a29def 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -70,7 +70,6 @@ impl Focusable for ContactFinder { impl PickerDelegate for ContactFinderDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.potential_contacts.len() diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 255960a7da4d5f..4afea885f434b1 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -1,6 +1,7 @@ mod persistence; use std::{ + any::Any, cmp::{self, Reverse}, collections::{HashMap, VecDeque}, sync::Arc, @@ -371,7 +372,6 @@ pub struct CommandPaletteStableId(SharedString); impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; - type StableId = CommandPaletteStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() @@ -522,13 +522,14 @@ impl PickerDelegate for CommandPaletteDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { let candidate_id = self.matches.get(ix)?.candidate_id; let name = self.commands.get(candidate_id)?.name.clone(); - Some(CommandPaletteStableId(name.into())) + Some(Box::new(CommandPaletteStableId(name.into()))) } - fn find_match_by_stable_id(&self, stable_id: &CommandPaletteStableId) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|m| { self.commands .get(m.candidate_id) diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 56e5305290fbce..6e537ae0c6e1db 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -136,7 +136,6 @@ impl ModalView for AttachModal {} impl PickerDelegate for AttachModalDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 83bc04a3500bd6..5b028671ed512a 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1206,7 +1206,6 @@ impl DebugDelegate { impl PickerDelegate for DebugDelegate { type ListItem = ui::ListItem; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 4dd8a2f606fc9c..7fcacf8004bef6 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -238,7 +238,6 @@ impl TemplatePickerDelegate { impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.matching_indices.len() @@ -422,7 +421,6 @@ impl FeaturePickerDelegate { impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.matching_indices.len() diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index feb5c2284e86e3..3954bf29a30a09 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -220,7 +220,6 @@ fn available_encodings() -> Vec<&'static Encoding> { impl PickerDelegate for EncodingSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Reopen with encoding...".into() diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 85628d453ca72b..6dd45954a71282 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -91,7 +91,6 @@ impl ExtensionVersionSelectorDelegate { impl PickerDelegate for ExtensionVersionSelectorDelegate { type ListItem = ui::ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select extension version...".into() diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index d9f16de00b380b..4302669ddc11c9 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1409,7 +1409,6 @@ fn full_path_budget( impl PickerDelegate for FileFinderDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project files...".into() diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 872607dc786ddf..0b7be3494ae239 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -14,7 +14,7 @@ use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::Repository; use project::project_settings::ProjectSettings; use settings::Settings; -use std::sync::Arc; +use std::{any::Any, sync::Arc}; use time::OffsetDateTime; use ui::{ Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, @@ -567,7 +567,6 @@ impl BranchListDelegate { impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; - type StableId = BranchStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { @@ -790,19 +789,22 @@ impl PickerDelegate for BranchListDelegate { .log_err(); }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { match self.matches.get(ix)? { - Entry::Branch { branch, .. } => Some(BranchStableId::Branch(branch.ref_name.clone())), - Entry::NewUrl { url } => Some(BranchStableId::NewUrl(url.clone())), - Entry::NewBranch { name } => Some(BranchStableId::NewBranch(name.clone())), - Entry::NewRemoteName { name, url } => Some(BranchStableId::NewRemoteName { + Entry::Branch { branch, .. } => { + Some(Box::new(BranchStableId::Branch(branch.ref_name.clone()))) + } + Entry::NewUrl { url } => Some(Box::new(BranchStableId::NewUrl(url.clone()))), + Entry::NewBranch { name } => Some(Box::new(BranchStableId::NewBranch(name.clone()))), + Entry::NewRemoteName { name, url } => Some(Box::new(BranchStableId::NewRemoteName { name: name.clone(), url: url.clone(), - }), + })), } } - fn find_match_by_stable_id(&self, stable_id: &BranchStableId) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches .iter() .position(|entry| match (entry, stable_id) { diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 6b89422bc21734..14daedda61ecc7 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -117,7 +117,6 @@ impl PickerPromptDelegate { impl PickerDelegate for PickerPromptDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.prompt.clone() diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 4dd06fbfcf7a8a..463540de90ce20 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -158,7 +158,6 @@ impl RepositorySelectorDelegate { impl PickerDelegate for RepositorySelectorDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.filtered_repositories.len() diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index d5545070cba091..e736dd806a3570 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -348,7 +348,6 @@ impl StashListDelegate { impl PickerDelegate for StashListDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a stash…".into() diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index b8827ed1b57944..91195e4eab4b02 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -599,7 +599,6 @@ async fn open_remote_worktree( impl PickerDelegate for WorktreeListDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select worktree…".into() diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index ceee4cc04f02c9..e5e6a2e264dbb9 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -197,7 +197,6 @@ impl LanguageSelectorDelegate { impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a language…".into() diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 3c5ce272a21a6c..504c327a349c97 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -114,7 +114,6 @@ impl LineEndingSelectorDelegate { impl PickerDelegate for LineEndingSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a line ending…".into() diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index fc99949837e0c4..63a2894a93504b 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -101,7 +101,6 @@ impl BaseKeymapSelectorDelegate { impl PickerDelegate for BaseKeymapSelectorDelegate { type ListItem = ui::ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a base keymap...".into() diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 4c15571ccbe1a6..fa609a63be1101 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -251,7 +251,6 @@ impl OpenPathPrompt { impl PickerDelegate for OpenPathDelegate { type ListItem = ui::ListItem; - type StableId = (); fn match_count(&self) -> usize { let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state { diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index a5a949ee4d679d..636dfb554d787f 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -1,3 +1,4 @@ +use std::any::Any; use std::ops::Range; use std::{cmp, sync::Arc}; @@ -264,7 +265,6 @@ impl OutlineViewDelegate { impl PickerDelegate for OutlineViewDelegate { type ListItem = ListItem; - type StableId = OutlineStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search buffer symbols...".into() @@ -362,16 +362,17 @@ impl PickerDelegate for OutlineViewDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { let mat = self.matches.get(ix)?; let outline_item = self.outline.items.get(mat.candidate_id)?; - Some(OutlineStableId::new( + Some(Box::new(OutlineStableId::new( outline_item.text.clone(), outline_item.depth, - )) + ))) } - fn find_match_by_stable_id(&self, stable_id: &OutlineStableId) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|mat| { self.outline .items diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index db52115d8c1bf5..44812affc5137c 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -14,7 +14,8 @@ use head::Head; use schemars::JsonSchema; use serde::Deserialize; use std::{ - cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, + any::Any, cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, + time::Duration, }; use theme::ThemeSettings; use ui::{ @@ -76,8 +77,7 @@ pub struct Picker { picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, - /// Tracks the stable ID of a manually selected item to preserve it across match updates. - manually_selected_stable_id: Option, + manually_selected_stable_id: Option>, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -91,7 +91,6 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; - type StableId; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; @@ -142,13 +141,11 @@ pub trait PickerDelegate: Sized + 'static { /// Returns a stable identifier for the match at the given index. /// If implemented, the picker will try to preserve manual selections /// across match updates by finding the same item again. - fn match_stable_id(&self, _ix: usize) -> Option { + fn match_stable_id(&self, _ix: usize) -> Option> { None } - /// Finds the index of a match with the given stable identifier. - /// Used in conjunction with `match_stable_id` to restore selections. - fn find_match_by_stable_id(&self, _stable_id: &Self::StableId) -> Option { + fn find_match_by_stable_id(&self, _stable_id: &dyn Any) -> Option { None } @@ -774,7 +771,7 @@ impl Picker { // Try to restore manually selected item let match_count = self.delegate.match_count(); let index = if let Some(stable_id) = &self.manually_selected_stable_id { - if let Some(ix) = self.delegate.find_match_by_stable_id(stable_id) { + if let Some(ix) = self.delegate.find_match_by_stable_id(stable_id.as_ref()) { // Found the manually selected item, restore selection self.delegate.set_selected_index(ix, window, cx); ix @@ -966,7 +963,6 @@ mod tests { impl PickerDelegate for SelectabilityDelegate { type ListItem = ui::ListItem; - type StableId = (); fn match_count(&self) -> usize { self.items.len() @@ -1146,7 +1142,6 @@ mod tests { impl PickerDelegate for TestDelegate { type ListItem = ListItem; - type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1169,14 +1164,15 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| SharedString::from(item.id.clone())) + .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|&item_ix| { self.items .get(item_ix) @@ -1478,7 +1474,6 @@ mod tests { impl PickerDelegate for BestMatchDelegate { type ListItem = ListItem; - type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1501,14 +1496,15 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| SharedString::from(item.id.clone())) + .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|&item_ix| { self.items .get(item_ix) @@ -1681,7 +1677,6 @@ mod tests { impl PickerDelegate for ReorderingDelegate { type ListItem = ListItem; - type StableId = SharedString; fn match_count(&self) -> usize { self.matches.len() @@ -1704,14 +1699,15 @@ mod tests { "Search...".into() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| SharedString::from(item.id.clone())) + .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|&item_ix| { self.items .get(item_ix) diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 4856091548b814..c6b9344baf9b24 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -9,7 +9,7 @@ use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; use settings::Settings; -use std::{cmp::Reverse, sync::Arc}; +use std::{any::Any, cmp::Reverse, sync::Arc}; use theme::{ActiveTheme, ThemeSettings}; use util::ResultExt; use workspace::{ @@ -114,7 +114,6 @@ pub struct ProjectSymbolStableId { impl PickerDelegate for ProjectSymbolsDelegate { type ListItem = ListItem; - type StableId = ProjectSymbolStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project symbols...".into() @@ -233,17 +232,18 @@ impl PickerDelegate for ProjectSymbolsDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { let mat = self.matches.get(ix)?; let symbol = self.symbols.get(mat.candidate_id)?; - Some(ProjectSymbolStableId { + Some(Box::new(ProjectSymbolStableId { path: symbol.path.clone(), symbol_name: symbol.name.clone(), symbol_range_start: symbol.range.start, - }) + })) } - fn find_match_by_stable_id(&self, stable_id: &ProjectSymbolStableId) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches.iter().position(|mat| { let Some(symbol) = self.symbols.get(mat.candidate_id) else { return false; diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index ced0ace29727fc..d71286f7daa6d2 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -5,6 +5,7 @@ mod remote_servers; mod ssh_config; use std::{ + any::Any, collections::HashSet, path::{Path, PathBuf}, sync::Arc, @@ -767,7 +768,6 @@ pub enum RecentProjectsStableId { impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; - type StableId = RecentProjectsStableId; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() @@ -793,26 +793,31 @@ impl PickerDelegate for RecentProjectsDelegate { self.filtered_entries.len() } - fn match_stable_id(&self, ix: usize) -> Option { + fn match_stable_id(&self, ix: usize) -> Option> { let entry = self.filtered_entries.get(ix)?; match entry { ProjectPickerEntry::OpenFolder { index, .. } => { let folder = self.open_folders.get(*index)?; - Some(RecentProjectsStableId::OpenFolder(folder.worktree_id)) + Some(Box::new(RecentProjectsStableId::OpenFolder( + folder.worktree_id, + ))) } ProjectPickerEntry::RecentProject(mat) => { let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; - Some(RecentProjectsStableId::RecentProject(*workspace_id)) + Some(Box::new(RecentProjectsStableId::RecentProject( + *workspace_id, + ))) } ProjectPickerEntry::Header(_) => None, ProjectPickerEntry::OpenProject(mat) => { let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; - Some(RecentProjectsStableId::OpenProject(*workspace_id)) + Some(Box::new(RecentProjectsStableId::OpenProject(*workspace_id))) } } } - fn find_match_by_stable_id(&self, stable_id: &Self::StableId) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.filtered_entries .iter() .position(|entry| match (entry, stable_id) { diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 8116e1e5661bde..4569492d4c73b6 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -201,7 +201,6 @@ impl DevContainerPickerDelegate { impl PickerDelegate for DevContainerPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.matching_candidates.len() diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 9983bd2cb76bf2..7f2a69eb68cb93 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -74,7 +74,6 @@ impl EventEmitter for Picker {} impl picker::PickerDelegate for WslPickerDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index abc45b046fc52b..ce68a4d30285fe 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -205,7 +205,6 @@ impl KernelPickerDelegate { impl PickerDelegate for KernelPickerDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index f531eb01152f6e..b4ff8033446410 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -199,7 +199,6 @@ impl EventEmitter for Picker {} impl PickerDelegate for RulePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_entries.len() diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 8a3260e4c667dc..7ca91e3767efb6 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -148,7 +148,6 @@ impl SettingsProfileSelectorDelegate { impl PickerDelegate for SettingsProfileSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc { "Select a settings profile...".into() diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index f75a2e80ccfff8..564d98c6d2d9a7 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -55,7 +55,6 @@ impl FontPickerDelegate { impl PickerDelegate for FontPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_fonts.len() diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index ab0cf7b9d3e049..f369a8207dc334 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -58,7 +58,6 @@ impl IconThemePickerDelegate { impl PickerDelegate for IconThemePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_themes.len() diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index 2c19deb7aefafc..268bf196bce3d0 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -61,7 +61,6 @@ impl OllamaModelPickerDelegate { impl PickerDelegate for OllamaModelPickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_models.len() diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index 697e27e1444158..a1f1339a7ad128 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -53,7 +53,6 @@ impl ThemePickerDelegate { impl PickerDelegate for ThemePickerDelegate { type ListItem = AnyElement; - type StableId = (); fn match_count(&self) -> usize { self.filtered_themes.len() diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index 88cf0cb23fcc61..c881d5276e6f96 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -198,7 +198,6 @@ impl ScopeSelectorDelegate { impl PickerDelegate for ScopeSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc { "Select snippet scope...".into() diff --git a/crates/storybook/src/stories/picker.rs b/crates/storybook/src/stories/picker.rs index 61e797ea96eaa5..fa65fd085dc158 100644 --- a/crates/storybook/src/stories/picker.rs +++ b/crates/storybook/src/stories/picker.rs @@ -32,7 +32,6 @@ impl Delegate { impl PickerDelegate for Delegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.candidates.len() diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 5a08a7d385a546..0fb13c85d21797 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -710,7 +710,6 @@ impl TabSwitcherDelegate { impl PickerDelegate for TabSwitcherDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search all tabs…".into() diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index d920b8ea51b10e..6b4fc21ef3ede0 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -248,7 +248,6 @@ const MAX_TAGS_LINE_LEN: usize = 30; impl PickerDelegate for TasksModalDelegate { type ListItem = ListItem; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index c2f3d36a51fcca..27fd6c1cdfffc0 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -6,7 +6,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use settings::{Settings as _, SettingsStore, update_settings_file}; -use std::sync::Arc; +use std::{any::Any, sync::Arc}; use theme::{ Appearance, IconThemeName, IconThemeSelection, SystemAppearance, ThemeMeta, ThemeRegistry, ThemeSettings, @@ -134,7 +134,6 @@ impl IconThemeSelectorDelegate { impl PickerDelegate for IconThemeSelectorDelegate { type ListItem = ui::ListItem; - type StableId = SharedString; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Icon Theme...".into() @@ -247,11 +246,14 @@ impl PickerDelegate for IconThemeSelectorDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { - self.matches.get(ix).map(|m| m.string.clone().into()) + fn match_stable_id(&self, ix: usize) -> Option> { + self.matches + .get(ix) + .map(|m| -> Box { Box::new(SharedString::from(m.string.clone())) }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches .iter() .position(|m| m.string == stable_id.as_ref()) diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 08ac664490790f..54b5565ffad29e 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -8,7 +8,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use settings::{Settings, SettingsStore, update_settings_file}; -use std::sync::Arc; +use std::{any::Any, sync::Arc}; use theme::{ Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry, ThemeSelection, ThemeSettings, @@ -334,7 +334,6 @@ fn retain_original_opposing_theme( impl PickerDelegate for ThemeSelectorDelegate { type ListItem = ui::ListItem; - type StableId = SharedString; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Theme...".into() @@ -442,13 +441,16 @@ impl PickerDelegate for ThemeSelectorDelegate { }) } - fn match_stable_id(&self, ix: usize) -> Option { - self.matches - .get(ix) - .map(|m| self.themes[m.candidate_id].name.to_string().into()) + fn match_stable_id(&self, ix: usize) -> Option> { + self.matches.get(ix).map(|m| -> Box { + Box::new(SharedString::from( + self.themes[m.candidate_id].name.to_string(), + )) + }) } - fn find_match_by_stable_id(&self, stable_id: &SharedString) -> Option { + fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { + let stable_id = stable_id.downcast_ref::()?; self.matches .iter() .position(|m| self.themes[m.candidate_id].name == stable_id.as_ref()) diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index f517e6fcce2520..7447975aa835c7 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -897,7 +897,6 @@ impl ToolchainSelectorDelegate { impl PickerDelegate for ToolchainSelectorDelegate { type ListItem = ListItem; - type StableId = (); fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone() diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 1309557e49f1e5..85bc6991d3ece2 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1214,7 +1214,6 @@ pub struct RegistersViewDelegate { impl PickerDelegate for RegistersViewDelegate { type ListItem = Div; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() @@ -1429,7 +1428,6 @@ pub struct MarksViewDelegate { impl PickerDelegate for MarksViewDelegate { type ListItem = Div; - type StableId = (); fn match_count(&self) -> usize { self.matches.len() From 89daa8611bdd6b68652d46aa3c6ef84361da52df Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 15:26:12 +0200 Subject: [PATCH 13/15] Tidy up --- crates/git_ui/src/branch_picker.rs | 17 ++++++++--------- crates/recent_projects/src/recent_projects.rs | 16 ++++++---------- .../theme_selector/src/icon_theme_selector.rs | 2 +- crates/theme_selector/src/theme_selector.rs | 4 ++-- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 0b7be3494ae239..64d03a2e2f223b 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -789,18 +789,17 @@ impl PickerDelegate for BranchListDelegate { .log_err(); }) } + fn match_stable_id(&self, ix: usize) -> Option> { - match self.matches.get(ix)? { - Entry::Branch { branch, .. } => { - Some(Box::new(BranchStableId::Branch(branch.ref_name.clone()))) - } - Entry::NewUrl { url } => Some(Box::new(BranchStableId::NewUrl(url.clone()))), - Entry::NewBranch { name } => Some(Box::new(BranchStableId::NewBranch(name.clone()))), - Entry::NewRemoteName { name, url } => Some(Box::new(BranchStableId::NewRemoteName { + Some(Box::new(match self.matches.get(ix)? { + Entry::Branch { branch, .. } => BranchStableId::Branch(branch.ref_name.clone()), + Entry::NewUrl { url } => BranchStableId::NewUrl(url.clone()), + Entry::NewBranch { name } => BranchStableId::NewBranch(name.clone()), + Entry::NewRemoteName { name, url } => BranchStableId::NewRemoteName { name: name.clone(), url: url.clone(), - })), - } + }, + })) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index d71286f7daa6d2..d6b7028c042e91 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -795,25 +795,21 @@ impl PickerDelegate for RecentProjectsDelegate { fn match_stable_id(&self, ix: usize) -> Option> { let entry = self.filtered_entries.get(ix)?; - match entry { + Some(Box::new(match entry { ProjectPickerEntry::OpenFolder { index, .. } => { let folder = self.open_folders.get(*index)?; - Some(Box::new(RecentProjectsStableId::OpenFolder( - folder.worktree_id, - ))) + RecentProjectsStableId::OpenFolder(folder.worktree_id) } ProjectPickerEntry::RecentProject(mat) => { let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; - Some(Box::new(RecentProjectsStableId::RecentProject( - *workspace_id, - ))) + RecentProjectsStableId::RecentProject(*workspace_id) } - ProjectPickerEntry::Header(_) => None, ProjectPickerEntry::OpenProject(mat) => { let (workspace_id, _, _, _) = self.workspaces.get(mat.candidate_id)?; - Some(Box::new(RecentProjectsStableId::OpenProject(*workspace_id))) + RecentProjectsStableId::OpenProject(*workspace_id) } - } + ProjectPickerEntry::Header(_) => return None, + })) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 27fd6c1cdfffc0..c44bd4358f9c20 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -249,7 +249,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { fn match_stable_id(&self, ix: usize) -> Option> { self.matches .get(ix) - .map(|m| -> Box { Box::new(SharedString::from(m.string.clone())) }) + .map(|m| Box::new(SharedString::from(m.string.clone())) as Box<_>) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 54b5565ffad29e..f15b77b1394d68 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -442,10 +442,10 @@ impl PickerDelegate for ThemeSelectorDelegate { } fn match_stable_id(&self, ix: usize) -> Option> { - self.matches.get(ix).map(|m| -> Box { + self.matches.get(ix).map(|m| { Box::new(SharedString::from( self.themes[m.candidate_id].name.to_string(), - )) + )) as Box<_> }) } From 0170f6eb26d6b27058621ad1453d7b4b20c24689 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 15:41:42 +0200 Subject: [PATCH 14/15] Use a better name --- crates/picker/src/picker.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 44812affc5137c..7bd992c2307cf5 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -446,7 +446,7 @@ impl Picker { /// Use this for user-driven selections (keyboard navigation, mouse clicks) where you want /// the user's choice to be maintained as they continue typing. For programmatic selections /// that should not persist, use `set_selected_index` instead. - pub fn select_index_sticky( + pub fn set_selected_manually( &mut self, ix: usize, fallback_direction: Option, @@ -536,7 +536,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == count - 1 { 0 } else { index + 1 }; - self.select_index_sticky(ix, Some(Direction::Down), true, window, cx); + self.set_selected_manually(ix, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -563,7 +563,7 @@ impl Picker { if count > 0 { let index = self.delegate.selected_index(); let ix = if index == 0 { count - 1 } else { index - 1 }; - self.select_index_sticky(ix, Some(Direction::Up), true, window, cx); + self.set_selected_manually(ix, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -580,7 +580,7 @@ impl Picker { ) { let count = self.delegate.match_count(); if count > 0 { - self.select_index_sticky(0, Some(Direction::Down), true, window, cx); + self.set_selected_manually(0, Some(Direction::Down), true, window, cx); cx.notify(); } } @@ -588,7 +588,7 @@ impl Picker { fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { let count = self.delegate.match_count(); if count > 0 { - self.select_index_sticky(count - 1, Some(Direction::Up), true, window, cx); + self.set_selected_manually(count - 1, Some(Direction::Up), true, window, cx); cx.notify(); } } @@ -597,7 +597,7 @@ impl Picker { let count = self.delegate.match_count(); let index = self.delegate.selected_index(); let new_index = if index + 1 == count { 0 } else { index + 1 }; - self.select_index_sticky(new_index, Some(Direction::Down), true, window, cx); + self.set_selected_manually(new_index, Some(Direction::Down), true, window, cx); cx.notify(); } @@ -673,7 +673,7 @@ impl Picker { if !self.delegate.can_select(ix, window, cx) { return; } - self.select_index_sticky(ix, None, false, window, cx); + self.set_selected_manually(ix, None, false, window, cx); self.do_confirm(secondary, window, cx) } @@ -1266,7 +1266,7 @@ mod tests { // Navigate to third item (cherry) picker .update(cx, |picker, window, cx| { - picker.select_index_sticky(2, None, true, window, cx); + picker.set_selected_manually(2, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), 2); }) .unwrap(); @@ -1315,7 +1315,7 @@ mod tests { // Navigate to box (index 1) picker .update(cx, |picker, window, cx| { - picker.select_index_sticky(1, None, true, window, cx); + picker.set_selected_manually(1, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), 1); }) .unwrap(); @@ -1378,7 +1378,7 @@ mod tests { .iter() .position(|&ix| picker.delegate.items[ix].id == "d") .unwrap(); - picker.select_index_sticky(door_index, None, true, window, cx); + picker.set_selected_manually(door_index, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), door_index); }) .unwrap(); @@ -1620,7 +1620,7 @@ mod tests { .iter() .position(|&ix| picker.delegate.items[ix].id == "a") .unwrap(); - picker.select_index_sticky(something_index, None, true, window, cx); + picker.set_selected_manually(something_index, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), something_index); }) .unwrap(); @@ -1811,7 +1811,7 @@ mod tests { // Select somethingNotifier (index 0 in matches) picker .update(cx, |picker, window, cx| { - picker.select_index_sticky(0, None, true, window, cx); + picker.set_selected_manually(0, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), 0); }) .unwrap(); @@ -1906,7 +1906,7 @@ mod tests { .iter() .position(|&ix| picker.delegate.items[ix].id == "a") .unwrap(); - picker.select_index_sticky(something_index, None, true, window, cx); + picker.set_selected_manually(something_index, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), something_index); }) .unwrap(); @@ -1967,7 +1967,7 @@ mod tests { // Navigate to cherry (index 2) using sticky selection picker .update(cx, |picker, window, cx| { - picker.select_index_sticky(2, None, true, window, cx); + picker.set_selected_manually(2, None, true, window, cx); assert_eq!(picker.delegate.selected_index(), 2); }) .unwrap(); From 94665c46944f818aa29a490eb2f78fccc4c5186b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 19 Mar 2026 15:43:00 +0200 Subject: [PATCH 15/15] On confirmation, set the item as manually selected --- crates/picker/src/picker.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 7bd992c2307cf5..7bae86c6553609 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -680,7 +680,7 @@ impl Picker { fn do_confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context) { if let Some(update_query) = self.delegate.confirm_update_query(window, cx) { self.set_query(&update_query, window, cx); - self.set_selected_index(0, Some(Direction::Down), false, window, cx); + self.set_selected_manually(0, Some(Direction::Down), false, window, cx); } else { self.delegate.confirm(secondary, window, cx) } @@ -1168,7 +1168,7 @@ mod tests { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) + .map(|item| Box::new(SharedString::from(item.id.clone())) as Box<_>) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { @@ -1500,7 +1500,7 @@ mod tests { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) + .map(|item| Box::new(SharedString::from(item.id.clone())) as Box<_>) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option { @@ -1703,7 +1703,7 @@ mod tests { self.matches .get(ix) .and_then(|&item_ix| self.items.get(item_ix)) - .map(|item| -> Box { Box::new(SharedString::from(item.id.clone())) }) + .map(|item| Box::new(SharedString::from(item.id.clone())) as Box<_>) } fn find_match_by_stable_id(&self, stable_id: &dyn Any) -> Option {