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