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::{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 fn is_command_palette(&self) -> bool {
38 true
39 }
40}
41
42pub struct CommandPalette {
43 picker: Entity<Picker<CommandPaletteDelegate>>,
44}
45
46/// Removes subsequent whitespace characters and double colons from the query.
47///
48/// This improves the likelihood of a match by either humanized name or keymap-style name.
49pub fn normalize_action_query(input: &str) -> String {
50 let mut result = String::with_capacity(input.len());
51 let mut last_char = None;
52
53 for char in input.trim().chars() {
54 match (last_char, char) {
55 (Some(':'), ':') => continue,
56 (Some(last_char), char) if last_char.is_whitespace() && char.is_whitespace() => {
57 continue;
58 }
59 _ => {
60 last_char = Some(char);
61 }
62 }
63 result.push(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,
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 let action_ix = self
358 .matches
359 .get(self.selected_ix)
360 .map(|m| m.candidate_id)
361 .unwrap_or(self.selected_ix);
362 // this gets called in headless tests where there are no commands loaded
363 // so we need to return an Option here
364 self.commands.get(action_ix)
365 }
366
367 #[cfg(any(test, feature = "test-support"))]
368 pub fn seed_history(&mut self, queries: &[&str]) {
369 self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect());
370 }
371}
372
373impl PickerDelegate for CommandPaletteDelegate {
374 type ListItem = ListItem;
375
376 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
377 "Execute a command...".into()
378 }
379
380 fn select_history(
381 &mut self,
382 direction: Direction,
383 query: &str,
384 _window: &mut Window,
385 cx: &mut App,
386 ) -> Option<String> {
387 match direction {
388 Direction::Up => {
389 let should_use_history =
390 self.selected_ix == 0 || self.query_history.is_navigating();
391 if should_use_history {
392 if let Some(query) = self
393 .query_history
394 .previous(query, cx)
395 .map(|s| s.to_string())
396 {
397 return Some(query);
398 }
399 }
400 }
401 Direction::Down => {
402 if self.query_history.is_navigating() {
403 if let Some(query) = self.query_history.next(query, cx).map(|s| s.to_string()) {
404 return Some(query);
405 } else {
406 let prefix = self.query_history.prefix.take().unwrap_or_default();
407 self.query_history.reset_cursor();
408 return Some(prefix);
409 }
410 }
411 }
412 }
413 None
414 }
415
416 fn match_count(&self) -> usize {
417 self.matches.len()
418 }
419
420 fn selected_index(&self) -> usize {
421 self.selected_ix
422 }
423
424 fn set_selected_index(
425 &mut self,
426 ix: usize,
427 _window: &mut Window,
428 _: &mut Context<Picker<Self>>,
429 ) {
430 self.selected_ix = ix;
431 }
432
433 fn update_matches(
434 &mut self,
435 mut query: String,
436 window: &mut Window,
437 cx: &mut Context<Picker<Self>>,
438 ) -> gpui::Task<()> {
439 let settings = WorkspaceSettings::get_global(cx);
440 if let Some(alias) = settings.command_aliases.get(&query) {
441 query = alias.to_string();
442 }
443
444 let workspace = self.workspace.clone();
445
446 let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
447
448 let (mut tx, mut rx) = postage::dispatch::channel(1);
449
450 let query_str = query.as_str();
451 let is_zed_link = parse_zed_link(query_str, cx).is_some();
452
453 let task = cx.background_spawn({
454 let mut commands = self.all_commands.clone();
455 let hit_counts = self.hit_counts(cx);
456 let executor = cx.background_executor().clone();
457 let query = normalize_action_query(query_str);
458 let query_for_link = query_str.to_string();
459 async move {
460 commands.sort_by_key(|action| {
461 (
462 Reverse(hit_counts.get(&action.name).cloned()),
463 action.name.clone(),
464 )
465 });
466
467 let candidates = commands
468 .iter()
469 .enumerate()
470 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
471 .collect::<Vec<_>>();
472
473 let matches = fuzzy::match_strings(
474 &candidates,
475 &query,
476 true,
477 true,
478 10000,
479 &Default::default(),
480 executor,
481 )
482 .await;
483
484 let intercept_result = if is_zed_link {
485 CommandInterceptResult {
486 results: vec![CommandInterceptItem {
487 action: OpenZedUrl {
488 url: query_for_link.clone(),
489 }
490 .boxed_clone(),
491 string: query_for_link,
492 positions: vec![],
493 }],
494 exclusive: false,
495 }
496 } else if let Some(task) = intercept_task {
497 task.await
498 } else {
499 CommandInterceptResult::default()
500 };
501
502 tx.send((commands, matches, intercept_result))
503 .await
504 .log_err();
505 }
506 });
507
508 self.updating_matches = Some((task, rx.clone()));
509
510 cx.spawn_in(window, async move |picker, cx| {
511 let Some((commands, matches, intercept_result)) = rx.recv().await else {
512 return;
513 };
514
515 picker
516 .update(cx, |picker, cx| {
517 picker
518 .delegate
519 .matches_updated(query, commands, matches, intercept_result, cx)
520 })
521 .ok();
522 })
523 }
524
525 fn finalize_update_matches(
526 &mut self,
527 query: String,
528 duration: Duration,
529 _: &mut Window,
530 cx: &mut Context<Picker<Self>>,
531 ) -> bool {
532 let Some((task, rx)) = self.updating_matches.take() else {
533 return true;
534 };
535
536 match cx
537 .foreground_executor()
538 .block_with_timeout(duration, rx.clone().recv())
539 {
540 Ok(Some((commands, matches, interceptor_result))) => {
541 self.matches_updated(query, commands, matches, interceptor_result, cx);
542 true
543 }
544 _ => {
545 self.updating_matches = Some((task, rx));
546 false
547 }
548 }
549 }
550
551 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
552 self.command_palette
553 .update(cx, |_, cx| cx.emit(DismissEvent))
554 .ok();
555 }
556
557 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
558 if secondary {
559 let Some(selected_command) = self.selected_command() else {
560 return;
561 };
562 let action_name = selected_command.action.name();
563 let open_keymap = Box::new(zed_actions::ChangeKeybinding {
564 action: action_name.to_string(),
565 });
566 window.dispatch_action(open_keymap, cx);
567 self.dismissed(window, cx);
568 return;
569 }
570
571 if self.matches.is_empty() {
572 self.dismissed(window, cx);
573 return;
574 }
575
576 if !self.latest_query.is_empty() {
577 self.query_history.add(self.latest_query.clone(), cx);
578 self.query_history.reset_cursor();
579 }
580
581 let action_ix = self.matches[self.selected_ix].candidate_id;
582 let command = self.commands.swap_remove(action_ix);
583 telemetry::event!(
584 "Action Invoked",
585 source = "command palette",
586 action = command.name
587 );
588 self.matches.clear();
589 self.commands.clear();
590 let command_name = command.name.clone();
591 let latest_query = self.latest_query.clone();
592 let db = CommandPaletteDB::global(cx);
593 cx.background_spawn(async move {
594 db.write_command_invocation(command_name, latest_query)
595 .await
596 })
597 .detach_and_log_err(cx);
598 let action = command.action;
599 window.focus(&self.previous_focus_handle, cx);
600 self.dismissed(window, cx);
601 window.dispatch_action(action, cx);
602 }
603
604 fn render_match(
605 &self,
606 ix: usize,
607 selected: bool,
608 _: &mut Window,
609 cx: &mut Context<Picker<Self>>,
610 ) -> Option<Self::ListItem> {
611 let matching_command = self.matches.get(ix)?;
612 let command = self.commands.get(matching_command.candidate_id)?;
613
614 Some(
615 ListItem::new(ix)
616 .inset(true)
617 .spacing(ListItemSpacing::Sparse)
618 .toggle_state(selected)
619 .child(
620 h_flex()
621 .w_full()
622 .py_px()
623 .justify_between()
624 .child(HighlightedLabel::new(
625 command.name.clone(),
626 matching_command.positions.clone(),
627 ))
628 .child(KeyBinding::for_action_in(
629 &*command.action,
630 &self.previous_focus_handle,
631 cx,
632 )),
633 ),
634 )
635 }
636
637 fn render_footer(
638 &self,
639 window: &mut Window,
640 cx: &mut Context<Picker<Self>>,
641 ) -> Option<AnyElement> {
642 let selected_command = self.selected_command()?;
643 let keybind =
644 KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
645
646 let focus_handle = &self.previous_focus_handle;
647 let keybinding_buttons = if keybind.has_binding(window) {
648 Button::new("change", "Change Keybinding…")
649 .key_binding(
650 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
651 .map(|kb| kb.size(rems_from_px(12.))),
652 )
653 .on_click(move |_, window, cx| {
654 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
655 })
656 } else {
657 Button::new("add", "Add Keybinding…")
658 .key_binding(
659 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
660 .map(|kb| kb.size(rems_from_px(12.))),
661 )
662 .on_click(move |_, window, cx| {
663 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
664 })
665 };
666
667 Some(
668 h_flex()
669 .w_full()
670 .p_1p5()
671 .gap_1()
672 .justify_end()
673 .border_t_1()
674 .border_color(cx.theme().colors().border_variant)
675 .child(keybinding_buttons)
676 .child(
677 Button::new("run-action", "Run")
678 .key_binding(
679 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
680 .map(|kb| kb.size(rems_from_px(12.))),
681 )
682 .on_click(|_, window, cx| {
683 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
684 }),
685 )
686 .into_any(),
687 )
688 }
689}
690
691pub fn humanize_action_name(name: &str) -> String {
692 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
693 let mut result = String::with_capacity(capacity);
694 for char in name.chars() {
695 if char == ':' {
696 if result.ends_with(':') {
697 result.push(' ');
698 } else {
699 result.push(':');
700 }
701 } else if char == '_' {
702 result.push(' ');
703 } else if char.is_uppercase() {
704 if !result.ends_with(' ') {
705 result.push(' ');
706 }
707 result.extend(char.to_lowercase());
708 } else {
709 result.push(char);
710 }
711 }
712 result
713}
714
715impl std::fmt::Debug for Command {
716 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717 f.debug_struct("Command")
718 .field("name", &self.name)
719 .finish_non_exhaustive()
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use std::sync::Arc;
726
727 use super::*;
728 use editor::Editor;
729 use go_to_line::GoToLine;
730 use gpui::{TestAppContext, VisualTestContext};
731 use language::Point;
732 use project::Project;
733 use settings::KeymapFile;
734 use workspace::{AppState, MultiWorkspace, Workspace};
735
736 #[test]
737 fn test_humanize_action_name() {
738 assert_eq!(
739 humanize_action_name("editor::GoToDefinition"),
740 "editor: go to definition"
741 );
742 assert_eq!(
743 humanize_action_name("editor::Backspace"),
744 "editor: backspace"
745 );
746 assert_eq!(
747 humanize_action_name("go_to_line::Deploy"),
748 "go to line: deploy"
749 );
750 }
751
752 #[test]
753 fn test_normalize_query() {
754 assert_eq!(
755 normalize_action_query("editor: backspace"),
756 "editor: backspace"
757 );
758 assert_eq!(
759 normalize_action_query("editor: backspace"),
760 "editor: backspace"
761 );
762 assert_eq!(
763 normalize_action_query("editor: backspace"),
764 "editor: backspace"
765 );
766 assert_eq!(
767 normalize_action_query("editor::GoToDefinition"),
768 "editor:GoToDefinition"
769 );
770 assert_eq!(
771 normalize_action_query("editor::::GoToDefinition"),
772 "editor:GoToDefinition"
773 );
774 assert_eq!(
775 normalize_action_query("editor: :GoToDefinition"),
776 "editor: :GoToDefinition"
777 );
778 }
779
780 #[gpui::test]
781 async fn test_command_palette(cx: &mut TestAppContext) {
782 let app_state = init_test(cx);
783 let db = cx.update(|cx| persistence::CommandPaletteDB::global(cx));
784 db.clear_all().await.unwrap();
785 let project = Project::test(app_state.fs.clone(), [], cx).await;
786 let (multi_workspace, cx) =
787 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
788 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
789
790 let editor = cx.new_window_entity(|window, cx| {
791 let mut editor = Editor::single_line(window, cx);
792 editor.set_text("abc", window, cx);
793 editor
794 });
795
796 workspace.update_in(cx, |workspace, window, cx| {
797 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
798 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
799 });
800
801 cx.simulate_keystrokes("cmd-shift-p");
802
803 let palette = workspace.update(cx, |workspace, cx| {
804 workspace
805 .active_modal::<CommandPalette>(cx)
806 .unwrap()
807 .read(cx)
808 .picker
809 .clone()
810 });
811
812 palette.read_with(cx, |palette, _| {
813 assert!(palette.delegate.commands.len() > 5);
814 let is_sorted =
815 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
816 assert!(is_sorted(&palette.delegate.commands));
817 });
818
819 cx.simulate_input("bcksp");
820
821 palette.read_with(cx, |palette, _| {
822 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
823 });
824
825 cx.simulate_keystrokes("enter");
826
827 workspace.update(cx, |workspace, cx| {
828 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
829 assert_eq!(editor.read(cx).text(cx), "ab")
830 });
831
832 // Add namespace filter, and redeploy the palette
833 cx.update(|_window, cx| {
834 CommandPaletteFilter::update_global(cx, |filter, _| {
835 filter.hide_namespace("editor");
836 });
837 });
838
839 cx.simulate_keystrokes("cmd-shift-p");
840 cx.simulate_input("bcksp");
841
842 let palette = workspace.update(cx, |workspace, cx| {
843 workspace
844 .active_modal::<CommandPalette>(cx)
845 .unwrap()
846 .read(cx)
847 .picker
848 .clone()
849 });
850 palette.read_with(cx, |palette, _| {
851 assert!(palette.delegate.matches.is_empty())
852 });
853 }
854 #[gpui::test]
855 async fn test_normalized_matches(cx: &mut TestAppContext) {
856 let app_state = init_test(cx);
857 let project = Project::test(app_state.fs.clone(), [], cx).await;
858 let (multi_workspace, cx) =
859 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
860 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
861
862 let editor = cx.new_window_entity(|window, cx| {
863 let mut editor = Editor::single_line(window, cx);
864 editor.set_text("abc", window, cx);
865 editor
866 });
867
868 workspace.update_in(cx, |workspace, window, cx| {
869 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
870 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
871 });
872
873 // Test normalize (trimming whitespace and double colons)
874 cx.simulate_keystrokes("cmd-shift-p");
875
876 let palette = workspace.update(cx, |workspace, cx| {
877 workspace
878 .active_modal::<CommandPalette>(cx)
879 .unwrap()
880 .read(cx)
881 .picker
882 .clone()
883 });
884
885 cx.simulate_input("Editor:: Backspace");
886 palette.read_with(cx, |palette, _| {
887 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
888 });
889 }
890
891 #[gpui::test]
892 async fn test_go_to_line(cx: &mut TestAppContext) {
893 let app_state = init_test(cx);
894 let project = Project::test(app_state.fs.clone(), [], cx).await;
895 let (multi_workspace, cx) =
896 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
897 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
898
899 cx.simulate_keystrokes("cmd-n");
900
901 let editor = workspace.update(cx, |workspace, cx| {
902 workspace.active_item_as::<Editor>(cx).unwrap()
903 });
904 editor.update_in(cx, |editor, window, cx| {
905 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
906 });
907
908 cx.simulate_keystrokes("cmd-shift-p");
909 cx.simulate_input("go to line: Toggle");
910 cx.simulate_keystrokes("enter");
911
912 workspace.update(cx, |workspace, cx| {
913 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
914 });
915
916 cx.simulate_keystrokes("3 enter");
917
918 editor.update_in(cx, |editor, window, cx| {
919 assert!(editor.focus_handle(cx).is_focused(window));
920 assert_eq!(
921 editor
922 .selections
923 .last::<Point>(&editor.display_snapshot(cx))
924 .range()
925 .start,
926 Point::new(2, 0)
927 );
928 });
929 }
930
931 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
932 cx.update(|cx| {
933 let app_state = AppState::test(cx);
934 theme::init(theme::LoadThemes::JustBase, cx);
935 editor::init(cx);
936 menu::init();
937 go_to_line::init(cx);
938 workspace::init(app_state.clone(), cx);
939 init(cx);
940 cx.bind_keys(KeymapFile::load_panic_on_failure(
941 r#"[
942 {
943 "bindings": {
944 "cmd-n": "workspace::NewFile",
945 "enter": "menu::Confirm",
946 "cmd-shift-p": "command_palette::Toggle",
947 "up": "menu::SelectPrevious",
948 "down": "menu::SelectNext"
949 }
950 }
951 ]"#,
952 cx,
953 ));
954 app_state
955 })
956 }
957
958 fn open_palette_with_history(
959 workspace: &Entity<Workspace>,
960 history: &[&str],
961 cx: &mut VisualTestContext,
962 ) -> Entity<Picker<CommandPaletteDelegate>> {
963 cx.simulate_keystrokes("cmd-shift-p");
964 cx.run_until_parked();
965
966 let palette = workspace.update(cx, |workspace, cx| {
967 workspace
968 .active_modal::<CommandPalette>(cx)
969 .unwrap()
970 .read(cx)
971 .picker
972 .clone()
973 });
974
975 palette.update(cx, |palette, _cx| {
976 palette.delegate.seed_history(history);
977 });
978
979 palette
980 }
981
982 #[gpui::test]
983 async fn test_history_navigation_basic(cx: &mut TestAppContext) {
984 let app_state = init_test(cx);
985 let project = Project::test(app_state.fs.clone(), [], cx).await;
986 let (multi_workspace, cx) =
987 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
988 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
989
990 let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx);
991
992 // Query should be empty initially
993 palette.read_with(cx, |palette, cx| {
994 assert_eq!(palette.query(cx), "");
995 });
996
997 // Press up - should load most recent query "select all"
998 cx.simulate_keystrokes("up");
999 cx.background_executor.run_until_parked();
1000 palette.read_with(cx, |palette, cx| {
1001 assert_eq!(palette.query(cx), "select all");
1002 });
1003
1004 // Press up again - should load "backspace"
1005 cx.simulate_keystrokes("up");
1006 cx.background_executor.run_until_parked();
1007 palette.read_with(cx, |palette, cx| {
1008 assert_eq!(palette.query(cx), "backspace");
1009 });
1010
1011 // Press down - should go back to "select all"
1012 cx.simulate_keystrokes("down");
1013 cx.background_executor.run_until_parked();
1014 palette.read_with(cx, |palette, cx| {
1015 assert_eq!(palette.query(cx), "select all");
1016 });
1017
1018 // Press down again - should clear query (exit history mode)
1019 cx.simulate_keystrokes("down");
1020 cx.background_executor.run_until_parked();
1021 palette.read_with(cx, |palette, cx| {
1022 assert_eq!(palette.query(cx), "");
1023 });
1024 }
1025
1026 #[gpui::test]
1027 async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) {
1028 let app_state = init_test(cx);
1029 let project = Project::test(app_state.fs.clone(), [], cx).await;
1030 let (multi_workspace, cx) =
1031 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1032 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1033
1034 let palette = open_palette_with_history(&workspace, &["backspace"], cx);
1035
1036 // Press up to enter history mode
1037 cx.simulate_keystrokes("up");
1038 cx.background_executor.run_until_parked();
1039 palette.read_with(cx, |palette, cx| {
1040 assert_eq!(palette.query(cx), "backspace");
1041 });
1042
1043 // Type something - should append to the history query
1044 cx.simulate_input("x");
1045 cx.background_executor.run_until_parked();
1046 palette.read_with(cx, |palette, cx| {
1047 assert_eq!(palette.query(cx), "backspacex");
1048 });
1049 }
1050
1051 #[gpui::test]
1052 async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) {
1053 let app_state = init_test(cx);
1054 let project = Project::test(app_state.fs.clone(), [], cx).await;
1055 let (multi_workspace, cx) =
1056 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1057 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1058
1059 let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx);
1060
1061 // Open palette with a query that has multiple matches
1062 cx.simulate_input("editor");
1063 cx.background_executor.run_until_parked();
1064
1065 // Should have multiple matches, selected_ix should be 0
1066 palette.read_with(cx, |palette, _| {
1067 assert!(palette.delegate.matches.len() > 1);
1068 assert_eq!(palette.delegate.selected_ix, 0);
1069 });
1070
1071 // Press down - should navigate to next suggestion (not history)
1072 cx.simulate_keystrokes("down");
1073 cx.background_executor.run_until_parked();
1074 palette.read_with(cx, |palette, _| {
1075 assert_eq!(palette.delegate.selected_ix, 1);
1076 });
1077
1078 // Press up - should go back to first suggestion
1079 cx.simulate_keystrokes("up");
1080 cx.background_executor.run_until_parked();
1081 palette.read_with(cx, |palette, _| {
1082 assert_eq!(palette.delegate.selected_ix, 0);
1083 });
1084
1085 // Press up again at top - should enter history mode and show previous query
1086 // that matches the "editor" prefix
1087 cx.simulate_keystrokes("up");
1088 cx.background_executor.run_until_parked();
1089 palette.read_with(cx, |palette, cx| {
1090 assert_eq!(palette.query(cx), "editor: open");
1091 });
1092 }
1093
1094 #[gpui::test]
1095 async fn test_history_prefix_search(cx: &mut TestAppContext) {
1096 let app_state = init_test(cx);
1097 let project = Project::test(app_state.fs.clone(), [], cx).await;
1098 let (multi_workspace, cx) =
1099 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1100 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1101
1102 let palette = open_palette_with_history(
1103 &workspace,
1104 &["open file", "select all", "select line", "backspace"],
1105 cx,
1106 );
1107
1108 // Type "sel" as a prefix
1109 cx.simulate_input("sel");
1110 cx.background_executor.run_until_parked();
1111
1112 // Press up - should get "select line" (most recent matching "sel")
1113 cx.simulate_keystrokes("up");
1114 cx.background_executor.run_until_parked();
1115 palette.read_with(cx, |palette, cx| {
1116 assert_eq!(palette.query(cx), "select line");
1117 });
1118
1119 // Press up again - should get "select all" (next matching "sel")
1120 cx.simulate_keystrokes("up");
1121 cx.background_executor.run_until_parked();
1122 palette.read_with(cx, |palette, cx| {
1123 assert_eq!(palette.query(cx), "select all");
1124 });
1125
1126 // Press up again - should stay at "select all" (no more matches for "sel")
1127 cx.simulate_keystrokes("up");
1128 cx.background_executor.run_until_parked();
1129 palette.read_with(cx, |palette, cx| {
1130 assert_eq!(palette.query(cx), "select all");
1131 });
1132
1133 // Press down - should go back to "select line"
1134 cx.simulate_keystrokes("down");
1135 cx.background_executor.run_until_parked();
1136 palette.read_with(cx, |palette, cx| {
1137 assert_eq!(palette.query(cx), "select line");
1138 });
1139
1140 // Press down again - should return to original prefix "sel"
1141 cx.simulate_keystrokes("down");
1142 cx.background_executor.run_until_parked();
1143 palette.read_with(cx, |palette, cx| {
1144 assert_eq!(palette.query(cx), "sel");
1145 });
1146 }
1147
1148 #[gpui::test]
1149 async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) {
1150 let app_state = init_test(cx);
1151 let project = Project::test(app_state.fs.clone(), [], cx).await;
1152 let (multi_workspace, cx) =
1153 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1154 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1155
1156 let palette =
1157 open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx);
1158
1159 // Type "xyz" as a prefix that doesn't match anything
1160 cx.simulate_input("xyz");
1161 cx.background_executor.run_until_parked();
1162
1163 // Press up - should stay at "xyz" (no matches)
1164 cx.simulate_keystrokes("up");
1165 cx.background_executor.run_until_parked();
1166 palette.read_with(cx, |palette, cx| {
1167 assert_eq!(palette.query(cx), "xyz");
1168 });
1169 }
1170
1171 #[gpui::test]
1172 async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) {
1173 let app_state = init_test(cx);
1174 let project = Project::test(app_state.fs.clone(), [], cx).await;
1175 let (multi_workspace, cx) =
1176 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1177 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1178
1179 let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx);
1180
1181 // With empty query, press up - should get "gamma" (most recent)
1182 cx.simulate_keystrokes("up");
1183 cx.background_executor.run_until_parked();
1184 palette.read_with(cx, |palette, cx| {
1185 assert_eq!(palette.query(cx), "gamma");
1186 });
1187
1188 // Press up - should get "beta"
1189 cx.simulate_keystrokes("up");
1190 cx.background_executor.run_until_parked();
1191 palette.read_with(cx, |palette, cx| {
1192 assert_eq!(palette.query(cx), "beta");
1193 });
1194
1195 // Press up - should get "alpha"
1196 cx.simulate_keystrokes("up");
1197 cx.background_executor.run_until_parked();
1198 palette.read_with(cx, |palette, cx| {
1199 assert_eq!(palette.query(cx), "alpha");
1200 });
1201
1202 // Press down - should get "beta"
1203 cx.simulate_keystrokes("down");
1204 cx.background_executor.run_until_parked();
1205 palette.read_with(cx, |palette, cx| {
1206 assert_eq!(palette.query(cx), "beta");
1207 });
1208
1209 // Press down - should get "gamma"
1210 cx.simulate_keystrokes("down");
1211 cx.background_executor.run_until_parked();
1212 palette.read_with(cx, |palette, cx| {
1213 assert_eq!(palette.query(cx), "gamma");
1214 });
1215
1216 // Press down - should return to empty string (exit history mode)
1217 cx.simulate_keystrokes("down");
1218 cx.background_executor.run_until_parked();
1219 palette.read_with(cx, |palette, cx| {
1220 assert_eq!(palette.query(cx), "");
1221 });
1222 }
1223}