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