Skip to main content

gpui_ai/
command_search.rs

1//! Stable-ID command search composed over gpui-component's native command palette.
2
3use gpui_base::StyledExt as _;
4use std::sync::Arc;
5
6use gpui::{
7    App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
8    InteractiveElement as _, IntoElement, ParentElement as _, Render, Role, SharedString,
9    StatefulInteractiveElement as _, Styled as _, Window, div, prelude::FluentBuilder as _,
10};
11use gpui_component::{
12    ActiveTheme as _, Disableable as _, ElementExt as _, IconName, IndexPath,
13    command::{Command, CommandItem, CommandState},
14    h_flex,
15    label::Label,
16    v_flex,
17};
18
19use crate::theme::SemanticStyledExt as _;
20
21/// One application-owned command-search item.
22///
23/// IDs must be stable and unique inside a snapshot. Labels may be duplicated:
24/// selection and emitted events always use `id`.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct CommandSearchItem {
27    id: SharedString,
28    title: SharedString,
29    subtitle: Option<SharedString>,
30    keywords: Arc<[SharedString]>,
31    shortcut: Option<SharedString>,
32    disabled: bool,
33}
34
35impl CommandSearchItem {
36    /// Create an enabled item with stable identity and a visible title.
37    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
38        Self {
39            id: id.into(),
40            title: title.into(),
41            subtitle: None,
42            keywords: Arc::from([]),
43            shortcut: None,
44            disabled: false,
45        }
46    }
47
48    /// Set secondary descriptive text, which also participates in filtering.
49    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
50        self.subtitle = Some(subtitle.into());
51        self
52    }
53
54    /// Replace the extra case-insensitive search terms.
55    pub fn keywords<I, S>(mut self, keywords: I) -> Self
56    where
57        I: IntoIterator<Item = S>,
58        S: Into<SharedString>,
59    {
60        self.keywords = keywords.into_iter().map(Into::into).collect();
61        self
62    }
63
64    /// Set the visible shortcut hint.
65    pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
66        self.shortcut = Some(shortcut.into());
67        self
68    }
69
70    /// Set whether the item is visible but unavailable to every activation path.
71    pub fn disabled(mut self, disabled: bool) -> Self {
72        self.disabled = disabled;
73        self
74    }
75
76    /// Return the stable application identity.
77    pub fn id(&self) -> &SharedString {
78        &self.id
79    }
80
81    /// Return the visible title.
82    pub fn title(&self) -> &SharedString {
83        &self.title
84    }
85
86    /// Return the optional secondary description.
87    pub fn subtitle_text(&self) -> Option<&SharedString> {
88        self.subtitle.as_ref()
89    }
90
91    /// Return the extra search terms.
92    pub fn keyword_terms(&self) -> &[SharedString] {
93        &self.keywords
94    }
95
96    /// Return the optional shortcut hint.
97    pub fn shortcut_text(&self) -> Option<&SharedString> {
98        self.shortcut.as_ref()
99    }
100
101    /// Return whether activation is disabled.
102    pub fn is_disabled(&self) -> bool {
103        self.disabled
104    }
105
106    fn accessibility_label(&self) -> SharedString {
107        match (&self.subtitle, &self.shortcut) {
108            (Some(subtitle), Some(shortcut)) => {
109                format!("{}, {}, shortcut {}", self.title, subtitle, shortcut).into()
110            }
111            (Some(subtitle), None) => format!("{}, {}", self.title, subtitle).into(),
112            (None, Some(shortcut)) => format!("{}, shortcut {}", self.title, shortcut).into(),
113            (None, None) => self.title.clone(),
114        }
115    }
116}
117
118/// A typed application intent emitted by [`CommandSearch`].
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum CommandSearchEvent {
121    /// The native command query changed.
122    QueryChanged {
123        /// Command-search component identity.
124        id: SharedString,
125        /// Latest query text.
126        query: SharedString,
127    },
128    /// An enabled item was confirmed by pointer, keyboard, or accessibility.
129    Selected {
130        /// Command-search component identity.
131        id: SharedString,
132        /// Stable application item identity.
133        item_id: SharedString,
134    },
135    /// Escape requested that the surrounding surface dismiss the search.
136    Dismissed {
137        /// Command-search component identity.
138        id: SharedString,
139    },
140}
141
142fn retained_selection(
143    previous: Option<&SharedString>,
144    items: &[CommandSearchItem],
145) -> Option<SharedString> {
146    previous
147        .and_then(|id| items.iter().find(|item| item.id == *id && !item.disabled))
148        .or_else(|| items.iter().find(|item| !item.disabled))
149        .map(|item| item.id.clone())
150}
151
152fn command_search_frame(id: &SharedString) -> gpui::Stateful<gpui::Div> {
153    div()
154        .id(id.clone())
155        .accessibility_id(format!("command-search.{id}"))
156        .debug_selector({
157            let id = id.clone();
158            move || format!("command-search-{id}")
159        })
160        .role(Role::Search)
161        .aria_label("Command search")
162        .w_full()
163        .min_w_0()
164        .overflow_hidden()
165}
166
167fn row_content(
168    search_id: SharedString,
169    item: CommandSearchItem,
170    cx: &mut App,
171) -> gpui::Stateful<gpui::Div> {
172    let tokens = cx.theme().semantic_tokens();
173    let accessibility_id = format!("command-search.{search_id}.item.{}", item.id);
174    let debug_item_id = item.id.clone();
175    let content_id = ElementId::from((
176        ElementId::from((ElementId::from(search_id), item.id.clone())),
177        "content",
178    ));
179    let label = item.accessibility_label();
180
181    h_flex()
182        .id(content_id)
183        .accessibility_id(accessibility_id)
184        .debug_selector(move || format!("command-search-item-{debug_item_id}"))
185        // Pinned Command supplies the sole option node and activation path.
186        .role(Role::Label)
187        .aria_label(label)
188        .w_full()
189        .min_w_0()
190        .gap(tokens.spacing.sm)
191        .child(
192            v_flex()
193                .min_w_0()
194                .flex_1()
195                .gap(tokens.spacing.xxs)
196                .child(Label::new(item.title.clone()).text_token(tokens.typography.sm))
197                .when_some(item.subtitle.clone(), |this, subtitle| {
198                    this.child(
199                        Label::new(subtitle)
200                            .text_token(tokens.typography.xs)
201                            .text_color(cx.theme().muted_foreground),
202                    )
203                }),
204        )
205        .when_some(item.shortcut.clone(), |this, shortcut| {
206            this.child(
207                div()
208                    .flex_none()
209                    .px(tokens.spacing.xs)
210                    .py(tokens.spacing.xxs)
211                    .rounded(tokens.radius.sm)
212                    .border_1()
213                    .border_color(cx.theme().border)
214                    .bg(cx.theme().muted.opacity(0.4))
215                    .text_token(tokens.typography.xs)
216                    .font_family(cx.theme().mono_font_family.clone())
217                    .text_color(cx.theme().muted_foreground)
218                    .child(Label::new(shortcut)),
219            )
220        })
221}
222
223fn upstream_item(search_id: &SharedString, item: &CommandSearchItem) -> CommandItem {
224    let row_item = item.clone();
225    let row_search_id = search_id.clone();
226    let mut search_terms =
227        Vec::with_capacity(item.keywords.len() + usize::from(item.subtitle.is_some()));
228    if let Some(subtitle) = &item.subtitle {
229        search_terms.push(subtitle.clone());
230    }
231    search_terms.extend(item.keywords.iter().cloned());
232
233    CommandItem::new()
234        .label(item.title.clone())
235        .keywords(search_terms)
236        .child(move |_, cx| row_content(row_search_id.clone(), row_item.clone(), cx))
237        .disabled(item.disabled)
238}
239
240/// A controlled stable-ID adapter around gpui-component's native command palette.
241///
242/// The application replaces immutable item snapshots and handles typed events.
243/// This entity owns one upstream [`CommandState`], which remains the sole owner
244/// of native text editing, focus, filtering, navigation, and virtual-list state.
245///
246/// ```no_run
247/// # use gpui_ai::prelude::*;
248/// # use gpui::AppContext;
249/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
250/// let search = cx.new(|cx| CommandSearch::new("workspace-search", window, cx));
251/// search.update(cx, |search, cx| {
252///     search.set_items(
253///         [CommandSearchItem::new("calendar", "Open calendar")
254///             .shortcut("Ctrl+K")],
255///         window,
256///         cx,
257///     );
258/// });
259/// # }
260/// ```
261pub struct CommandSearch {
262    /// Styles the caller put on this component, applied to its own frame.
263    ///
264    /// Last, so a caller outranks the component's defaults - the same rule the
265    /// builder components follow. A wrapper `div` cannot stand in for this:
266    /// a background, a border, or an ink set on a wrapper paints around the
267    /// component rather than on it.
268    style: gpui::StyleRefinement,
269    id: SharedString,
270    items: Arc<[CommandSearchItem]>,
271    upstream_items: Arc<[CommandItem]>,
272    state: Entity<CommandState>,
273    selected_id: Option<SharedString>,
274    last_emitted_query: SharedString,
275    items_revision: u64,
276    applied_selection_revision: u64,
277}
278
279impl CommandSearch {
280    /// Create an empty command search with native input and focus state.
281    pub fn new(id: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
282        Self {
283            style: gpui::StyleRefinement::default(),
284            id: id.into(),
285            items: Arc::from([]),
286            upstream_items: Arc::from([]),
287            state: cx.new(|cx| CommandState::new(window, cx)),
288            selected_id: None,
289            last_emitted_query: "".into(),
290            items_revision: 0,
291            applied_selection_revision: 0,
292        }
293    }
294
295    /// Replace the controlled item snapshot and retain selection by stable ID.
296    pub fn set_items(
297        &mut self,
298        items: impl IntoIterator<Item = CommandSearchItem>,
299        _window: &mut Window,
300        cx: &mut Context<Self>,
301    ) {
302        let items: Arc<[CommandSearchItem]> = items.into_iter().collect();
303        if self.items == items {
304            return;
305        }
306
307        self.selected_id = retained_selection(self.selected_id.as_ref(), &items);
308        self.upstream_items = items
309            .iter()
310            .map(|item| upstream_item(&self.id, item))
311            .collect();
312        self.items = items;
313        self.items_revision = self.items_revision.wrapping_add(1);
314        cx.notify();
315    }
316
317    /// Replace the native query and emit [`CommandSearchEvent::QueryChanged`]
318    /// when it actually changes.
319    pub fn set_query(
320        &mut self,
321        query: impl Into<SharedString>,
322        window: &mut Window,
323        cx: &mut Context<Self>,
324    ) {
325        let query = query.into();
326        let changed = self.query(cx) != query;
327        self.state
328            .update(cx, |state, cx| state.set_query(query.clone(), window, cx));
329        if changed {
330            self.emit_query_changed(query, cx);
331        }
332    }
333
334    /// Return the native input's current query.
335    pub fn query(&self, cx: &App) -> SharedString {
336        self.state.read(cx).query(cx)
337    }
338
339    /// Move focus to the native command input.
340    pub fn focus(&self, window: &mut Window, cx: &mut App) {
341        self.state.update(cx, |state, cx| state.focus(window, cx));
342    }
343
344    fn item_id_at(items: &[CommandSearchItem], index: IndexPath) -> Option<SharedString> {
345        (index.section == 0)
346            .then(|| items.get(index.row))
347            .flatten()
348            .map(|item| item.id.clone())
349    }
350
351    fn index_for_id(&self, id: &SharedString) -> Option<IndexPath> {
352        self.items
353            .iter()
354            .position(|item| item.id == *id && !item.disabled)
355            .map(IndexPath::new)
356    }
357
358    fn current_item_id_from_installed(
359        &self,
360        installed_revision: u64,
361        installed_items: &[CommandSearchItem],
362        index: IndexPath,
363    ) -> Option<SharedString> {
364        if installed_revision > self.items_revision {
365            return None;
366        }
367        let item_id = Self::item_id_at(installed_items, index)?;
368        self.items
369            .iter()
370            .find(|item| item.id == item_id && !item.disabled)
371            .map(|item| item.id.clone())
372    }
373
374    fn on_select(
375        &mut self,
376        installed_revision: u64,
377        installed_items: &[CommandSearchItem],
378        index: IndexPath,
379    ) {
380        if let Some(item_id) =
381            self.current_item_id_from_installed(installed_revision, installed_items, index)
382        {
383            self.selected_id = Some(item_id);
384        }
385    }
386
387    fn on_confirm(
388        &mut self,
389        installed_revision: u64,
390        installed_items: &[CommandSearchItem],
391        index: IndexPath,
392        cx: &mut Context<Self>,
393    ) {
394        let Some(item_id) =
395            self.current_item_id_from_installed(installed_revision, installed_items, index)
396        else {
397            return;
398        };
399        self.confirm_id(item_id, cx);
400    }
401
402    fn confirm_id(&mut self, item_id: SharedString, cx: &mut Context<Self>) {
403        let Some(item) = self
404            .items
405            .iter()
406            .find(|item| item.id == item_id && !item.disabled)
407        else {
408            return;
409        };
410
411        self.selected_id = Some(item.id.clone());
412        cx.emit(CommandSearchEvent::Selected {
413            id: self.id.clone(),
414            item_id: item.id.clone(),
415        });
416    }
417
418    fn on_query(&mut self, query: &str, cx: &mut Context<Self>) {
419        if self.query(cx).as_ref() != query {
420            return;
421        }
422        self.emit_query_changed(query.to_owned().into(), cx);
423    }
424
425    fn emit_query_changed(&mut self, query: SharedString, cx: &mut Context<Self>) {
426        if self.last_emitted_query == query {
427            return;
428        }
429        self.last_emitted_query = query.clone();
430        cx.emit(CommandSearchEvent::QueryChanged {
431            id: self.id.clone(),
432            query,
433        });
434    }
435
436    fn dismiss(&mut self, cx: &mut Context<Self>) {
437        cx.emit(CommandSearchEvent::Dismissed {
438            id: self.id.clone(),
439        });
440    }
441}
442
443impl EventEmitter<CommandSearchEvent> for CommandSearch {}
444
445impl Focusable for CommandSearch {
446    fn focus_handle(&self, cx: &App) -> FocusHandle {
447        self.state.focus_handle(cx)
448    }
449}
450
451impl gpui::Styled for CommandSearch {
452    fn style(&mut self) -> &mut gpui::StyleRefinement {
453        &mut self.style
454    }
455}
456
457impl Render for CommandSearch {
458    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
459        let id = self.id.clone();
460        let tokens = cx.theme().semantic_tokens();
461        let query_owner = cx.weak_entity();
462        let select_owner = cx.weak_entity();
463        let confirm_owner = cx.weak_entity();
464        let cancel_owner = cx.weak_entity();
465        let selection_owner = cx.weak_entity();
466        let revision = self.items_revision;
467        let installed_items = self.items.clone();
468        let select_items = installed_items.clone();
469        let confirm_items = installed_items;
470        let should_restore_selection = self.applied_selection_revision != revision;
471        let command = Command::new(&self.state)
472            .items(self.upstream_items.iter().cloned())
473            .placeholder("Search commands")
474            .max_h(tokens.spacing.xxl * 10.)
475            .empty({
476                let id = id.clone();
477                move |state, _, cx| {
478                    let (message, selector): (SharedString, &'static str) =
479                        if state.query(cx).is_empty() {
480                            (
481                                "No commands available".into(),
482                                "command-search-empty-catalog",
483                            )
484                        } else {
485                            ("No matching commands".into(), "command-search-no-results")
486                        };
487                    div()
488                        .id(ElementId::from((ElementId::from(id.clone()), "empty")))
489                        .debug_selector(move || selector.to_owned())
490                        .role(Role::Status)
491                        .aria_label(message.clone())
492                        .child(crate::surface::empty_state(
493                            IconName::Search,
494                            message,
495                            None,
496                            cx,
497                        ))
498                }
499            })
500            .on_query(move |query, _, cx| {
501                _ = query_owner.update(cx, |search, cx| search.on_query(query, cx));
502            })
503            .on_select(move |index, _, cx| {
504                _ = select_owner.update(cx, |search, _| {
505                    search.on_select(revision, &select_items, index);
506                });
507            })
508            .on_confirm(move |index, _, cx| {
509                _ = confirm_owner.update(cx, |search, cx| {
510                    search.on_confirm(revision, &confirm_items, index, cx);
511                });
512            })
513            .on_cancel(move |_, cx| {
514                _ = cancel_owner.update(cx, |search, cx| search.dismiss(cx));
515            });
516
517        command_search_frame(&self.id)
518            .on_prepaint(move |_, window, cx| {
519                if !should_restore_selection {
520                    return;
521                }
522                _ = selection_owner.update(cx, |search, cx| {
523                    if search.items_revision != revision
524                        || search.applied_selection_revision == revision
525                    {
526                        return;
527                    }
528                    search.applied_selection_revision = revision;
529                    let selected_index = search
530                        .selected_id
531                        .as_ref()
532                        .and_then(|id| search.index_for_id(id));
533                    search.state.update(cx, |state, cx| {
534                        let current_index = state.selected_index();
535                        state.set_selected_index(selected_index, window, cx);
536                        if selected_index.is_some()
537                            && state.selected_index().is_none()
538                            && current_index.is_some()
539                        {
540                            state.set_selected_index(current_index, window, cx);
541                        }
542                    });
543                });
544            })
545            .child(command)
546            .refine_style(&self.style)
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use std::{
553        cell::{Cell, RefCell},
554        rc::Rc,
555        sync::{Arc, Mutex},
556    };
557
558    use gpui::{
559        AnyElement, App, AppContext as _, Bounds, Context, Element, Entity, GlobalElementId,
560        InspectorElementId, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, LayoutId, Modifiers,
561        Pixels, Render, Role, Subscription, TestAppContext, VisualTestContext, Window, accesskit,
562        canvas, point, px, size,
563    };
564
565    use super::{
566        CommandSearch, CommandSearchEvent, CommandSearchItem, command_search_frame,
567        retained_selection, row_content,
568    };
569
570    #[test]
571    fn selection_is_retained_by_id_after_replacement_and_reorder() {
572        let replacement = vec![
573            CommandSearchItem::new("third", "Third"),
574            CommandSearchItem::new("selected", "Selected"),
575            CommandSearchItem::new("first", "First"),
576        ];
577
578        assert_eq!(
579            retained_selection(Some(&"selected".into()), &replacement),
580            Some("selected".into())
581        );
582        assert_eq!(
583            retained_selection(Some(&"removed".into()), &replacement),
584            Some("third".into())
585        );
586    }
587
588    #[test]
589    fn disabled_items_are_skipped_when_choosing_a_replacement_selection() {
590        let replacement = vec![
591            CommandSearchItem::new("disabled", "Disabled").disabled(true),
592            CommandSearchItem::new("enabled", "Enabled"),
593        ];
594
595        assert_eq!(
596            retained_selection(None, &replacement),
597            Some("enabled".into())
598        );
599    }
600
601    struct CapturedRow {
602        role: Option<Role>,
603        node: accesskit::Node,
604    }
605
606    struct RowA11yProbe {
607        disabled: bool,
608        captured: Arc<Mutex<Option<CapturedRow>>>,
609    }
610
611    impl Render for RowA11yProbe {
612        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
613            let disabled = self.disabled;
614            let captured = self.captured.clone();
615            canvas(
616                move |_, _, cx| {
617                    let row = row_content(
618                        "probe".into(),
619                        CommandSearchItem::new("report", "Open report")
620                            .subtitle("Supplier pricing")
621                            .shortcut("Ctrl+R")
622                            .disabled(disabled),
623                        cx,
624                    );
625                    let role = row.a11y_role();
626                    let mut node = accesskit::Node::new(Role::Unknown);
627                    row.write_a11y_info(&mut node);
628                    *captured.lock().expect("row capture should be available") =
629                        Some(CapturedRow { role, node });
630                },
631                |_, _, _, _| {},
632            )
633        }
634    }
635
636    fn capture_row(disabled: bool, cx: &mut TestAppContext) -> CapturedRow {
637        cx.update(crate::init);
638        let captured = Arc::new(Mutex::new(None));
639        let result = captured.clone();
640        let (_, cx) = cx.add_window_view(move |_, _| RowA11yProbe { disabled, captured });
641        cx.update(|window, cx| window.draw(cx).clear(cx));
642        result
643            .lock()
644            .expect("row capture should be available")
645            .take()
646            .expect("row should be captured")
647    }
648
649    #[gpui::test]
650    fn custom_rows_are_named_noninteractive_labels(cx: &mut TestAppContext) {
651        let enabled = capture_row(false, cx);
652        assert_eq!(enabled.role, Some(Role::Label));
653        assert_eq!(
654            enabled.node.label(),
655            Some("Open report, Supplier pricing, shortcut Ctrl+R")
656        );
657        assert!(!enabled.node.supports_action(accesskit::Action::Click));
658
659        let disabled = capture_row(true, cx);
660        assert_eq!(disabled.role, Some(Role::Label));
661        assert_eq!(
662            disabled.node.label(),
663            Some("Open report, Supplier pricing, shortcut Ctrl+R")
664        );
665        assert!(!disabled.node.supports_action(accesskit::Action::Click));
666    }
667
668    #[test]
669    fn production_frame_exposes_a_named_search_landmark() {
670        let frame = command_search_frame(&"palette".into()).into_element();
671        let mut node = accesskit::Node::new(Role::Unknown);
672        frame.write_a11y_info(&mut node);
673
674        assert_eq!(frame.a11y_role(), Some(Role::Search));
675        assert_eq!(node.label(), Some("Command search"));
676    }
677
678    struct Harness {
679        search: Entity<CommandSearch>,
680        events: Rc<RefCell<Vec<CommandSearchEvent>>>,
681        _subscription: Subscription,
682    }
683
684    struct PreRenderQueryHarness {
685        search: Entity<CommandSearch>,
686        events: Rc<RefCell<Vec<CommandSearchEvent>>>,
687        _subscription: Subscription,
688    }
689
690    impl PreRenderQueryHarness {
691        fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
692            let search = cx.new(|cx| CommandSearch::new("pre-render-query", window, cx));
693            let events = Rc::new(RefCell::new(Vec::new()));
694            let captured = events.clone();
695            let subscription = cx.subscribe(&search, move |_, _, event, _| {
696                captured.borrow_mut().push(event.clone());
697            });
698            search.update(cx, |search, cx| {
699                search.set_query("before render", window, cx);
700            });
701            Self {
702                search,
703                events,
704                _subscription: subscription,
705            }
706        }
707    }
708
709    impl Render for PreRenderQueryHarness {
710        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
711            self.search.clone()
712        }
713    }
714
715    impl Harness {
716        fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
717            let search = cx.new(|cx| CommandSearch::new("test-search", window, cx));
718            let events = Rc::new(RefCell::new(Vec::new()));
719            let captured = events.clone();
720            let subscription = cx.subscribe(&search, move |_, _, event, _| {
721                captured.borrow_mut().push(event.clone());
722            });
723            Self {
724                search,
725                events,
726                _subscription: subscription,
727            }
728        }
729    }
730
731    impl Render for Harness {
732        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
733            self.search.clone()
734        }
735    }
736
737    struct ReplaceBeforeChildPrepaint {
738        search: Entity<CommandSearch>,
739        replacement: Option<Vec<CommandSearchItem>>,
740        observed_applied_revision: Rc<Cell<u64>>,
741        child: AnyElement,
742    }
743
744    impl IntoElement for ReplaceBeforeChildPrepaint {
745        type Element = Self;
746
747        fn into_element(self) -> Self::Element {
748            self
749        }
750    }
751
752    impl Element for ReplaceBeforeChildPrepaint {
753        type RequestLayoutState = ();
754        type PrepaintState = ();
755
756        fn id(&self) -> Option<gpui::ElementId> {
757            None
758        }
759
760        fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
761            None
762        }
763
764        fn request_layout(
765            &mut self,
766            _: Option<&GlobalElementId>,
767            _: Option<&InspectorElementId>,
768            window: &mut Window,
769            cx: &mut App,
770        ) -> (LayoutId, Self::RequestLayoutState) {
771            (self.child.request_layout(window, cx), ())
772        }
773
774        fn prepaint(
775            &mut self,
776            _: Option<&GlobalElementId>,
777            _: Option<&InspectorElementId>,
778            _: Bounds<Pixels>,
779            _: &mut Self::RequestLayoutState,
780            window: &mut Window,
781            cx: &mut App,
782        ) -> Self::PrepaintState {
783            let replacement = self
784                .replacement
785                .take()
786                .expect("the catalog should be replaced exactly once before child prepaint");
787            self.search.update(cx, |search, cx| {
788                search.set_items(replacement, window, cx);
789            });
790            self.child.prepaint(window, cx);
791            self.observed_applied_revision
792                .set(self.search.read(cx).applied_selection_revision);
793        }
794
795        fn paint(
796            &mut self,
797            _: Option<&GlobalElementId>,
798            _: Option<&InspectorElementId>,
799            _: Bounds<Pixels>,
800            _: &mut Self::RequestLayoutState,
801            _: &mut Self::PrepaintState,
802            window: &mut Window,
803            cx: &mut App,
804        ) {
805            self.child.paint(window, cx);
806        }
807    }
808
809    fn interactive_catalog() -> Vec<CommandSearchItem> {
810        vec![
811            CommandSearchItem::new("first", "Open report").shortcut("Ctrl+1"),
812            CommandSearchItem::new("disabled", "Unavailable report").disabled(true),
813            CommandSearchItem::new("second", "Open report")
814                .subtitle("Supplier pricing")
815                .keywords(["margin"])
816                .shortcut("Ctrl+2"),
817        ]
818    }
819
820    fn harness(cx: &mut TestAppContext) -> (Entity<Harness>, &mut VisualTestContext) {
821        cx.update(crate::init);
822        let (harness, cx) = cx.add_window_view(Harness::new);
823        let cx: &mut VisualTestContext = cx;
824        cx.simulate_resize(size(px(520.), px(360.)));
825        let search = harness.read_with(cx, |harness, _| harness.search.clone());
826        cx.update(|window, cx| {
827            search.update(cx, |search, cx| {
828                search.set_items(interactive_catalog(), window, cx);
829                #[cfg(not(target_os = "macos"))]
830                search.focus(window, cx);
831            });
832            window.draw(cx).clear(cx);
833        });
834        cx.run_until_parked();
835        cx.update(|window, cx| window.draw(cx).clear(cx));
836        (harness, cx)
837    }
838
839    fn activate_key(cx: &mut VisualTestContext, key: &str) {
840        let keystroke = Keystroke::parse(key).expect("test key should parse");
841        cx.simulate_event(KeyDownEvent {
842            keystroke: keystroke.clone(),
843            is_held: false,
844            prefer_character_input: false,
845        });
846        cx.simulate_event(KeyUpEvent { keystroke });
847        cx.run_until_parked();
848    }
849
850    #[gpui::test]
851    fn set_query_emits_once_before_the_first_render(cx: &mut TestAppContext) {
852        cx.update(crate::init);
853        let (harness, cx) = cx.add_window_view(PreRenderQueryHarness::new);
854
855        assert_eq!(
856            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
857            [CommandSearchEvent::QueryChanged {
858                id: "pre-render-query".into(),
859                query: "before render".into(),
860            }]
861        );
862
863        cx.update(|window, cx| window.draw(cx).clear(cx));
864        cx.run_until_parked();
865        assert_eq!(
866            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
867            [CommandSearchEvent::QueryChanged {
868                id: "pre-render-query".into(),
869                query: "before render".into(),
870            }]
871        );
872    }
873
874    #[gpui::test]
875    fn upstream_filter_matches_title_subtitle_and_keywords_case_insensitively(
876        cx: &mut TestAppContext,
877    ) {
878        let (harness, cx) = harness(cx);
879        let search = harness.read_with(cx, |harness, _| harness.search.clone());
880        cx.update(|window, cx| {
881            search.update(cx, |search, cx| {
882                search.set_items(
883                    [
884                        CommandSearchItem::new("calendar", "Open Calendar")
885                            .subtitle("Review supplier delivery dates")
886                            .keywords(["schedule", "dates"]),
887                        CommandSearchItem::new("pricing", "Open report")
888                            .subtitle("Supplier pricing")
889                            .keywords(["margin", "cost"]),
890                        CommandSearchItem::new("archive", "Archive workspace")
891                            .keywords(["close", "store"]),
892                    ],
893                    window,
894                    cx,
895                );
896            });
897            window.draw(cx).clear(cx);
898        });
899        cx.run_until_parked();
900
901        for (query, expected_id) in [
902            ("CALENDAR", "calendar"),
903            ("DELIVERY", "calendar"),
904            ("MARGIN", "pricing"),
905        ] {
906            cx.update(|window, cx| {
907                search.update(cx, |search, cx| search.set_query(query, window, cx));
908            });
909            cx.run_until_parked();
910            search.read_with(cx, |search, cx| {
911                assert_eq!(search.state.read(cx).matched_count(), 1, "query {query}");
912                assert_eq!(
913                    search.selected_id.as_deref(),
914                    Some(expected_id),
915                    "query {query}"
916                );
917            });
918        }
919    }
920
921    #[gpui::test]
922    fn programmatic_query_emits_the_latest_stable_component_id(cx: &mut TestAppContext) {
923        let (harness, cx) = harness(cx);
924        let search = harness.read_with(cx, |harness, _| harness.search.clone());
925
926        cx.update(|window, cx| {
927            search.update(cx, |search, cx| search.set_query("MARGIN", window, cx));
928        });
929        cx.run_until_parked();
930
931        assert_eq!(
932            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
933            [CommandSearchEvent::QueryChanged {
934                id: "test-search".into(),
935                query: "MARGIN".into(),
936            }]
937        );
938    }
939
940    #[gpui::test]
941    fn empty_catalog_and_no_results_render_distinct_named_statuses(cx: &mut TestAppContext) {
942        let (harness, cx) = harness(cx);
943        let search = harness.read_with(cx, |harness, _| harness.search.clone());
944
945        cx.update(|window, cx| {
946            search.update(cx, |search, cx| search.set_items([], window, cx));
947            window.draw(cx).clear(cx);
948        });
949        cx.run_until_parked();
950        cx.update(|window, cx| window.draw(cx).clear(cx));
951        assert!(cx.debug_bounds("command-search-empty-catalog").is_some());
952        assert!(cx.debug_bounds("command-search-no-results").is_none());
953
954        cx.update(|window, cx| {
955            search.update(cx, |search, cx| {
956                search.set_items(
957                    [CommandSearchItem::new("present", "Present command")],
958                    window,
959                    cx,
960                );
961                search.set_query("absent", window, cx);
962            });
963            window.draw(cx).clear(cx);
964        });
965        cx.run_until_parked();
966        cx.update(|window, cx| window.draw(cx).clear(cx));
967        assert!(cx.debug_bounds("command-search-empty-catalog").is_none());
968        assert!(cx.debug_bounds("command-search-no-results").is_some());
969    }
970
971    #[cfg_attr(
972        target_os = "macos",
973        ignore = "pinned GPUI TestWindow has no native macOS handle for focused InputState"
974    )]
975    #[gpui::test]
976    fn keyboard_navigation_skips_disabled_and_confirms_duplicate_label_by_id(
977        cx: &mut TestAppContext,
978    ) {
979        let (harness, cx) = harness(cx);
980
981        activate_key(cx, "down");
982        activate_key(cx, "enter");
983
984        assert_eq!(
985            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
986            [CommandSearchEvent::Selected {
987                id: "test-search".into(),
988                item_id: "second".into(),
989            }]
990        );
991    }
992
993    #[gpui::test]
994    fn pointer_and_keyboard_activation_emit_the_same_stable_event(cx: &mut TestAppContext) {
995        let (harness, cx) = harness(cx);
996        let first = cx
997            .debug_bounds("command-search-item-first")
998            .expect("the first visible row should expose a stable test selector");
999
1000        cx.simulate_click(first.center(), Modifiers::default());
1001        cx.run_until_parked();
1002        activate_key(cx, "enter");
1003
1004        assert_eq!(
1005            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
1006            [
1007                CommandSearchEvent::Selected {
1008                    id: "test-search".into(),
1009                    item_id: "first".into(),
1010                },
1011                CommandSearchEvent::Selected {
1012                    id: "test-search".into(),
1013                    item_id: "first".into(),
1014                },
1015            ]
1016        );
1017    }
1018
1019    #[cfg_attr(
1020        target_os = "macos",
1021        ignore = "pinned GPUI TestWindow has no native macOS handle for focused InputState"
1022    )]
1023    #[gpui::test]
1024    fn deferred_select_and_confirm_keep_the_installed_snapshot_identity(cx: &mut TestAppContext) {
1025        let (harness, cx) = harness(cx);
1026        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1027        cx.update(|window, cx| {
1028            window.dispatch_keystroke(Keystroke::parse("down").expect("test key should parse"), cx);
1029            window.dispatch_keystroke(
1030                Keystroke::parse("enter").expect("test key should parse"),
1031                cx,
1032            );
1033            search.update(cx, |search, cx| {
1034                search.set_items(
1035                    [
1036                        CommandSearchItem::new("second", "Open report"),
1037                        CommandSearchItem::new("first", "Open report"),
1038                        CommandSearchItem::new("replacement", "Replacement command"),
1039                    ],
1040                    window,
1041                    cx,
1042                );
1043            });
1044        });
1045        cx.run_until_parked();
1046
1047        assert_eq!(
1048            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
1049            [CommandSearchEvent::Selected {
1050                id: "test-search".into(),
1051                item_id: "second".into(),
1052            }]
1053        );
1054        assert_eq!(
1055            search.read_with(cx, |search, _| search.selected_id.clone()),
1056            Some("second".into())
1057        );
1058    }
1059
1060    #[cfg_attr(
1061        target_os = "macos",
1062        ignore = "pinned GPUI TestWindow has no native macOS handle for focused InputState"
1063    )]
1064    #[gpui::test]
1065    fn escape_clears_a_query_before_emitting_dismissal(cx: &mut TestAppContext) {
1066        let (harness, cx) = harness(cx);
1067        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1068        cx.update(|window, cx| {
1069            search.update(cx, |search, cx| search.set_query("report", window, cx));
1070        });
1071        cx.run_until_parked();
1072
1073        activate_key(cx, "escape");
1074        activate_key(cx, "escape");
1075
1076        assert_eq!(
1077            harness.read_with(cx, |harness, _| harness.events.borrow().clone()),
1078            [
1079                CommandSearchEvent::QueryChanged {
1080                    id: "test-search".into(),
1081                    query: "report".into(),
1082                },
1083                CommandSearchEvent::QueryChanged {
1084                    id: "test-search".into(),
1085                    query: "".into(),
1086                },
1087                CommandSearchEvent::Dismissed {
1088                    id: "test-search".into(),
1089                },
1090            ]
1091        );
1092    }
1093
1094    #[cfg_attr(
1095        target_os = "macos",
1096        ignore = "pinned GPUI TestWindow has no native macOS handle for focused InputState"
1097    )]
1098    #[gpui::test]
1099    fn selected_id_survives_snapshot_reorder_and_moves_the_upstream_highlight(
1100        cx: &mut TestAppContext,
1101    ) {
1102        let (harness, cx) = harness(cx);
1103        activate_key(cx, "down");
1104        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1105
1106        cx.update(|window, cx| {
1107            search.update(cx, |search, cx| {
1108                search.set_items(
1109                    [
1110                        CommandSearchItem::new("second", "Open report"),
1111                        CommandSearchItem::new("first", "Open report"),
1112                    ],
1113                    window,
1114                    cx,
1115                );
1116            });
1117            window.draw(cx).clear(cx);
1118        });
1119        cx.run_until_parked();
1120        cx.update(|window, cx| window.draw(cx).clear(cx));
1121
1122        search.read_with(cx, |search, cx| {
1123            assert_eq!(search.selected_id, Some("second".into()));
1124            assert_eq!(
1125                search.state.read(cx).selected_index(),
1126                Some(gpui_component::IndexPath::new(0))
1127            );
1128        });
1129    }
1130
1131    #[cfg_attr(
1132        target_os = "macos",
1133        ignore = "pinned GPUI TestWindow has no native macOS handle for focused InputState"
1134    )]
1135    #[gpui::test]
1136    fn stale_prepaint_does_not_apply_new_indices_to_an_old_command_model(cx: &mut TestAppContext) {
1137        let (harness, cx) = harness(cx);
1138        activate_key(cx, "down");
1139        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1140        let applied_before = search.read_with(cx, |search, _| search.applied_selection_revision);
1141        let observed_applied_revision = Rc::new(Cell::new(u64::MAX));
1142
1143        cx.draw(
1144            point(px(0.), px(0.)),
1145            size(px(520.), px(360.)),
1146            |window, cx| {
1147                search.update(cx, |search, cx| {
1148                    search.set_items(
1149                        [
1150                            CommandSearchItem::new("first", "First command"),
1151                            CommandSearchItem::new("second", "Second command"),
1152                        ],
1153                        window,
1154                        cx,
1155                    );
1156                });
1157                ReplaceBeforeChildPrepaint {
1158                    search: search.clone(),
1159                    replacement: Some(vec![
1160                        CommandSearchItem::new("second", "Second command"),
1161                        CommandSearchItem::new("first", "First command"),
1162                    ]),
1163                    observed_applied_revision: observed_applied_revision.clone(),
1164                    child: search.clone().into_any_element(),
1165                }
1166            },
1167        );
1168        assert_eq!(
1169            observed_applied_revision.get(),
1170            applied_before,
1171            "a stale prepaint must not mark its model revision as applied"
1172        );
1173        cx.run_until_parked();
1174
1175        cx.update(|window, cx| window.draw(cx).clear(cx));
1176        cx.run_until_parked();
1177        cx.update(|window, cx| window.draw(cx).clear(cx));
1178
1179        search.read_with(cx, |search, cx| {
1180            assert_eq!(search.selected_id, Some("second".into()));
1181            assert_eq!(
1182                search.state.read(cx).selected_index(),
1183                Some(gpui_component::IndexPath::new(0))
1184            );
1185        });
1186    }
1187
1188    #[gpui::test]
1189    fn pre_first_render_query_keeps_the_upstream_first_match_selected(cx: &mut TestAppContext) {
1190        cx.update(crate::init);
1191        let (harness, cx) = cx.add_window_view(Harness::new);
1192        let cx: &mut VisualTestContext = cx;
1193        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1194        cx.update(|window, cx| {
1195            search.update(cx, |search, cx| {
1196                search.set_items(
1197                    [
1198                        CommandSearchItem::new("hidden", "Hidden command"),
1199                        CommandSearchItem::new("match", "Matching command"),
1200                    ],
1201                    window,
1202                    cx,
1203                );
1204                search.set_query("matching", window, cx);
1205            });
1206            window.draw(cx).clear(cx);
1207        });
1208        cx.run_until_parked();
1209
1210        search.read_with(cx, |search, cx| {
1211            assert_eq!(search.selected_id, Some("match".into()));
1212            assert_eq!(
1213                search.state.read(cx).selected_index(),
1214                Some(gpui_component::IndexPath::new(1))
1215            );
1216        });
1217    }
1218
1219    #[gpui::test]
1220    fn replacement_under_query_preserves_a_valid_upstream_match(cx: &mut TestAppContext) {
1221        let (harness, cx) = harness(cx);
1222        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1223        cx.update(|window, cx| {
1224            search.update(cx, |search, cx| search.set_query("margin", window, cx));
1225        });
1226        cx.run_until_parked();
1227        assert_eq!(
1228            search.read_with(cx, |search, _| search.selected_id.clone()),
1229            Some("second".into())
1230        );
1231
1232        cx.update(|window, cx| {
1233            search.update(cx, |search, cx| {
1234                search.set_items(
1235                    [
1236                        CommandSearchItem::new("second", "No longer matches"),
1237                        CommandSearchItem::new("next", "Margin command"),
1238                    ],
1239                    window,
1240                    cx,
1241                );
1242            });
1243            window.draw(cx).clear(cx);
1244        });
1245        cx.run_until_parked();
1246
1247        search.read_with(cx, |search, cx| {
1248            assert_eq!(search.selected_id, Some("next".into()));
1249            assert_eq!(
1250                search.state.read(cx).selected_index(),
1251                Some(gpui_component::IndexPath::new(1))
1252            );
1253        });
1254    }
1255
1256    #[gpui::test]
1257    fn thousand_item_palette_paints_only_a_bounded_visible_range_and_reaches_the_end(
1258        cx: &mut TestAppContext,
1259    ) {
1260        let (harness, cx) = harness(cx);
1261        let search = harness.read_with(cx, |harness, _| harness.search.clone());
1262        let items = (0..1_000)
1263            .map(|index| {
1264                CommandSearchItem::new(format!("large-{index}"), format!("Large command {index}"))
1265            })
1266            .collect::<Vec<_>>();
1267        cx.update(|window, cx| {
1268            search.update(cx, |search, cx| search.set_items(items, window, cx));
1269            window.draw(cx).clear(cx);
1270        });
1271        cx.run_until_parked();
1272        cx.update(|window, cx| window.draw(cx).clear(cx));
1273
1274        let visible = (0..1_000)
1275            .filter(|index| {
1276                let selector: &'static str =
1277                    Box::leak(format!("command-search-item-large-{index}").into_boxed_str());
1278                cx.debug_bounds(selector).is_some()
1279            })
1280            .count();
1281        assert!(
1282            visible < 50,
1283            "only a small visible range should be painted, got {visible}"
1284        );
1285        assert!(cx.debug_bounds("command-search-item-large-999").is_none());
1286
1287        cx.update(|window, cx| {
1288            search.update(cx, |search, cx| {
1289                search.state.update(cx, |state, cx| {
1290                    state.set_selected_index(Some(gpui_component::IndexPath::new(999)), window, cx);
1291                });
1292            });
1293            window.draw(cx).clear(cx);
1294        });
1295        cx.run_until_parked();
1296        cx.update(|window, cx| window.draw(cx).clear(cx));
1297        assert!(cx.debug_bounds("command-search-item-large-999").is_some());
1298        assert!(cx.debug_bounds("command-search-item-large-0").is_none());
1299    }
1300}