1mod persistence;
2
3use std::{
4 cmp::{self, Reverse},
5 collections::{HashMap, VecDeque},
6 sync::Arc,
7 time::Duration,
8};
9
10use client::parse_zed_link;
11use command_palette_hooks::{
12 CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter,
13 GlobalCommandPaletteInterceptor,
14};
15
16use fuzzy_nucleo::{StringMatch, StringMatchCandidate};
17use gpui::{
18 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
19 ParentElement, Render, Styled, Task, WeakEntity, Window,
20};
21use persistence::CommandPaletteDB;
22use picker::Direction;
23use picker::{Picker, PickerDelegate};
24use postage::{sink::Sink, stream::Stream};
25use settings::Settings;
26use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
27use util::ResultExt;
28use workspace::{ModalView, Workspace, WorkspaceSettings};
29use zed_actions::{OpenZedUrl, command_palette::Toggle};
30
31pub fn init(cx: &mut App) {
32 command_palette_hooks::init(cx);
33 cx.observe_new(CommandPalette::register).detach();
34}
35
36impl ModalView for CommandPalette {}
37
38pub struct CommandPalette {
39 picker: Entity<Picker<CommandPaletteDelegate>>,
40}
41
42/// Removes subsequent whitespace characters and double colons from the query, and converts
43/// underscores to spaces.
44///
45/// This improves the likelihood of a match by either humanized name or keymap-style name.
46/// Underscores are converted to spaces because `humanize_action_name` converts them to spaces
47/// when building the search candidates (e.g. `terminal_panel::Toggle` -> `terminal panel: toggle`).
48pub fn normalize_action_query(input: &str) -> String {
49 let mut result = String::with_capacity(input.len());
50 let mut last_char = None;
51
52 for char in input.trim().chars() {
53 let normalized_char = if char == '_' { ' ' } else { char };
54 match (last_char, normalized_char) {
55 (Some(':'), ':') => continue,
56 (Some(last_char), c) if last_char.is_whitespace() && c.is_whitespace() => {
57 continue;
58 }
59 _ => {
60 last_char = Some(normalized_char);
61 }
62 }
63 result.push(normalized_char);
64 }
65
66 result
67}
68
69impl CommandPalette {
70 fn register(
71 workspace: &mut Workspace,
72 _window: Option<&mut Window>,
73 _: &mut Context<Workspace>,
74 ) {
75 workspace.register_action(|workspace, _: &Toggle, window, cx| {
76 Self::toggle(workspace, "", window, cx)
77 });
78 }
79
80 pub fn toggle(
81 workspace: &mut Workspace,
82 query: &str,
83 window: &mut Window,
84 cx: &mut Context<Workspace>,
85 ) {
86 let Some(previous_focus_handle) = window.focused(cx) else {
87 return;
88 };
89
90 let entity = cx.weak_entity();
91 workspace.toggle_modal(window, cx, move |window, cx| {
92 CommandPalette::new(previous_focus_handle, query, entity, window, cx)
93 });
94 }
95
96 fn new(
97 previous_focus_handle: FocusHandle,
98 query: &str,
99 entity: WeakEntity<Workspace>,
100 window: &mut Window,
101 cx: &mut Context<Self>,
102 ) -> Self {
103 let filter = CommandPaletteFilter::try_global(cx);
104
105 let commands = window
106 .available_actions(cx)
107 .into_iter()
108 .filter_map(|action| {
109 if filter.is_some_and(|filter| filter.is_hidden(&*action)) {
110 return None;
111 }
112
113 Some(Command {
114 name: humanize_action_name(action.name()),
115 action,
116 })
117 })
118 .collect();
119
120 let delegate = CommandPaletteDelegate::new(
121 cx.entity().downgrade(),
122 entity,
123 commands,
124 previous_focus_handle,
125 );
126
127 let picker = cx.new(|cx| {
128 let picker = Picker::uniform_list(delegate, window, cx);
129 picker.set_query(query, window, cx);
130 picker
131 });
132 Self { picker }
133 }
134
135 pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
136 self.picker
137 .update(cx, |picker, cx| picker.set_query(query, window, cx))
138 }
139}
140
141impl EventEmitter<DismissEvent> for CommandPalette {}
142
143impl Focusable for CommandPalette {
144 fn focus_handle(&self, cx: &App) -> FocusHandle {
145 self.picker.focus_handle(cx)
146 }
147}
148
149impl Render for CommandPalette {
150 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
151 v_flex()
152 .key_context("CommandPalette")
153 .w(rems(34.))
154 .child(self.picker.clone())
155 }
156}
157
158pub struct CommandPaletteDelegate {
159 latest_query: String,
160 command_palette: WeakEntity<CommandPalette>,
161 workspace: WeakEntity<Workspace>,
162 all_commands: Vec<Command>,
163 commands: Vec<Command>,
164 matches: Vec<StringMatch>,
165 selected_ix: usize,
166 previous_focus_handle: FocusHandle,
167 updating_matches: Option<(
168 Task<()>,
169 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
170 )>,
171 query_history: QueryHistory,
172}
173
174struct Command {
175 name: String,
176 action: Box<dyn Action>,
177}
178
179#[derive(Default)]
180struct QueryHistory {
181 history: Option<VecDeque<String>>,
182 cursor: Option<usize>,
183 prefix: Option<String>,
184}
185
186impl QueryHistory {
187 fn history(&mut self, cx: &App) -> &mut VecDeque<String> {
188 self.history.get_or_insert_with(|| {
189 CommandPaletteDB::global(cx)
190 .list_recent_queries()
191 .unwrap_or_default()
192 .into_iter()
193 .collect()
194 })
195 }
196
197 fn add(&mut self, query: String, cx: &App) {
198 if let Some(pos) = self.history(cx).iter().position(|h| h == &query) {
199 self.history(cx).remove(pos);
200 }
201 self.history(cx).push_back(query);
202 self.cursor = None;
203 self.prefix = None;
204 }
205
206 fn validate_cursor(&mut self, current_query: &str, cx: &App) -> Option<usize> {
207 if let Some(pos) = self.cursor {
208 if self.history(cx).get(pos).map(|s| s.as_str()) != Some(current_query) {
209 self.cursor = None;
210 self.prefix = None;
211 }
212 }
213 self.cursor
214 }
215
216 fn previous(&mut self, current_query: &str, cx: &App) -> Option<&str> {
217 if self.validate_cursor(current_query, cx).is_none() {
218 self.prefix = Some(current_query.to_string());
219 }
220
221 let prefix = self.prefix.clone().unwrap_or_default();
222 let start_index = self.cursor.unwrap_or(self.history(cx).len());
223
224 for i in (0..start_index).rev() {
225 if self
226 .history(cx)
227 .get(i)
228 .is_some_and(|e| e.starts_with(&prefix))
229 {
230 self.cursor = Some(i);
231 return self.history(cx).get(i).map(|s| s.as_str());
232 }
233 }
234 None
235 }
236
237 fn next(&mut self, current_query: &str, cx: &App) -> Option<&str> {
238 let selected = self.validate_cursor(current_query, cx)?;
239 let prefix = self.prefix.clone().unwrap_or_default();
240
241 for i in (selected + 1)..self.history(cx).len() {
242 if self
243 .history(cx)
244 .get(i)
245 .is_some_and(|e| e.starts_with(&prefix))
246 {
247 self.cursor = Some(i);
248 return self.history(cx).get(i).map(|s| s.as_str());
249 }
250 }
251 None
252 }
253
254 fn reset_cursor(&mut self) {
255 self.cursor = None;
256 self.prefix = None;
257 }
258
259 fn is_navigating(&self) -> bool {
260 self.cursor.is_some()
261 }
262}
263
264impl Clone for Command {
265 fn clone(&self) -> Self {
266 Self {
267 name: self.name.clone(),
268 action: self.action.boxed_clone(),
269 }
270 }
271}
272
273impl CommandPaletteDelegate {
274 fn new(
275 command_palette: WeakEntity<CommandPalette>,
276 workspace: WeakEntity<Workspace>,
277 commands: Vec<Command>,
278 previous_focus_handle: FocusHandle,
279 ) -> Self {
280 Self {
281 command_palette,
282 workspace,
283 all_commands: commands.clone(),
284 matches: vec![],
285 commands,
286 selected_ix: 0,
287 previous_focus_handle,
288 latest_query: String::new(),
289 updating_matches: None,
290 query_history: Default::default(),
291 }
292 }
293
294 fn matches_updated(
295 &mut self,
296 query: String,
297 mut commands: Vec<Command>,
298 mut matches: Vec<StringMatch>,
299 intercept_result: CommandInterceptResult,
300 _: &mut Context<Picker<Self>>,
301 ) {
302 self.updating_matches.take();
303 self.latest_query = query;
304
305 let mut new_matches = Vec::new();
306
307 for CommandInterceptItem {
308 action,
309 string,
310 positions,
311 } in intercept_result.results
312 {
313 if let Some(idx) = matches
314 .iter()
315 .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
316 {
317 matches.remove(idx);
318 }
319 commands.push(Command {
320 name: string.clone(),
321 action,
322 });
323 new_matches.push(StringMatch {
324 candidate_id: commands.len() - 1,
325 string: string.into(),
326 positions,
327 score: 0.0,
328 })
329 }
330 if !intercept_result.exclusive {
331 new_matches.append(&mut matches);
332 }
333 self.commands = commands;
334 self.matches = new_matches;
335 if self.matches.is_empty() {
336 self.selected_ix = 0;
337 } else {
338 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
339 }
340 }
341
342 /// Hit count for each command in the palette.
343 /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
344 /// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
345 fn hit_counts(&self, cx: &App) -> HashMap<String, u16> {
346 if let Ok(commands) = CommandPaletteDB::global(cx).list_commands_used() {
347 commands
348 .into_iter()
349 .map(|command| (command.command_name, command.invocations))
350 .collect()
351 } else {
352 HashMap::new()
353 }
354 }
355
356 fn selected_command(&self) -> Option<&Command> {
357 if self.matches.is_empty() {
358 return None;
359 }
360 let action_ix = self
361 .matches
362 .get(self.selected_ix)
363 .map(|m| m.candidate_id)
364 .unwrap_or(self.selected_ix);
365 // this gets called in headless tests where there are no commands loaded
366 // so we need to return an Option here
367 self.commands.get(action_ix)
368 }
369
370 #[cfg(any(test, feature = "test-support"))]
371 pub fn seed_history(&mut self, queries: &[&str]) {
372 self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect());
373 }
374}
375
376impl PickerDelegate for CommandPaletteDelegate {
377 type ListItem = ListItem;
378
379 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
380 "Execute a command...".into()
381 }
382
383 fn select_history(
384 &mut self,
385 direction: Direction,
386 query: &str,
387 _window: &mut Window,
388 cx: &mut App,
389 ) -> Option<String> {
390 match direction {
391 Direction::Up => {
392 let should_use_history =
393 self.selected_ix == 0 || self.query_history.is_navigating();
394 if should_use_history {
395 if let Some(query) = self
396 .query_history
397 .previous(query, cx)
398 .map(|s| s.to_string())
399 {
400 return Some(query);
401 }
402 }
403 }
404 Direction::Down => {
405 if self.query_history.is_navigating() {
406 if let Some(query) = self.query_history.next(query, cx).map(|s| s.to_string()) {
407 return Some(query);
408 } else {
409 let prefix = self.query_history.prefix.take().unwrap_or_default();
410 self.query_history.reset_cursor();
411 return Some(prefix);
412 }
413 }
414 }
415 }
416 None
417 }
418
419 fn match_count(&self) -> usize {
420 self.matches.len()
421 }
422
423 fn selected_index(&self) -> usize {
424 self.selected_ix
425 }
426
427 fn set_selected_index(
428 &mut self,
429 ix: usize,
430 _window: &mut Window,
431 _: &mut Context<Picker<Self>>,
432 ) {
433 self.selected_ix = ix;
434 }
435
436 fn update_matches(
437 &mut self,
438 mut query: String,
439 window: &mut Window,
440 cx: &mut Context<Picker<Self>>,
441 ) -> gpui::Task<()> {
442 let settings = WorkspaceSettings::get_global(cx);
443 if let Some(alias) = settings.command_aliases.get(&query) {
444 query = alias.as_ref().to_owned();
445 }
446
447 let workspace = self.workspace.clone();
448
449 let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
450
451 let (mut tx, mut rx) = postage::dispatch::channel(1);
452
453 let query_str = query.as_str();
454 let is_zed_link = parse_zed_link(query_str, cx).is_some();
455
456 let task = cx.background_spawn({
457 let mut commands = self.all_commands.clone();
458 let hit_counts = self.hit_counts(cx);
459 let executor = cx.background_executor().clone();
460 let query = normalize_action_query(query_str);
461 let query_for_link = query_str.to_string();
462 async move {
463 commands.sort_by_key(|action| {
464 (
465 Reverse(hit_counts.get(&action.name).cloned()),
466 action.name.clone(),
467 )
468 });
469
470 let candidates = commands
471 .iter()
472 .enumerate()
473 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
474 .collect::<Vec<_>>();
475
476 let matches = fuzzy_nucleo::match_strings_async(
477 &candidates,
478 &query,
479 fuzzy_nucleo::Case::Smart,
480 fuzzy_nucleo::LengthPenalty::On,
481 10000,
482 &Default::default(),
483 executor,
484 )
485 .await;
486
487 let intercept_result = if is_zed_link {
488 CommandInterceptResult {
489 results: vec![CommandInterceptItem {
490 action: OpenZedUrl {
491 url: query_for_link.clone(),
492 }
493 .boxed_clone(),
494 string: query_for_link,
495 positions: vec![],
496 }],
497 exclusive: false,
498 }
499 } else if let Some(task) = intercept_task {
500 task.await
501 } else {
502 CommandInterceptResult::default()
503 };
504
505 tx.send((commands, matches, intercept_result))
506 .await
507 .log_err();
508 }
509 });
510
511 self.updating_matches = Some((task, rx.clone()));
512
513 cx.spawn_in(window, async move |picker, cx| {
514 let Some((commands, matches, intercept_result)) = rx.recv().await else {
515 return;
516 };
517
518 picker
519 .update(cx, |picker, cx| {
520 picker
521 .delegate
522 .matches_updated(query, commands, matches, intercept_result, cx)
523 })
524 .ok();
525 })
526 }
527
528 fn finalize_update_matches(
529 &mut self,
530 query: String,
531 duration: Duration,
532 _: &mut Window,
533 cx: &mut Context<Picker<Self>>,
534 ) -> bool {
535 let Some((task, rx)) = self.updating_matches.take() else {
536 return true;
537 };
538
539 match cx
540 .foreground_executor()
541 .block_with_timeout(duration, rx.clone().recv())
542 {
543 Ok(Some((commands, matches, interceptor_result))) => {
544 self.matches_updated(query, commands, matches, interceptor_result, cx);
545 true
546 }
547 _ => {
548 self.updating_matches = Some((task, rx));
549 false
550 }
551 }
552 }
553
554 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
555 self.command_palette
556 .update(cx, |_, cx| cx.emit(DismissEvent))
557 .ok();
558 }
559
560 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
561 if secondary {
562 if self.matches.is_empty() {
563 return;
564 }
565 let Some(selected_command) = self.selected_command() else {
566 return;
567 };
568 let action_name = selected_command.action.name();
569 let open_keymap = Box::new(zed_actions::ChangeKeybinding {
570 action: action_name.to_string(),
571 });
572 window.dispatch_action(open_keymap, cx);
573 self.dismissed(window, cx);
574 return;
575 }
576
577 if self.matches.is_empty() {
578 self.dismissed(window, cx);
579 return;
580 }
581
582 if !self.latest_query.is_empty() {
583 self.query_history.add(self.latest_query.clone(), cx);
584 self.query_history.reset_cursor();
585 }
586
587 let action_ix = self.matches[self.selected_ix].candidate_id;
588 let command = self.commands.swap_remove(action_ix);
589 telemetry::event!(
590 "Action Invoked",
591 source = "command palette",
592 action = command.name
593 );
594 self.matches.clear();
595 self.commands.clear();
596 let command_name = command.name.clone();
597 let latest_query = self.latest_query.clone();
598 let db = CommandPaletteDB::global(cx);
599 cx.background_spawn(async move {
600 db.write_command_invocation(command_name, latest_query)
601 .await
602 })
603 .detach_and_log_err(cx);
604 let action = command.action;
605 window.focus(&self.previous_focus_handle, cx);
606 self.dismissed(window, cx);
607 window.dispatch_action(action, cx);
608 }
609
610 fn render_match(
611 &self,
612 ix: usize,
613 selected: bool,
614 _: &mut Window,
615 cx: &mut Context<Picker<Self>>,
616 ) -> Option<Self::ListItem> {
617 let matching_command = self.matches.get(ix)?;
618 let command = self.commands.get(matching_command.candidate_id)?;
619
620 Some(
621 ListItem::new(ix)
622 .inset(true)
623 .spacing(ListItemSpacing::Sparse)
624 .toggle_state(selected)
625 .child(
626 h_flex()
627 .w_full()
628 .py_px()
629 .justify_between()
630 .child(HighlightedLabel::new(
631 command.name.clone(),
632 matching_command.positions.clone(),
633 ))
634 .child(KeyBinding::for_action_in(
635 &*command.action,
636 &self.previous_focus_handle,
637 cx,
638 )),
639 ),
640 )
641 }
642
643 fn render_footer(
644 &self,
645 window: &mut Window,
646 cx: &mut Context<Picker<Self>>,
647 ) -> Option<AnyElement> {
648 let selected_command = self.selected_command()?;
649 let keybind =
650 KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
651
652 let focus_handle = &self.previous_focus_handle;
653 let keybinding_buttons = if keybind.has_binding(window) {
654 Button::new("change", "Change Keybinding…")
655 .key_binding(
656 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
657 .map(|kb| kb.size(rems_from_px(12.))),
658 )
659 .on_click(move |_, window, cx| {
660 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
661 })
662 } else {
663 Button::new("add", "Add Keybinding…")
664 .key_binding(
665 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
666 .map(|kb| kb.size(rems_from_px(12.))),
667 )
668 .on_click(move |_, window, cx| {
669 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
670 })
671 };
672
673 Some(
674 h_flex()
675 .w_full()
676 .p_1p5()
677 .gap_1()
678 .justify_end()
679 .border_t_1()
680 .border_color(cx.theme().colors().border_variant)
681 .child(keybinding_buttons)
682 .child(
683 Button::new("run-action", "Run")
684 .key_binding(
685 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
686 .map(|kb| kb.size(rems_from_px(12.))),
687 )
688 .on_click(|_, window, cx| {
689 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
690 }),
691 )
692 .into_any(),
693 )
694 }
695}
696
697pub fn humanize_action_name(name: &str) -> String {
698 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
699 let mut result = String::with_capacity(capacity);
700 for char in name.chars() {
701 if char == ':' {
702 if result.ends_with(':') {
703 result.push(' ');
704 } else {
705 result.push(':');
706 }
707 } else if char == '_' {
708 result.push(' ');
709 } else if char.is_uppercase() {
710 if !result.ends_with(' ') {
711 result.push(' ');
712 }
713 result.extend(char.to_lowercase());
714 } else {
715 result.push(char);
716 }
717 }
718 result
719}
720
721impl std::fmt::Debug for Command {
722 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
723 f.debug_struct("Command")
724 .field("name", &self.name)
725 .finish_non_exhaustive()
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use std::sync::Arc;
732
733 use super::*;
734 use editor::Editor;
735 use go_to_line::GoToLine;
736 use gpui::{TestAppContext, VisualTestContext};
737 use language::Point;
738 use project::Project;
739 use settings::KeymapFile;
740 use workspace::{AppState, MultiWorkspace, Workspace};
741
742 #[test]
743 fn test_humanize_action_name() {
744 assert_eq!(
745 humanize_action_name("editor::GoToDefinition"),
746 "editor: go to definition"
747 );
748 assert_eq!(
749 humanize_action_name("editor::Backspace"),
750 "editor: backspace"
751 );
752 assert_eq!(
753 humanize_action_name("go_to_line::Deploy"),
754 "go to line: deploy"
755 );
756 }
757
758 #[test]
759 fn test_normalize_query() {
760 assert_eq!(
761 normalize_action_query("editor: backspace"),
762 "editor: backspace"
763 );
764 assert_eq!(
765 normalize_action_query("editor: backspace"),
766 "editor: backspace"
767 );
768 assert_eq!(
769 normalize_action_query("editor: backspace"),
770 "editor: backspace"
771 );
772 assert_eq!(
773 normalize_action_query("editor::GoToDefinition"),
774 "editor:GoToDefinition"
775 );
776 assert_eq!(
777 normalize_action_query("editor::::GoToDefinition"),
778 "editor:GoToDefinition"
779 );
780 assert_eq!(
781 normalize_action_query("editor: :GoToDefinition"),
782 "editor: :GoToDefinition"
783 );
784 assert_eq!(
785 normalize_action_query("terminal_panel::Toggle"),
786 "terminal panel:Toggle"
787 );
788 assert_eq!(
789 normalize_action_query("project_panel::ToggleFocus"),
790 "project panel:ToggleFocus"
791 );
792 }
793
794 #[gpui::test]
795 async fn test_command_palette(cx: &mut TestAppContext) {
796 let app_state = init_test(cx);
797 let db = cx.update(|cx| persistence::CommandPaletteDB::global(cx));
798 db.clear_all().await.unwrap();
799 let project = Project::test(app_state.fs.clone(), [], cx).await;
800 let (multi_workspace, cx) =
801 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
802 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
803
804 let editor = cx.new_window_entity(|window, cx| {
805 let mut editor = Editor::single_line(window, cx);
806 editor.set_text("abc", window, cx);
807 editor
808 });
809
810 workspace.update_in(cx, |workspace, window, cx| {
811 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
812 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
813 });
814
815 cx.simulate_keystrokes("cmd-shift-p");
816
817 let palette = workspace.update(cx, |workspace, cx| {
818 workspace
819 .active_modal::<CommandPalette>(cx)
820 .unwrap()
821 .read(cx)
822 .picker
823 .clone()
824 });
825
826 palette.read_with(cx, |palette, _| {
827 assert!(palette.delegate.commands.len() > 5);
828 let is_sorted =
829 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
830 assert!(is_sorted(&palette.delegate.commands));
831 });
832
833 cx.simulate_input("bcksp");
834
835 palette.read_with(cx, |palette, _| {
836 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
837 });
838
839 cx.simulate_keystrokes("enter");
840
841 workspace.update(cx, |workspace, cx| {
842 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
843 assert_eq!(editor.read(cx).text(cx), "ab")
844 });
845
846 // Add namespace filter, and redeploy the palette
847 cx.update(|_window, cx| {
848 CommandPaletteFilter::update_global(cx, |filter, _| {
849 filter.hide_namespace("editor");
850 });
851 });
852
853 cx.simulate_keystrokes("cmd-shift-p");
854 cx.simulate_input("bcksp");
855
856 let palette = workspace.update(cx, |workspace, cx| {
857 workspace
858 .active_modal::<CommandPalette>(cx)
859 .unwrap()
860 .read(cx)
861 .picker
862 .clone()
863 });
864 palette.read_with(cx, |palette, _| {
865 assert!(palette.delegate.matches.is_empty())
866 });
867 }
868
869 #[gpui::test]
870 async fn test_selected_command_none_when_no_matches(cx: &mut TestAppContext) {
871 let app_state = init_test(cx);
872 let project = Project::test(app_state.fs.clone(), [], cx).await;
873 let (multi_workspace, cx) =
874 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
875 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
876
877 cx.simulate_keystrokes("cmd-shift-p");
878 let picker = workspace.update(cx, |workspace, cx| {
879 workspace
880 .active_modal::<CommandPalette>(cx)
881 .unwrap()
882 .read(cx)
883 .picker
884 .clone()
885 });
886
887 cx.simulate_input("definitely-no-command-should-match-this");
888 cx.background_executor.run_until_parked();
889
890 picker.read_with(cx, |picker, _cx| {
891 assert!(picker.delegate.matches.is_empty());
892 assert!(picker.delegate.selected_command().is_none());
893 });
894 }
895 #[gpui::test]
896 async fn test_normalized_matches(cx: &mut TestAppContext) {
897 let app_state = init_test(cx);
898 let project = Project::test(app_state.fs.clone(), [], cx).await;
899 let (multi_workspace, cx) =
900 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
901 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
902
903 let editor = cx.new_window_entity(|window, cx| {
904 let mut editor = Editor::single_line(window, cx);
905 editor.set_text("abc", window, cx);
906 editor
907 });
908
909 workspace.update_in(cx, |workspace, window, cx| {
910 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
911 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
912 });
913
914 // Test normalize (trimming whitespace and double colons)
915 cx.simulate_keystrokes("cmd-shift-p");
916
917 let palette = workspace.update(cx, |workspace, cx| {
918 workspace
919 .active_modal::<CommandPalette>(cx)
920 .unwrap()
921 .read(cx)
922 .picker
923 .clone()
924 });
925
926 cx.simulate_input("Editor:: Backspace");
927 palette.read_with(cx, |palette, _| {
928 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
929 });
930 }
931
932 #[gpui::test]
933 async fn test_go_to_line(cx: &mut TestAppContext) {
934 let app_state = init_test(cx);
935 let project = Project::test(app_state.fs.clone(), [], cx).await;
936 let (multi_workspace, cx) =
937 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
938 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
939
940 cx.simulate_keystrokes("cmd-n");
941
942 let editor = workspace.update(cx, |workspace, cx| {
943 workspace.active_item_as::<Editor>(cx).unwrap()
944 });
945 editor.update_in(cx, |editor, window, cx| {
946 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
947 });
948
949 cx.simulate_keystrokes("cmd-shift-p");
950 cx.simulate_input("go to line: Toggle");
951 cx.simulate_keystrokes("enter");
952
953 workspace.update(cx, |workspace, cx| {
954 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
955 });
956
957 cx.simulate_keystrokes("3 enter");
958
959 editor.update_in(cx, |editor, window, cx| {
960 assert!(editor.focus_handle(cx).is_focused(window));
961 assert_eq!(
962 editor
963 .selections
964 .last::<Point>(&editor.display_snapshot(cx))
965 .range()
966 .start,
967 Point::new(2, 0)
968 );
969 });
970 }
971
972 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
973 cx.update(|cx| {
974 let app_state = AppState::test(cx);
975 theme_settings::init(theme::LoadThemes::JustBase, cx);
976 editor::init(cx);
977 menu::init();
978 go_to_line::init(cx);
979 workspace::init(app_state.clone(), cx);
980 init(cx);
981 cx.bind_keys(KeymapFile::load_panic_on_failure(
982 r#"[
983 {
984 "bindings": {
985 "cmd-n": "workspace::NewFile",
986 "enter": "menu::Confirm",
987 "cmd-shift-p": "command_palette::Toggle",
988 "up": "menu::SelectPrevious",
989 "down": "menu::SelectNext"
990 }
991 }
992 ]"#,
993 cx,
994 ));
995 app_state
996 })
997 }
998
999 fn open_palette_with_history(
1000 workspace: &Entity<Workspace>,
1001 history: &[&str],
1002 cx: &mut VisualTestContext,
1003 ) -> Entity<Picker<CommandPaletteDelegate>> {
1004 cx.simulate_keystrokes("cmd-shift-p");
1005 cx.run_until_parked();
1006
1007 let palette = workspace.update(cx, |workspace, cx| {
1008 workspace
1009 .active_modal::<CommandPalette>(cx)
1010 .unwrap()
1011 .read(cx)
1012 .picker
1013 .clone()
1014 });
1015
1016 palette.update(cx, |palette, _cx| {
1017 palette.delegate.seed_history(history);
1018 });
1019
1020 palette
1021 }
1022
1023 #[gpui::test]
1024 async fn test_history_navigation_basic(cx: &mut TestAppContext) {
1025 let app_state = init_test(cx);
1026 let project = Project::test(app_state.fs.clone(), [], cx).await;
1027 let (multi_workspace, cx) =
1028 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1029 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1030
1031 let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx);
1032
1033 // Query should be empty initially
1034 palette.read_with(cx, |palette, cx| {
1035 assert_eq!(palette.query(cx), "");
1036 });
1037
1038 // Press up - should load most recent query "select all"
1039 cx.simulate_keystrokes("up");
1040 cx.background_executor.run_until_parked();
1041 palette.read_with(cx, |palette, cx| {
1042 assert_eq!(palette.query(cx), "select all");
1043 });
1044
1045 // Press up again - should load "backspace"
1046 cx.simulate_keystrokes("up");
1047 cx.background_executor.run_until_parked();
1048 palette.read_with(cx, |palette, cx| {
1049 assert_eq!(palette.query(cx), "backspace");
1050 });
1051
1052 // Press down - should go back to "select all"
1053 cx.simulate_keystrokes("down");
1054 cx.background_executor.run_until_parked();
1055 palette.read_with(cx, |palette, cx| {
1056 assert_eq!(palette.query(cx), "select all");
1057 });
1058
1059 // Press down again - should clear query (exit history mode)
1060 cx.simulate_keystrokes("down");
1061 cx.background_executor.run_until_parked();
1062 palette.read_with(cx, |palette, cx| {
1063 assert_eq!(palette.query(cx), "");
1064 });
1065 }
1066
1067 #[gpui::test]
1068 async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) {
1069 let app_state = init_test(cx);
1070 let project = Project::test(app_state.fs.clone(), [], cx).await;
1071 let (multi_workspace, cx) =
1072 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1073 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1074
1075 let palette = open_palette_with_history(&workspace, &["backspace"], cx);
1076
1077 // Press up to enter history mode
1078 cx.simulate_keystrokes("up");
1079 cx.background_executor.run_until_parked();
1080 palette.read_with(cx, |palette, cx| {
1081 assert_eq!(palette.query(cx), "backspace");
1082 });
1083
1084 // Type something - should append to the history query
1085 cx.simulate_input("x");
1086 cx.background_executor.run_until_parked();
1087 palette.read_with(cx, |palette, cx| {
1088 assert_eq!(palette.query(cx), "backspacex");
1089 });
1090 }
1091
1092 #[gpui::test]
1093 async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) {
1094 let app_state = init_test(cx);
1095 let project = Project::test(app_state.fs.clone(), [], cx).await;
1096 let (multi_workspace, cx) =
1097 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1098 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1099
1100 let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx);
1101
1102 // Open palette with a query that has multiple matches
1103 cx.simulate_input("editor");
1104 cx.background_executor.run_until_parked();
1105
1106 // Should have multiple matches, selected_ix should be 0
1107 palette.read_with(cx, |palette, _| {
1108 assert!(palette.delegate.matches.len() > 1);
1109 assert_eq!(palette.delegate.selected_ix, 0);
1110 });
1111
1112 // Press down - should navigate to next suggestion (not history)
1113 cx.simulate_keystrokes("down");
1114 cx.background_executor.run_until_parked();
1115 palette.read_with(cx, |palette, _| {
1116 assert_eq!(palette.delegate.selected_ix, 1);
1117 });
1118
1119 // Press up - should go back to first suggestion
1120 cx.simulate_keystrokes("up");
1121 cx.background_executor.run_until_parked();
1122 palette.read_with(cx, |palette, _| {
1123 assert_eq!(palette.delegate.selected_ix, 0);
1124 });
1125
1126 // Press up again at top - should enter history mode and show previous query
1127 // that matches the "editor" prefix
1128 cx.simulate_keystrokes("up");
1129 cx.background_executor.run_until_parked();
1130 palette.read_with(cx, |palette, cx| {
1131 assert_eq!(palette.query(cx), "editor: open");
1132 });
1133 }
1134
1135 #[gpui::test]
1136 async fn test_history_prefix_search(cx: &mut TestAppContext) {
1137 let app_state = init_test(cx);
1138 let project = Project::test(app_state.fs.clone(), [], cx).await;
1139 let (multi_workspace, cx) =
1140 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1141 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1142
1143 let palette = open_palette_with_history(
1144 &workspace,
1145 &["open file", "select all", "select line", "backspace"],
1146 cx,
1147 );
1148
1149 // Type "sel" as a prefix
1150 cx.simulate_input("sel");
1151 cx.background_executor.run_until_parked();
1152
1153 // Press up - should get "select line" (most recent matching "sel")
1154 cx.simulate_keystrokes("up");
1155 cx.background_executor.run_until_parked();
1156 palette.read_with(cx, |palette, cx| {
1157 assert_eq!(palette.query(cx), "select line");
1158 });
1159
1160 // Press up again - should get "select all" (next matching "sel")
1161 cx.simulate_keystrokes("up");
1162 cx.background_executor.run_until_parked();
1163 palette.read_with(cx, |palette, cx| {
1164 assert_eq!(palette.query(cx), "select all");
1165 });
1166
1167 // Press up again - should stay at "select all" (no more matches for "sel")
1168 cx.simulate_keystrokes("up");
1169 cx.background_executor.run_until_parked();
1170 palette.read_with(cx, |palette, cx| {
1171 assert_eq!(palette.query(cx), "select all");
1172 });
1173
1174 // Press down - should go back to "select line"
1175 cx.simulate_keystrokes("down");
1176 cx.background_executor.run_until_parked();
1177 palette.read_with(cx, |palette, cx| {
1178 assert_eq!(palette.query(cx), "select line");
1179 });
1180
1181 // Press down again - should return to original prefix "sel"
1182 cx.simulate_keystrokes("down");
1183 cx.background_executor.run_until_parked();
1184 palette.read_with(cx, |palette, cx| {
1185 assert_eq!(palette.query(cx), "sel");
1186 });
1187 }
1188
1189 #[gpui::test]
1190 async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) {
1191 let app_state = init_test(cx);
1192 let project = Project::test(app_state.fs.clone(), [], cx).await;
1193 let (multi_workspace, cx) =
1194 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1195 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1196
1197 let palette =
1198 open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx);
1199
1200 // Type "xyz" as a prefix that doesn't match anything
1201 cx.simulate_input("xyz");
1202 cx.background_executor.run_until_parked();
1203
1204 // Press up - should stay at "xyz" (no matches)
1205 cx.simulate_keystrokes("up");
1206 cx.background_executor.run_until_parked();
1207 palette.read_with(cx, |palette, cx| {
1208 assert_eq!(palette.query(cx), "xyz");
1209 });
1210 }
1211
1212 #[gpui::test]
1213 async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) {
1214 let app_state = init_test(cx);
1215 let project = Project::test(app_state.fs.clone(), [], cx).await;
1216 let (multi_workspace, cx) =
1217 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1218 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1219
1220 let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx);
1221
1222 // With empty query, press up - should get "gamma" (most recent)
1223 cx.simulate_keystrokes("up");
1224 cx.background_executor.run_until_parked();
1225 palette.read_with(cx, |palette, cx| {
1226 assert_eq!(palette.query(cx), "gamma");
1227 });
1228
1229 // Press up - should get "beta"
1230 cx.simulate_keystrokes("up");
1231 cx.background_executor.run_until_parked();
1232 palette.read_with(cx, |palette, cx| {
1233 assert_eq!(palette.query(cx), "beta");
1234 });
1235
1236 // Press up - should get "alpha"
1237 cx.simulate_keystrokes("up");
1238 cx.background_executor.run_until_parked();
1239 palette.read_with(cx, |palette, cx| {
1240 assert_eq!(palette.query(cx), "alpha");
1241 });
1242
1243 // Press down - should get "beta"
1244 cx.simulate_keystrokes("down");
1245 cx.background_executor.run_until_parked();
1246 palette.read_with(cx, |palette, cx| {
1247 assert_eq!(palette.query(cx), "beta");
1248 });
1249
1250 // Press down - should get "gamma"
1251 cx.simulate_keystrokes("down");
1252 cx.background_executor.run_until_parked();
1253 palette.read_with(cx, |palette, cx| {
1254 assert_eq!(palette.query(cx), "gamma");
1255 });
1256
1257 // Press down - should return to empty string (exit history mode)
1258 cx.simulate_keystrokes("down");
1259 cx.background_executor.run_until_parked();
1260 palette.read_with(cx, |palette, cx| {
1261 assert_eq!(palette.query(cx), "");
1262 });
1263 }
1264}