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