1mod persistence;
2
3use std::{
4 cmp::{self, Reverse},
5 collections::HashMap,
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::COMMAND_PALETTE_HISTORY;
22use picker::{Picker, PickerDelegate};
23use postage::{sink::Sink, stream::Stream};
24use settings::Settings;
25use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
26use util::ResultExt;
27use workspace::{ModalView, Workspace, WorkspaceSettings};
28use zed_actions::{OpenZedUrl, command_palette::Toggle};
29
30pub fn init(cx: &mut App) {
31 client::init_settings(cx);
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}
168
169struct Command {
170 name: String,
171 action: Box<dyn Action>,
172}
173
174impl Clone for Command {
175 fn clone(&self) -> Self {
176 Self {
177 name: self.name.clone(),
178 action: self.action.boxed_clone(),
179 }
180 }
181}
182
183impl CommandPaletteDelegate {
184 fn new(
185 command_palette: WeakEntity<CommandPalette>,
186 workspace: WeakEntity<Workspace>,
187 commands: Vec<Command>,
188 previous_focus_handle: FocusHandle,
189 ) -> Self {
190 Self {
191 command_palette,
192 workspace,
193 all_commands: commands.clone(),
194 matches: vec![],
195 commands,
196 selected_ix: 0,
197 previous_focus_handle,
198 latest_query: String::new(),
199 updating_matches: None,
200 }
201 }
202
203 fn matches_updated(
204 &mut self,
205 query: String,
206 mut commands: Vec<Command>,
207 mut matches: Vec<StringMatch>,
208 intercept_result: CommandInterceptResult,
209 _: &mut Context<Picker<Self>>,
210 ) {
211 self.updating_matches.take();
212 self.latest_query = query;
213
214 let mut new_matches = Vec::new();
215
216 for CommandInterceptItem {
217 action,
218 string,
219 positions,
220 } in intercept_result.results
221 {
222 if let Some(idx) = matches
223 .iter()
224 .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
225 {
226 matches.remove(idx);
227 }
228 commands.push(Command {
229 name: string.clone(),
230 action,
231 });
232 new_matches.push(StringMatch {
233 candidate_id: commands.len() - 1,
234 string,
235 positions,
236 score: 0.0,
237 })
238 }
239 if !intercept_result.exclusive {
240 new_matches.append(&mut matches);
241 }
242 self.commands = commands;
243 self.matches = new_matches;
244 if self.matches.is_empty() {
245 self.selected_ix = 0;
246 } else {
247 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
248 }
249 }
250
251 /// Hit count for each command in the palette.
252 /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
253 /// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
254 fn hit_counts(&self) -> HashMap<String, u16> {
255 if let Ok(commands) = COMMAND_PALETTE_HISTORY.list_commands_used() {
256 commands
257 .into_iter()
258 .map(|command| (command.command_name, command.invocations))
259 .collect()
260 } else {
261 HashMap::new()
262 }
263 }
264
265 fn selected_command(&self) -> Option<&Command> {
266 let action_ix = self
267 .matches
268 .get(self.selected_ix)
269 .map(|m| m.candidate_id)
270 .unwrap_or(self.selected_ix);
271 // this gets called in headless tests where there are no commands loaded
272 // so we need to return an Option here
273 self.commands.get(action_ix)
274 }
275}
276
277impl PickerDelegate for CommandPaletteDelegate {
278 type ListItem = ListItem;
279
280 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
281 "Execute a command...".into()
282 }
283
284 fn match_count(&self) -> usize {
285 self.matches.len()
286 }
287
288 fn selected_index(&self) -> usize {
289 self.selected_ix
290 }
291
292 fn set_selected_index(
293 &mut self,
294 ix: usize,
295 _window: &mut Window,
296 _: &mut Context<Picker<Self>>,
297 ) {
298 self.selected_ix = ix;
299 }
300
301 fn update_matches(
302 &mut self,
303 mut query: String,
304 window: &mut Window,
305 cx: &mut Context<Picker<Self>>,
306 ) -> gpui::Task<()> {
307 let settings = WorkspaceSettings::get_global(cx);
308 if let Some(alias) = settings.command_aliases.get(&query) {
309 query = alias.to_string();
310 }
311
312 let workspace = self.workspace.clone();
313
314 let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
315
316 let (mut tx, mut rx) = postage::dispatch::channel(1);
317
318 let query_str = query.as_str();
319 let is_zed_link = parse_zed_link(query_str, cx).is_some();
320
321 let task = cx.background_spawn({
322 let mut commands = self.all_commands.clone();
323 let hit_counts = self.hit_counts();
324 let executor = cx.background_executor().clone();
325 let query = normalize_action_query(query_str);
326 let query_for_link = query_str.to_string();
327 async move {
328 commands.sort_by_key(|action| {
329 (
330 Reverse(hit_counts.get(&action.name).cloned()),
331 action.name.clone(),
332 )
333 });
334
335 let candidates = commands
336 .iter()
337 .enumerate()
338 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
339 .collect::<Vec<_>>();
340
341 let matches = fuzzy::match_strings(
342 &candidates,
343 &query,
344 true,
345 true,
346 10000,
347 &Default::default(),
348 executor,
349 )
350 .await;
351
352 let intercept_result = if is_zed_link {
353 CommandInterceptResult {
354 results: vec![CommandInterceptItem {
355 action: OpenZedUrl {
356 url: query_for_link.clone(),
357 }
358 .boxed_clone(),
359 string: query_for_link,
360 positions: vec![],
361 }],
362 exclusive: false,
363 }
364 } else if let Some(task) = intercept_task {
365 task.await
366 } else {
367 CommandInterceptResult::default()
368 };
369
370 tx.send((commands, matches, intercept_result))
371 .await
372 .log_err();
373 }
374 });
375
376 self.updating_matches = Some((task, rx.clone()));
377
378 cx.spawn_in(window, async move |picker, cx| {
379 let Some((commands, matches, intercept_result)) = rx.recv().await else {
380 return;
381 };
382
383 picker
384 .update(cx, |picker, cx| {
385 picker
386 .delegate
387 .matches_updated(query, commands, matches, intercept_result, cx)
388 })
389 .log_err();
390 })
391 }
392
393 fn finalize_update_matches(
394 &mut self,
395 query: String,
396 duration: Duration,
397 _: &mut Window,
398 cx: &mut Context<Picker<Self>>,
399 ) -> bool {
400 let Some((task, rx)) = self.updating_matches.take() else {
401 return true;
402 };
403
404 match cx
405 .background_executor()
406 .block_with_timeout(duration, rx.clone().recv())
407 {
408 Ok(Some((commands, matches, interceptor_result))) => {
409 self.matches_updated(query, commands, matches, interceptor_result, cx);
410 true
411 }
412 _ => {
413 self.updating_matches = Some((task, rx));
414 false
415 }
416 }
417 }
418
419 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
420 self.command_palette
421 .update(cx, |_, cx| cx.emit(DismissEvent))
422 .log_err();
423 }
424
425 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
426 if secondary {
427 let Some(selected_command) = self.selected_command() else {
428 return;
429 };
430 let action_name = selected_command.action.name();
431 let open_keymap = Box::new(zed_actions::ChangeKeybinding {
432 action: action_name.to_string(),
433 });
434 window.dispatch_action(open_keymap, cx);
435 self.dismissed(window, cx);
436 return;
437 }
438
439 if self.matches.is_empty() {
440 self.dismissed(window, cx);
441 return;
442 }
443 let action_ix = self.matches[self.selected_ix].candidate_id;
444 let command = self.commands.swap_remove(action_ix);
445 telemetry::event!(
446 "Action Invoked",
447 source = "command palette",
448 action = command.name
449 );
450 self.matches.clear();
451 self.commands.clear();
452 let command_name = command.name.clone();
453 let latest_query = self.latest_query.clone();
454 cx.background_spawn(async move {
455 COMMAND_PALETTE_HISTORY
456 .write_command_invocation(command_name, latest_query)
457 .await
458 })
459 .detach_and_log_err(cx);
460 let action = command.action;
461 window.focus(&self.previous_focus_handle);
462 self.dismissed(window, cx);
463 window.dispatch_action(action, cx);
464 }
465
466 fn render_match(
467 &self,
468 ix: usize,
469 selected: bool,
470 _: &mut Window,
471 cx: &mut Context<Picker<Self>>,
472 ) -> Option<Self::ListItem> {
473 let matching_command = self.matches.get(ix)?;
474 let command = self.commands.get(matching_command.candidate_id)?;
475
476 Some(
477 ListItem::new(ix)
478 .inset(true)
479 .spacing(ListItemSpacing::Sparse)
480 .toggle_state(selected)
481 .child(
482 h_flex()
483 .w_full()
484 .py_px()
485 .justify_between()
486 .child(HighlightedLabel::new(
487 command.name.clone(),
488 matching_command.positions.clone(),
489 ))
490 .child(KeyBinding::for_action_in(
491 &*command.action,
492 &self.previous_focus_handle,
493 cx,
494 )),
495 ),
496 )
497 }
498
499 fn render_footer(
500 &self,
501 window: &mut Window,
502 cx: &mut Context<Picker<Self>>,
503 ) -> Option<AnyElement> {
504 let selected_command = self.selected_command()?;
505 let keybind =
506 KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
507
508 let focus_handle = &self.previous_focus_handle;
509 let keybinding_buttons = if keybind.has_binding(window) {
510 Button::new("change", "Change Keybinding…")
511 .key_binding(
512 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
513 .map(|kb| kb.size(rems_from_px(12.))),
514 )
515 .on_click(move |_, window, cx| {
516 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
517 })
518 } else {
519 Button::new("add", "Add Keybinding…")
520 .key_binding(
521 KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
522 .map(|kb| kb.size(rems_from_px(12.))),
523 )
524 .on_click(move |_, window, cx| {
525 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
526 })
527 };
528
529 Some(
530 h_flex()
531 .w_full()
532 .p_1p5()
533 .gap_1()
534 .justify_end()
535 .border_t_1()
536 .border_color(cx.theme().colors().border_variant)
537 .child(keybinding_buttons)
538 .child(
539 Button::new("run-action", "Run")
540 .key_binding(
541 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
542 .map(|kb| kb.size(rems_from_px(12.))),
543 )
544 .on_click(|_, window, cx| {
545 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
546 }),
547 )
548 .into_any(),
549 )
550 }
551}
552
553pub fn humanize_action_name(name: &str) -> String {
554 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
555 let mut result = String::with_capacity(capacity);
556 for char in name.chars() {
557 if char == ':' {
558 if result.ends_with(':') {
559 result.push(' ');
560 } else {
561 result.push(':');
562 }
563 } else if char == '_' {
564 result.push(' ');
565 } else if char.is_uppercase() {
566 if !result.ends_with(' ') {
567 result.push(' ');
568 }
569 result.extend(char.to_lowercase());
570 } else {
571 result.push(char);
572 }
573 }
574 result
575}
576
577impl std::fmt::Debug for Command {
578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579 f.debug_struct("Command")
580 .field("name", &self.name)
581 .finish_non_exhaustive()
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use std::sync::Arc;
588
589 use super::*;
590 use editor::Editor;
591 use go_to_line::GoToLine;
592 use gpui::TestAppContext;
593 use language::Point;
594 use project::Project;
595 use settings::KeymapFile;
596 use workspace::{AppState, Workspace};
597
598 #[test]
599 fn test_humanize_action_name() {
600 assert_eq!(
601 humanize_action_name("editor::GoToDefinition"),
602 "editor: go to definition"
603 );
604 assert_eq!(
605 humanize_action_name("editor::Backspace"),
606 "editor: backspace"
607 );
608 assert_eq!(
609 humanize_action_name("go_to_line::Deploy"),
610 "go to line: deploy"
611 );
612 }
613
614 #[test]
615 fn test_normalize_query() {
616 assert_eq!(
617 normalize_action_query("editor: backspace"),
618 "editor: backspace"
619 );
620 assert_eq!(
621 normalize_action_query("editor: backspace"),
622 "editor: backspace"
623 );
624 assert_eq!(
625 normalize_action_query("editor: backspace"),
626 "editor: backspace"
627 );
628 assert_eq!(
629 normalize_action_query("editor::GoToDefinition"),
630 "editor:GoToDefinition"
631 );
632 assert_eq!(
633 normalize_action_query("editor::::GoToDefinition"),
634 "editor:GoToDefinition"
635 );
636 assert_eq!(
637 normalize_action_query("editor: :GoToDefinition"),
638 "editor: :GoToDefinition"
639 );
640 }
641
642 #[gpui::test]
643 async fn test_command_palette(cx: &mut TestAppContext) {
644 let app_state = init_test(cx);
645 let project = Project::test(app_state.fs.clone(), [], cx).await;
646 let (workspace, cx) =
647 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
648
649 let editor = cx.new_window_entity(|window, cx| {
650 let mut editor = Editor::single_line(window, cx);
651 editor.set_text("abc", window, cx);
652 editor
653 });
654
655 workspace.update_in(cx, |workspace, window, cx| {
656 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
657 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
658 });
659
660 cx.simulate_keystrokes("cmd-shift-p");
661
662 let palette = workspace.update(cx, |workspace, cx| {
663 workspace
664 .active_modal::<CommandPalette>(cx)
665 .unwrap()
666 .read(cx)
667 .picker
668 .clone()
669 });
670
671 palette.read_with(cx, |palette, _| {
672 assert!(palette.delegate.commands.len() > 5);
673 let is_sorted =
674 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
675 assert!(is_sorted(&palette.delegate.commands));
676 });
677
678 cx.simulate_input("bcksp");
679
680 palette.read_with(cx, |palette, _| {
681 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
682 });
683
684 cx.simulate_keystrokes("enter");
685
686 workspace.update(cx, |workspace, cx| {
687 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
688 assert_eq!(editor.read(cx).text(cx), "ab")
689 });
690
691 // Add namespace filter, and redeploy the palette
692 cx.update(|_window, cx| {
693 CommandPaletteFilter::update_global(cx, |filter, _| {
694 filter.hide_namespace("editor");
695 });
696 });
697
698 cx.simulate_keystrokes("cmd-shift-p");
699 cx.simulate_input("bcksp");
700
701 let palette = workspace.update(cx, |workspace, cx| {
702 workspace
703 .active_modal::<CommandPalette>(cx)
704 .unwrap()
705 .read(cx)
706 .picker
707 .clone()
708 });
709 palette.read_with(cx, |palette, _| {
710 assert!(palette.delegate.matches.is_empty())
711 });
712 }
713 #[gpui::test]
714 async fn test_normalized_matches(cx: &mut TestAppContext) {
715 let app_state = init_test(cx);
716 let project = Project::test(app_state.fs.clone(), [], cx).await;
717 let (workspace, cx) =
718 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
719
720 let editor = cx.new_window_entity(|window, cx| {
721 let mut editor = Editor::single_line(window, cx);
722 editor.set_text("abc", window, cx);
723 editor
724 });
725
726 workspace.update_in(cx, |workspace, window, cx| {
727 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
728 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
729 });
730
731 // Test normalize (trimming whitespace and double colons)
732 cx.simulate_keystrokes("cmd-shift-p");
733
734 let palette = workspace.update(cx, |workspace, cx| {
735 workspace
736 .active_modal::<CommandPalette>(cx)
737 .unwrap()
738 .read(cx)
739 .picker
740 .clone()
741 });
742
743 cx.simulate_input("Editor:: Backspace");
744 palette.read_with(cx, |palette, _| {
745 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
746 });
747 }
748
749 #[gpui::test]
750 async fn test_go_to_line(cx: &mut TestAppContext) {
751 let app_state = init_test(cx);
752 let project = Project::test(app_state.fs.clone(), [], cx).await;
753 let (workspace, cx) =
754 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
755
756 cx.simulate_keystrokes("cmd-n");
757
758 let editor = workspace.update(cx, |workspace, cx| {
759 workspace.active_item_as::<Editor>(cx).unwrap()
760 });
761 editor.update_in(cx, |editor, window, cx| {
762 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
763 });
764
765 cx.simulate_keystrokes("cmd-shift-p");
766 cx.simulate_input("go to line: Toggle");
767 cx.simulate_keystrokes("enter");
768
769 workspace.update(cx, |workspace, cx| {
770 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
771 });
772
773 cx.simulate_keystrokes("3 enter");
774
775 editor.update_in(cx, |editor, window, cx| {
776 assert!(editor.focus_handle(cx).is_focused(window));
777 assert_eq!(
778 editor
779 .selections
780 .last::<Point>(&editor.display_snapshot(cx))
781 .range()
782 .start,
783 Point::new(2, 0)
784 );
785 });
786 }
787
788 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
789 cx.update(|cx| {
790 let app_state = AppState::test(cx);
791 theme::init(theme::LoadThemes::JustBase, cx);
792 language::init(cx);
793 editor::init(cx);
794 menu::init();
795 go_to_line::init(cx);
796 workspace::init(app_state.clone(), cx);
797 init(cx);
798 Project::init_settings(cx);
799 cx.bind_keys(KeymapFile::load_panic_on_failure(
800 r#"[
801 {
802 "bindings": {
803 "cmd-n": "workspace::NewFile",
804 "enter": "menu::Confirm",
805 "cmd-shift-p": "command_palette::Toggle"
806 }
807 }
808 ]"#,
809 cx,
810 ));
811 app_state
812 })
813 }
814}