1use 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#[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 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 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
50 self.subtitle = Some(subtitle.into());
51 self
52 }
53
54 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 pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
66 self.shortcut = Some(shortcut.into());
67 self
68 }
69
70 pub fn disabled(mut self, disabled: bool) -> Self {
72 self.disabled = disabled;
73 self
74 }
75
76 pub fn id(&self) -> &SharedString {
78 &self.id
79 }
80
81 pub fn title(&self) -> &SharedString {
83 &self.title
84 }
85
86 pub fn subtitle_text(&self) -> Option<&SharedString> {
88 self.subtitle.as_ref()
89 }
90
91 pub fn keyword_terms(&self) -> &[SharedString] {
93 &self.keywords
94 }
95
96 pub fn shortcut_text(&self) -> Option<&SharedString> {
98 self.shortcut.as_ref()
99 }
100
101 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#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum CommandSearchEvent {
121 QueryChanged {
123 id: SharedString,
125 query: SharedString,
127 },
128 Selected {
130 id: SharedString,
132 item_id: SharedString,
134 },
135 Dismissed {
137 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 .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
240pub struct CommandSearch {
262 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 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 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 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 pub fn query(&self, cx: &App) -> SharedString {
336 self.state.read(cx).query(cx)
337 }
338
339 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}