1use std::{
2 cmp::{self, Reverse},
3 sync::Arc,
4 time::Duration,
5};
6
7use client::parse_zed_link;
8use collections::HashMap;
9use command_palette_hooks::{
10 CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
11};
12use fuzzy::{StringMatch, StringMatchCandidate};
13use gpui::{
14 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
15 ParentElement, Render, Styled, Task, UpdateGlobal, WeakEntity, Window,
16};
17use picker::{Picker, PickerDelegate};
18use postage::{sink::Sink, stream::Stream};
19use settings::Settings;
20use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, h_flex, prelude::*, v_flex};
21use util::ResultExt;
22use workspace::{ModalView, Workspace, WorkspaceSettings};
23use zed_actions::{OpenZedUrl, command_palette::Toggle};
24
25pub fn init(cx: &mut App) {
26 client::init_settings(cx);
27 cx.set_global(HitCounts::default());
28 command_palette_hooks::init(cx);
29 cx.observe_new(CommandPalette::register).detach();
30}
31
32impl ModalView for CommandPalette {}
33
34pub struct CommandPalette {
35 picker: Entity<Picker<CommandPaletteDelegate>>,
36}
37
38/// Removes subsequent whitespace characters and double colons from the query.
39///
40/// This improves the likelihood of a match by either humanized name or keymap-style name.
41fn normalize_query(input: &str) -> String {
42 let mut result = String::with_capacity(input.len());
43 let mut last_char = None;
44
45 for char in input.trim().chars() {
46 match (last_char, char) {
47 (Some(':'), ':') => continue,
48 (Some(last_char), char) if last_char.is_whitespace() && char.is_whitespace() => {
49 continue;
50 }
51 _ => {
52 last_char = Some(char);
53 }
54 }
55 result.push(char);
56 }
57
58 result
59}
60
61impl CommandPalette {
62 fn register(
63 workspace: &mut Workspace,
64 _window: Option<&mut Window>,
65 _: &mut Context<Workspace>,
66 ) {
67 workspace.register_action(|workspace, _: &Toggle, window, cx| {
68 Self::toggle(workspace, "", window, cx)
69 });
70 }
71
72 pub fn toggle(
73 workspace: &mut Workspace,
74 query: &str,
75 window: &mut Window,
76 cx: &mut Context<Workspace>,
77 ) {
78 let Some(previous_focus_handle) = window.focused(cx) else {
79 return;
80 };
81 workspace.toggle_modal(window, cx, move |window, cx| {
82 CommandPalette::new(previous_focus_handle, query, window, cx)
83 });
84 }
85
86 fn new(
87 previous_focus_handle: FocusHandle,
88 query: &str,
89 window: &mut Window,
90 cx: &mut Context<Self>,
91 ) -> Self {
92 let filter = CommandPaletteFilter::try_global(cx);
93
94 let commands = window
95 .available_actions(cx)
96 .into_iter()
97 .filter_map(|action| {
98 if filter.is_some_and(|filter| filter.is_hidden(&*action)) {
99 return None;
100 }
101
102 Some(Command {
103 name: humanize_action_name(action.name()),
104 action,
105 })
106 })
107 .collect();
108
109 let delegate =
110 CommandPaletteDelegate::new(cx.entity().downgrade(), commands, previous_focus_handle);
111
112 let picker = cx.new(|cx| {
113 let picker = Picker::uniform_list(delegate, window, cx);
114 picker.set_query(query, window, cx);
115 picker
116 });
117 Self { picker }
118 }
119
120 pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
121 self.picker
122 .update(cx, |picker, cx| picker.set_query(query, window, cx))
123 }
124}
125
126impl EventEmitter<DismissEvent> for CommandPalette {}
127
128impl Focusable for CommandPalette {
129 fn focus_handle(&self, cx: &App) -> FocusHandle {
130 self.picker.focus_handle(cx)
131 }
132}
133
134impl Render for CommandPalette {
135 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
136 v_flex().w(rems(34.)).child(self.picker.clone())
137 }
138}
139
140pub struct CommandPaletteDelegate {
141 command_palette: WeakEntity<CommandPalette>,
142 all_commands: Vec<Command>,
143 commands: Vec<Command>,
144 matches: Vec<StringMatch>,
145 selected_ix: usize,
146 previous_focus_handle: FocusHandle,
147 updating_matches: Option<(
148 Task<()>,
149 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
150 )>,
151}
152
153struct Command {
154 name: String,
155 action: Box<dyn Action>,
156}
157
158impl Clone for Command {
159 fn clone(&self) -> Self {
160 Self {
161 name: self.name.clone(),
162 action: self.action.boxed_clone(),
163 }
164 }
165}
166
167/// Hit count for each command in the palette.
168/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
169/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
170#[derive(Default, Clone)]
171struct HitCounts(HashMap<String, usize>);
172
173impl Global for HitCounts {}
174
175impl CommandPaletteDelegate {
176 fn new(
177 command_palette: WeakEntity<CommandPalette>,
178 commands: Vec<Command>,
179 previous_focus_handle: FocusHandle,
180 ) -> Self {
181 Self {
182 command_palette,
183 all_commands: commands.clone(),
184 matches: vec![],
185 commands,
186 selected_ix: 0,
187 previous_focus_handle,
188 updating_matches: None,
189 }
190 }
191
192 fn matches_updated(
193 &mut self,
194 query: String,
195 mut commands: Vec<Command>,
196 mut matches: Vec<StringMatch>,
197 cx: &mut Context<Picker<Self>>,
198 ) {
199 self.updating_matches.take();
200
201 let mut intercept_results = CommandPaletteInterceptor::try_global(cx)
202 .map(|interceptor| interceptor.intercept(&query, cx))
203 .unwrap_or_default();
204
205 if parse_zed_link(&query, cx).is_some() {
206 intercept_results = vec![CommandInterceptResult {
207 action: OpenZedUrl { url: query.clone() }.boxed_clone(),
208 string: query.clone(),
209 positions: vec![],
210 }]
211 }
212
213 let mut new_matches = Vec::new();
214
215 for CommandInterceptResult {
216 action,
217 string,
218 positions,
219 } in intercept_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 new_matches.append(&mut matches);
239 self.commands = commands;
240 self.matches = new_matches;
241 if self.matches.is_empty() {
242 self.selected_ix = 0;
243 } else {
244 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
245 }
246 }
247}
248
249impl PickerDelegate for CommandPaletteDelegate {
250 type ListItem = ListItem;
251
252 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
253 "Execute a command...".into()
254 }
255
256 fn match_count(&self) -> usize {
257 self.matches.len()
258 }
259
260 fn selected_index(&self) -> usize {
261 self.selected_ix
262 }
263
264 fn set_selected_index(
265 &mut self,
266 ix: usize,
267 _window: &mut Window,
268 _: &mut Context<Picker<Self>>,
269 ) {
270 self.selected_ix = ix;
271 }
272
273 fn update_matches(
274 &mut self,
275 mut query: String,
276 window: &mut Window,
277 cx: &mut Context<Picker<Self>>,
278 ) -> gpui::Task<()> {
279 let settings = WorkspaceSettings::get_global(cx);
280 if let Some(alias) = settings.command_aliases.get(&query) {
281 query = alias.to_string();
282 }
283 let (mut tx, mut rx) = postage::dispatch::channel(1);
284 let task = cx.background_spawn({
285 let mut commands = self.all_commands.clone();
286 let hit_counts = cx.global::<HitCounts>().clone();
287 let executor = cx.background_executor().clone();
288 let query = normalize_query(query.as_str());
289 async move {
290 commands.sort_by_key(|action| {
291 (
292 Reverse(hit_counts.0.get(&action.name).cloned()),
293 action.name.clone(),
294 )
295 });
296
297 let candidates = commands
298 .iter()
299 .enumerate()
300 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
301 .collect::<Vec<_>>();
302 let matches = if query.is_empty() {
303 candidates
304 .into_iter()
305 .enumerate()
306 .map(|(index, candidate)| StringMatch {
307 candidate_id: index,
308 string: candidate.string,
309 positions: Vec::new(),
310 score: 0.0,
311 })
312 .collect()
313 } else {
314 fuzzy::match_strings(
315 &candidates,
316 &query,
317 true,
318 10000,
319 &Default::default(),
320 executor,
321 )
322 .await
323 };
324
325 tx.send((commands, matches)).await.log_err();
326 }
327 });
328 self.updating_matches = Some((task, rx.clone()));
329
330 cx.spawn_in(window, async move |picker, cx| {
331 let Some((commands, matches)) = rx.recv().await else {
332 return;
333 };
334
335 picker
336 .update(cx, |picker, cx| {
337 picker
338 .delegate
339 .matches_updated(query, commands, matches, cx)
340 })
341 .log_err();
342 })
343 }
344
345 fn finalize_update_matches(
346 &mut self,
347 query: String,
348 duration: Duration,
349 _: &mut Window,
350 cx: &mut Context<Picker<Self>>,
351 ) -> bool {
352 let Some((task, rx)) = self.updating_matches.take() else {
353 return true;
354 };
355
356 match cx
357 .background_executor()
358 .block_with_timeout(duration, rx.clone().recv())
359 {
360 Ok(Some((commands, matches))) => {
361 self.matches_updated(query, commands, matches, cx);
362 true
363 }
364 _ => {
365 self.updating_matches = Some((task, rx));
366 false
367 }
368 }
369 }
370
371 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
372 self.command_palette
373 .update(cx, |_, cx| cx.emit(DismissEvent))
374 .log_err();
375 }
376
377 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
378 if self.matches.is_empty() {
379 self.dismissed(window, cx);
380 return;
381 }
382 let action_ix = self.matches[self.selected_ix].candidate_id;
383 let command = self.commands.swap_remove(action_ix);
384 telemetry::event!(
385 "Action Invoked",
386 source = "command palette",
387 action = command.name
388 );
389 self.matches.clear();
390 self.commands.clear();
391 HitCounts::update_global(cx, |hit_counts, _cx| {
392 *hit_counts.0.entry(command.name).or_default() += 1;
393 });
394 let action = command.action;
395 window.focus(&self.previous_focus_handle);
396 self.dismissed(window, cx);
397 window.dispatch_action(action, cx);
398 }
399
400 fn render_match(
401 &self,
402 ix: usize,
403 selected: bool,
404 window: &mut Window,
405 cx: &mut Context<Picker<Self>>,
406 ) -> Option<Self::ListItem> {
407 let r#match = self.matches.get(ix)?;
408 let command = self.commands.get(r#match.candidate_id)?;
409 Some(
410 ListItem::new(ix)
411 .inset(true)
412 .spacing(ListItemSpacing::Sparse)
413 .toggle_state(selected)
414 .child(
415 h_flex()
416 .w_full()
417 .py_px()
418 .justify_between()
419 .child(HighlightedLabel::new(
420 command.name.clone(),
421 r#match.positions.clone(),
422 ))
423 .children(KeyBinding::for_action_in(
424 &*command.action,
425 &self.previous_focus_handle,
426 window,
427 cx,
428 )),
429 ),
430 )
431 }
432}
433
434fn humanize_action_name(name: &str) -> String {
435 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
436 let mut result = String::with_capacity(capacity);
437 for char in name.chars() {
438 if char == ':' {
439 if result.ends_with(':') {
440 result.push(' ');
441 } else {
442 result.push(':');
443 }
444 } else if char == '_' {
445 result.push(' ');
446 } else if char.is_uppercase() {
447 if !result.ends_with(' ') {
448 result.push(' ');
449 }
450 result.extend(char.to_lowercase());
451 } else {
452 result.push(char);
453 }
454 }
455 result
456}
457
458impl std::fmt::Debug for Command {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.debug_struct("Command")
461 .field("name", &self.name)
462 .finish_non_exhaustive()
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use std::sync::Arc;
469
470 use super::*;
471 use editor::Editor;
472 use go_to_line::GoToLine;
473 use gpui::TestAppContext;
474 use language::Point;
475 use project::Project;
476 use settings::KeymapFile;
477 use workspace::{AppState, Workspace};
478
479 #[test]
480 fn test_humanize_action_name() {
481 assert_eq!(
482 humanize_action_name("editor::GoToDefinition"),
483 "editor: go to definition"
484 );
485 assert_eq!(
486 humanize_action_name("editor::Backspace"),
487 "editor: backspace"
488 );
489 assert_eq!(
490 humanize_action_name("go_to_line::Deploy"),
491 "go to line: deploy"
492 );
493 }
494
495 #[test]
496 fn test_normalize_query() {
497 assert_eq!(normalize_query("editor: backspace"), "editor: backspace");
498 assert_eq!(normalize_query("editor: backspace"), "editor: backspace");
499 assert_eq!(normalize_query("editor: backspace"), "editor: backspace");
500 assert_eq!(
501 normalize_query("editor::GoToDefinition"),
502 "editor:GoToDefinition"
503 );
504 assert_eq!(
505 normalize_query("editor::::GoToDefinition"),
506 "editor:GoToDefinition"
507 );
508 assert_eq!(
509 normalize_query("editor: :GoToDefinition"),
510 "editor: :GoToDefinition"
511 );
512 }
513
514 #[gpui::test]
515 async fn test_command_palette(cx: &mut TestAppContext) {
516 let app_state = init_test(cx);
517 let project = Project::test(app_state.fs.clone(), [], cx).await;
518 let (workspace, cx) =
519 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
520
521 let editor = cx.new_window_entity(|window, cx| {
522 let mut editor = Editor::single_line(window, cx);
523 editor.set_text("abc", window, cx);
524 editor
525 });
526
527 workspace.update_in(cx, |workspace, window, cx| {
528 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
529 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
530 });
531
532 cx.simulate_keystrokes("cmd-shift-p");
533
534 let palette = workspace.update(cx, |workspace, cx| {
535 workspace
536 .active_modal::<CommandPalette>(cx)
537 .unwrap()
538 .read(cx)
539 .picker
540 .clone()
541 });
542
543 palette.update(cx, |palette, _| {
544 assert!(palette.delegate.commands.len() > 5);
545 let is_sorted =
546 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
547 assert!(is_sorted(&palette.delegate.commands));
548 });
549
550 cx.simulate_input("bcksp");
551
552 palette.update(cx, |palette, _| {
553 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
554 });
555
556 cx.simulate_keystrokes("enter");
557
558 workspace.update(cx, |workspace, cx| {
559 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
560 assert_eq!(editor.read(cx).text(cx), "ab")
561 });
562
563 // Add namespace filter, and redeploy the palette
564 cx.update(|_window, cx| {
565 CommandPaletteFilter::update_global(cx, |filter, _| {
566 filter.hide_namespace("editor");
567 });
568 });
569
570 cx.simulate_keystrokes("cmd-shift-p");
571 cx.simulate_input("bcksp");
572
573 let palette = workspace.update(cx, |workspace, cx| {
574 workspace
575 .active_modal::<CommandPalette>(cx)
576 .unwrap()
577 .read(cx)
578 .picker
579 .clone()
580 });
581 palette.update(cx, |palette, _| {
582 assert!(palette.delegate.matches.is_empty())
583 });
584 }
585 #[gpui::test]
586 async fn test_normalized_matches(cx: &mut TestAppContext) {
587 let app_state = init_test(cx);
588 let project = Project::test(app_state.fs.clone(), [], cx).await;
589 let (workspace, cx) =
590 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
591
592 let editor = cx.new_window_entity(|window, cx| {
593 let mut editor = Editor::single_line(window, cx);
594 editor.set_text("abc", window, cx);
595 editor
596 });
597
598 workspace.update_in(cx, |workspace, window, cx| {
599 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
600 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
601 });
602
603 // Test normalize (trimming whitespace and double colons)
604 cx.simulate_keystrokes("cmd-shift-p");
605
606 let palette = workspace.update(cx, |workspace, cx| {
607 workspace
608 .active_modal::<CommandPalette>(cx)
609 .unwrap()
610 .read(cx)
611 .picker
612 .clone()
613 });
614
615 cx.simulate_input("Editor:: Backspace");
616 palette.update(cx, |palette, _| {
617 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
618 });
619 }
620
621 #[gpui::test]
622 async fn test_go_to_line(cx: &mut TestAppContext) {
623 let app_state = init_test(cx);
624 let project = Project::test(app_state.fs.clone(), [], cx).await;
625 let (workspace, cx) =
626 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
627
628 cx.simulate_keystrokes("cmd-n");
629
630 let editor = workspace.update(cx, |workspace, cx| {
631 workspace.active_item_as::<Editor>(cx).unwrap()
632 });
633 editor.update_in(cx, |editor, window, cx| {
634 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
635 });
636
637 cx.simulate_keystrokes("cmd-shift-p");
638 cx.simulate_input("go to line: Toggle");
639 cx.simulate_keystrokes("enter");
640
641 workspace.update(cx, |workspace, cx| {
642 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
643 });
644
645 cx.simulate_keystrokes("3 enter");
646
647 editor.update_in(cx, |editor, window, cx| {
648 assert!(editor.focus_handle(cx).is_focused(window));
649 assert_eq!(
650 editor.selections.last::<Point>(cx).range().start,
651 Point::new(2, 0)
652 );
653 });
654 }
655
656 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
657 cx.update(|cx| {
658 let app_state = AppState::test(cx);
659 theme::init(theme::LoadThemes::JustBase, cx);
660 language::init(cx);
661 editor::init(cx);
662 menu::init();
663 go_to_line::init(cx);
664 workspace::init(app_state.clone(), cx);
665 init(cx);
666 Project::init_settings(cx);
667 cx.bind_keys(KeymapFile::load_panic_on_failure(
668 r#"[
669 {
670 "bindings": {
671 "cmd-n": "workspace::NewFile",
672 "enter": "menu::Confirm",
673 "cmd-shift-p": "command_palette::Toggle"
674 }
675 }
676 ]"#,
677 cx,
678 ));
679 app_state
680 })
681 }
682}