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, h_flex, prelude::*, v_flex};
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, _cx: &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
266impl PickerDelegate for CommandPaletteDelegate {
267 type ListItem = ListItem;
268
269 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
270 "Execute a command...".into()
271 }
272
273 fn match_count(&self) -> usize {
274 self.matches.len()
275 }
276
277 fn selected_index(&self) -> usize {
278 self.selected_ix
279 }
280
281 fn set_selected_index(
282 &mut self,
283 ix: usize,
284 _window: &mut Window,
285 _: &mut Context<Picker<Self>>,
286 ) {
287 self.selected_ix = ix;
288 }
289
290 fn update_matches(
291 &mut self,
292 mut query: String,
293 window: &mut Window,
294 cx: &mut Context<Picker<Self>>,
295 ) -> gpui::Task<()> {
296 let settings = WorkspaceSettings::get_global(cx);
297 if let Some(alias) = settings.command_aliases.get(&query) {
298 query = alias.to_string();
299 }
300
301 let workspace = self.workspace.clone();
302
303 let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
304
305 let (mut tx, mut rx) = postage::dispatch::channel(1);
306
307 let query_str = query.as_str();
308 let is_zed_link = parse_zed_link(query_str, cx).is_some();
309
310 let task = cx.background_spawn({
311 let mut commands = self.all_commands.clone();
312 let hit_counts = self.hit_counts();
313 let executor = cx.background_executor().clone();
314 let query = normalize_action_query(query_str);
315 let query_for_link = query_str.to_string();
316 async move {
317 commands.sort_by_key(|action| {
318 (
319 Reverse(hit_counts.get(&action.name).cloned()),
320 action.name.clone(),
321 )
322 });
323
324 let candidates = commands
325 .iter()
326 .enumerate()
327 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
328 .collect::<Vec<_>>();
329
330 let matches = fuzzy::match_strings(
331 &candidates,
332 &query,
333 true,
334 true,
335 10000,
336 &Default::default(),
337 executor,
338 )
339 .await;
340
341 let intercept_result = if is_zed_link {
342 CommandInterceptResult {
343 results: vec![CommandInterceptItem {
344 action: OpenZedUrl {
345 url: query_for_link.clone(),
346 }
347 .boxed_clone(),
348 string: query_for_link,
349 positions: vec![],
350 }],
351 exclusive: false,
352 }
353 } else if let Some(task) = intercept_task {
354 task.await
355 } else {
356 CommandInterceptResult::default()
357 };
358
359 tx.send((commands, matches, intercept_result))
360 .await
361 .log_err();
362 }
363 });
364
365 self.updating_matches = Some((task, rx.clone()));
366
367 cx.spawn_in(window, async move |picker, cx| {
368 let Some((commands, matches, intercept_result)) = rx.recv().await else {
369 return;
370 };
371
372 picker
373 .update(cx, |picker, cx| {
374 picker
375 .delegate
376 .matches_updated(query, commands, matches, intercept_result, cx)
377 })
378 .log_err();
379 })
380 }
381
382 fn finalize_update_matches(
383 &mut self,
384 query: String,
385 duration: Duration,
386 _: &mut Window,
387 cx: &mut Context<Picker<Self>>,
388 ) -> bool {
389 let Some((task, rx)) = self.updating_matches.take() else {
390 return true;
391 };
392
393 match cx
394 .background_executor()
395 .block_with_timeout(duration, rx.clone().recv())
396 {
397 Ok(Some((commands, matches, interceptor_result))) => {
398 self.matches_updated(query, commands, matches, interceptor_result, cx);
399 true
400 }
401 _ => {
402 self.updating_matches = Some((task, rx));
403 false
404 }
405 }
406 }
407
408 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
409 self.command_palette
410 .update(cx, |_, cx| cx.emit(DismissEvent))
411 .log_err();
412 }
413
414 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
415 if self.matches.is_empty() {
416 self.dismissed(window, cx);
417 return;
418 }
419 let action_ix = self.matches[self.selected_ix].candidate_id;
420 let command = self.commands.swap_remove(action_ix);
421 telemetry::event!(
422 "Action Invoked",
423 source = "command palette",
424 action = command.name
425 );
426 self.matches.clear();
427 self.commands.clear();
428 let command_name = command.name.clone();
429 let latest_query = self.latest_query.clone();
430 cx.background_spawn(async move {
431 COMMAND_PALETTE_HISTORY
432 .write_command_invocation(command_name, latest_query)
433 .await
434 })
435 .detach_and_log_err(cx);
436 let action = command.action;
437 window.focus(&self.previous_focus_handle);
438 self.dismissed(window, cx);
439 window.dispatch_action(action, cx);
440 }
441
442 fn render_match(
443 &self,
444 ix: usize,
445 selected: bool,
446 window: &mut Window,
447 cx: &mut Context<Picker<Self>>,
448 ) -> Option<Self::ListItem> {
449 let matching_command = self.matches.get(ix)?;
450 let command = self.commands.get(matching_command.candidate_id)?;
451 Some(
452 ListItem::new(ix)
453 .inset(true)
454 .spacing(ListItemSpacing::Sparse)
455 .toggle_state(selected)
456 .child(
457 h_flex()
458 .w_full()
459 .py_px()
460 .justify_between()
461 .child(HighlightedLabel::new(
462 command.name.clone(),
463 matching_command.positions.clone(),
464 ))
465 .children(KeyBinding::for_action_in(
466 &*command.action,
467 &self.previous_focus_handle,
468 window,
469 cx,
470 )),
471 ),
472 )
473 }
474}
475
476pub fn humanize_action_name(name: &str) -> String {
477 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
478 let mut result = String::with_capacity(capacity);
479 for char in name.chars() {
480 if char == ':' {
481 if result.ends_with(':') {
482 result.push(' ');
483 } else {
484 result.push(':');
485 }
486 } else if char == '_' {
487 result.push(' ');
488 } else if char.is_uppercase() {
489 if !result.ends_with(' ') {
490 result.push(' ');
491 }
492 result.extend(char.to_lowercase());
493 } else {
494 result.push(char);
495 }
496 }
497 result
498}
499
500impl std::fmt::Debug for Command {
501 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502 f.debug_struct("Command")
503 .field("name", &self.name)
504 .finish_non_exhaustive()
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use std::sync::Arc;
511
512 use super::*;
513 use editor::Editor;
514 use go_to_line::GoToLine;
515 use gpui::TestAppContext;
516 use language::Point;
517 use project::Project;
518 use settings::KeymapFile;
519 use workspace::{AppState, Workspace};
520
521 #[test]
522 fn test_humanize_action_name() {
523 assert_eq!(
524 humanize_action_name("editor::GoToDefinition"),
525 "editor: go to definition"
526 );
527 assert_eq!(
528 humanize_action_name("editor::Backspace"),
529 "editor: backspace"
530 );
531 assert_eq!(
532 humanize_action_name("go_to_line::Deploy"),
533 "go to line: deploy"
534 );
535 }
536
537 #[test]
538 fn test_normalize_query() {
539 assert_eq!(
540 normalize_action_query("editor: backspace"),
541 "editor: backspace"
542 );
543 assert_eq!(
544 normalize_action_query("editor: backspace"),
545 "editor: backspace"
546 );
547 assert_eq!(
548 normalize_action_query("editor: backspace"),
549 "editor: backspace"
550 );
551 assert_eq!(
552 normalize_action_query("editor::GoToDefinition"),
553 "editor:GoToDefinition"
554 );
555 assert_eq!(
556 normalize_action_query("editor::::GoToDefinition"),
557 "editor:GoToDefinition"
558 );
559 assert_eq!(
560 normalize_action_query("editor: :GoToDefinition"),
561 "editor: :GoToDefinition"
562 );
563 }
564
565 #[gpui::test]
566 async fn test_command_palette(cx: &mut TestAppContext) {
567 let app_state = init_test(cx);
568 let project = Project::test(app_state.fs.clone(), [], cx).await;
569 let (workspace, cx) =
570 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
571
572 let editor = cx.new_window_entity(|window, cx| {
573 let mut editor = Editor::single_line(window, cx);
574 editor.set_text("abc", window, cx);
575 editor
576 });
577
578 workspace.update_in(cx, |workspace, window, cx| {
579 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
580 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
581 });
582
583 cx.simulate_keystrokes("cmd-shift-p");
584
585 let palette = workspace.update(cx, |workspace, cx| {
586 workspace
587 .active_modal::<CommandPalette>(cx)
588 .unwrap()
589 .read(cx)
590 .picker
591 .clone()
592 });
593
594 palette.read_with(cx, |palette, _| {
595 assert!(palette.delegate.commands.len() > 5);
596 let is_sorted =
597 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
598 assert!(is_sorted(&palette.delegate.commands));
599 });
600
601 cx.simulate_input("bcksp");
602
603 palette.read_with(cx, |palette, _| {
604 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
605 });
606
607 cx.simulate_keystrokes("enter");
608
609 workspace.update(cx, |workspace, cx| {
610 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
611 assert_eq!(editor.read(cx).text(cx), "ab")
612 });
613
614 // Add namespace filter, and redeploy the palette
615 cx.update(|_window, cx| {
616 CommandPaletteFilter::update_global(cx, |filter, _| {
617 filter.hide_namespace("editor");
618 });
619 });
620
621 cx.simulate_keystrokes("cmd-shift-p");
622 cx.simulate_input("bcksp");
623
624 let palette = workspace.update(cx, |workspace, cx| {
625 workspace
626 .active_modal::<CommandPalette>(cx)
627 .unwrap()
628 .read(cx)
629 .picker
630 .clone()
631 });
632 palette.read_with(cx, |palette, _| {
633 assert!(palette.delegate.matches.is_empty())
634 });
635 }
636 #[gpui::test]
637 async fn test_normalized_matches(cx: &mut TestAppContext) {
638 let app_state = init_test(cx);
639 let project = Project::test(app_state.fs.clone(), [], cx).await;
640 let (workspace, cx) =
641 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
642
643 let editor = cx.new_window_entity(|window, cx| {
644 let mut editor = Editor::single_line(window, cx);
645 editor.set_text("abc", window, cx);
646 editor
647 });
648
649 workspace.update_in(cx, |workspace, window, cx| {
650 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
651 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
652 });
653
654 // Test normalize (trimming whitespace and double colons)
655 cx.simulate_keystrokes("cmd-shift-p");
656
657 let palette = workspace.update(cx, |workspace, cx| {
658 workspace
659 .active_modal::<CommandPalette>(cx)
660 .unwrap()
661 .read(cx)
662 .picker
663 .clone()
664 });
665
666 cx.simulate_input("Editor:: Backspace");
667 palette.read_with(cx, |palette, _| {
668 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
669 });
670 }
671
672 #[gpui::test]
673 async fn test_go_to_line(cx: &mut TestAppContext) {
674 let app_state = init_test(cx);
675 let project = Project::test(app_state.fs.clone(), [], cx).await;
676 let (workspace, cx) =
677 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
678
679 cx.simulate_keystrokes("cmd-n");
680
681 let editor = workspace.update(cx, |workspace, cx| {
682 workspace.active_item_as::<Editor>(cx).unwrap()
683 });
684 editor.update_in(cx, |editor, window, cx| {
685 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
686 });
687
688 cx.simulate_keystrokes("cmd-shift-p");
689 cx.simulate_input("go to line: Toggle");
690 cx.simulate_keystrokes("enter");
691
692 workspace.update(cx, |workspace, cx| {
693 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
694 });
695
696 cx.simulate_keystrokes("3 enter");
697
698 editor.update_in(cx, |editor, window, cx| {
699 assert!(editor.focus_handle(cx).is_focused(window));
700 assert_eq!(
701 editor
702 .selections
703 .last::<Point>(&editor.display_snapshot(cx))
704 .range()
705 .start,
706 Point::new(2, 0)
707 );
708 });
709 }
710
711 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
712 cx.update(|cx| {
713 let app_state = AppState::test(cx);
714 theme::init(theme::LoadThemes::JustBase, cx);
715 language::init(cx);
716 editor::init(cx);
717 menu::init();
718 go_to_line::init(cx);
719 workspace::init(app_state.clone(), cx);
720 init(cx);
721 Project::init_settings(cx);
722 cx.bind_keys(KeymapFile::load_panic_on_failure(
723 r#"[
724 {
725 "bindings": {
726 "cmd-n": "workspace::NewFile",
727 "enter": "menu::Confirm",
728 "cmd-shift-p": "command_palette::Toggle"
729 }
730 }
731 ]"#,
732 cx,
733 ));
734 app_state
735 })
736 }
737}