1mod persistence;
2
3use std::{
4 cmp::{self, Reverse},
5 collections::HashMap,
6 pin::pin,
7 sync::Arc,
8 time::Duration,
9};
10
11use client::parse_zed_link;
12use command_palette_hooks::{
13 CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
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 workspace.toggle_modal(window, cx, move |window, cx| {
86 CommandPalette::new(previous_focus_handle, query, window, cx)
87 });
88 }
89
90 fn new(
91 previous_focus_handle: FocusHandle,
92 query: &str,
93 window: &mut Window,
94 cx: &mut Context<Self>,
95 ) -> Self {
96 let filter = CommandPaletteFilter::try_global(cx);
97
98 let commands = window
99 .available_actions(cx)
100 .into_iter()
101 .filter_map(|action| {
102 if filter.is_some_and(|filter| filter.is_hidden(&*action)) {
103 return None;
104 }
105
106 Some(Command {
107 name: humanize_action_name(action.name()),
108 action,
109 })
110 })
111 .collect();
112
113 let delegate =
114 CommandPaletteDelegate::new(cx.entity().downgrade(), commands, previous_focus_handle);
115
116 let picker = cx.new(|cx| {
117 let picker = Picker::uniform_list(delegate, window, cx);
118 picker.set_query(query, window, cx);
119 picker
120 });
121 Self { picker }
122 }
123
124 pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
125 self.picker
126 .update(cx, |picker, cx| picker.set_query(query, window, cx))
127 }
128}
129
130impl EventEmitter<DismissEvent> for CommandPalette {}
131
132impl Focusable for CommandPalette {
133 fn focus_handle(&self, cx: &App) -> FocusHandle {
134 self.picker.focus_handle(cx)
135 }
136}
137
138impl Render for CommandPalette {
139 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
140 v_flex()
141 .key_context("CommandPalette")
142 .w(rems(34.))
143 .child(self.picker.clone())
144 }
145}
146
147pub struct CommandPaletteDelegate {
148 latest_query: String,
149 command_palette: WeakEntity<CommandPalette>,
150 all_commands: Vec<Command>,
151 commands: Vec<Command>,
152 matches: Vec<StringMatch>,
153 selected_ix: usize,
154 previous_focus_handle: FocusHandle,
155 updating_matches: Option<(
156 Task<()>,
157 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
158 )>,
159}
160
161struct Command {
162 name: String,
163 action: Box<dyn Action>,
164}
165
166impl Clone for Command {
167 fn clone(&self) -> Self {
168 Self {
169 name: self.name.clone(),
170 action: self.action.boxed_clone(),
171 }
172 }
173}
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 latest_query: String::new(),
189 updating_matches: None,
190 }
191 }
192
193 fn matches_updated(
194 &mut self,
195 query: String,
196 mut commands: Vec<Command>,
197 mut matches: Vec<StringMatch>,
198 cx: &mut Context<Picker<Self>>,
199 ) {
200 self.updating_matches.take();
201 self.latest_query = query.clone();
202
203 let mut intercept_results = CommandPaletteInterceptor::try_global(cx)
204 .map(|interceptor| interceptor.intercept(&query, cx))
205 .unwrap_or_default();
206
207 if parse_zed_link(&query, cx).is_some() {
208 intercept_results = vec![CommandInterceptResult {
209 action: OpenZedUrl { url: query.clone() }.boxed_clone(),
210 string: query,
211 positions: vec![],
212 }]
213 }
214
215 let mut new_matches = Vec::new();
216
217 for CommandInterceptResult {
218 action,
219 string,
220 positions,
221 } in intercept_results
222 {
223 if let Some(idx) = matches
224 .iter()
225 .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
226 {
227 matches.remove(idx);
228 }
229 commands.push(Command {
230 name: string.clone(),
231 action,
232 });
233 new_matches.push(StringMatch {
234 candidate_id: commands.len() - 1,
235 string,
236 positions,
237 score: 0.0,
238 })
239 }
240 new_matches.append(&mut matches);
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
265impl PickerDelegate for CommandPaletteDelegate {
266 type ListItem = ListItem;
267
268 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
269 "Execute a command...".into()
270 }
271
272 fn match_count(&self) -> usize {
273 self.matches.len()
274 }
275
276 fn selected_index(&self) -> usize {
277 self.selected_ix
278 }
279
280 fn set_selected_index(
281 &mut self,
282 ix: usize,
283 _window: &mut Window,
284 _: &mut Context<Picker<Self>>,
285 ) {
286 self.selected_ix = ix;
287 }
288
289 fn update_matches(
290 &mut self,
291 mut query: String,
292 window: &mut Window,
293 cx: &mut Context<Picker<Self>>,
294 ) -> gpui::Task<()> {
295 let settings = WorkspaceSettings::get_global(cx);
296 if let Some(alias) = settings.command_aliases.get(&query) {
297 query = alias.to_string();
298 }
299 let (mut tx, mut rx) = postage::dispatch::channel(1);
300 let task = cx.background_spawn({
301 let mut commands = self.all_commands.clone();
302 let hit_counts = self.hit_counts();
303 let executor = cx.background_executor().clone();
304 let query = normalize_action_query(query.as_str());
305 async move {
306 commands.sort_by_key(|action| {
307 (
308 Reverse(hit_counts.get(&action.name).cloned()),
309 action.name.clone(),
310 )
311 });
312
313 let candidates = commands
314 .iter()
315 .enumerate()
316 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
317 .collect::<Vec<_>>();
318
319 let matches = fuzzy::match_strings(
320 &candidates,
321 &query,
322 true,
323 true,
324 10000,
325 &Default::default(),
326 executor,
327 )
328 .await;
329
330 tx.send((commands, matches)).await.log_err();
331 }
332 });
333 self.updating_matches = Some((task, rx.clone()));
334
335 cx.spawn_in(window, async move |picker, cx| {
336 let Some((commands, matches)) = rx.recv().await else {
337 return;
338 };
339
340 picker
341 .update(cx, |picker, cx| {
342 picker
343 .delegate
344 .matches_updated(query, commands, matches, cx)
345 })
346 .log_err();
347 })
348 }
349
350 fn finalize_update_matches(
351 &mut self,
352 query: String,
353 duration: Duration,
354 _: &mut Window,
355 cx: &mut Context<Picker<Self>>,
356 ) -> bool {
357 let Some((task, rx)) = self.updating_matches.take() else {
358 return true;
359 };
360
361 let result = pin!({
362 let mut rx = rx.clone();
363 async move { rx.recv().await }
364 });
365 match cx
366 .foreground_executor()
367 .block_with_timeout(duration, result)
368 {
369 Ok(Some((commands, matches))) => {
370 self.matches_updated(query, commands, matches, cx);
371 true
372 }
373 _ => {
374 self.updating_matches = Some((task, rx));
375 false
376 }
377 }
378 }
379
380 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
381 self.command_palette
382 .update(cx, |_, cx| cx.emit(DismissEvent))
383 .log_err();
384 }
385
386 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
387 if self.matches.is_empty() {
388 self.dismissed(window, cx);
389 return;
390 }
391 let action_ix = self.matches[self.selected_ix].candidate_id;
392 let command = self.commands.swap_remove(action_ix);
393 telemetry::event!(
394 "Action Invoked",
395 source = "command palette",
396 action = command.name
397 );
398 self.matches.clear();
399 self.commands.clear();
400 let command_name = command.name.clone();
401 let latest_query = self.latest_query.clone();
402 cx.background_spawn(async move {
403 COMMAND_PALETTE_HISTORY
404 .write_command_invocation(command_name, latest_query)
405 .await
406 })
407 .detach_and_log_err(cx);
408 let action = command.action;
409 window.focus(&self.previous_focus_handle);
410 self.dismissed(window, cx);
411 window.dispatch_action(action, cx);
412 }
413
414 fn render_match(
415 &self,
416 ix: usize,
417 selected: bool,
418 window: &mut Window,
419 cx: &mut Context<Picker<Self>>,
420 ) -> Option<Self::ListItem> {
421 let matching_command = self.matches.get(ix)?;
422 let command = self.commands.get(matching_command.candidate_id)?;
423 Some(
424 ListItem::new(ix)
425 .inset(true)
426 .spacing(ListItemSpacing::Sparse)
427 .toggle_state(selected)
428 .child(
429 h_flex()
430 .w_full()
431 .py_px()
432 .justify_between()
433 .child(HighlightedLabel::new(
434 command.name.clone(),
435 matching_command.positions.clone(),
436 ))
437 .children(KeyBinding::for_action_in(
438 &*command.action,
439 &self.previous_focus_handle,
440 window,
441 cx,
442 )),
443 ),
444 )
445 }
446}
447
448pub fn humanize_action_name(name: &str) -> String {
449 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
450 let mut result = String::with_capacity(capacity);
451 for char in name.chars() {
452 if char == ':' {
453 if result.ends_with(':') {
454 result.push(' ');
455 } else {
456 result.push(':');
457 }
458 } else if char == '_' {
459 result.push(' ');
460 } else if char.is_uppercase() {
461 if !result.ends_with(' ') {
462 result.push(' ');
463 }
464 result.extend(char.to_lowercase());
465 } else {
466 result.push(char);
467 }
468 }
469 result
470}
471
472impl std::fmt::Debug for Command {
473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474 f.debug_struct("Command")
475 .field("name", &self.name)
476 .finish_non_exhaustive()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use std::sync::Arc;
483
484 use super::*;
485 use editor::Editor;
486 use go_to_line::GoToLine;
487 use gpui::TestAppContext;
488 use language::Point;
489 use project::Project;
490 use settings::KeymapFile;
491 use workspace::{AppState, Workspace};
492
493 #[test]
494 fn test_humanize_action_name() {
495 assert_eq!(
496 humanize_action_name("editor::GoToDefinition"),
497 "editor: go to definition"
498 );
499 assert_eq!(
500 humanize_action_name("editor::Backspace"),
501 "editor: backspace"
502 );
503 assert_eq!(
504 humanize_action_name("go_to_line::Deploy"),
505 "go to line: deploy"
506 );
507 }
508
509 #[test]
510 fn test_normalize_query() {
511 assert_eq!(
512 normalize_action_query("editor: backspace"),
513 "editor: backspace"
514 );
515 assert_eq!(
516 normalize_action_query("editor: backspace"),
517 "editor: backspace"
518 );
519 assert_eq!(
520 normalize_action_query("editor: backspace"),
521 "editor: backspace"
522 );
523 assert_eq!(
524 normalize_action_query("editor::GoToDefinition"),
525 "editor:GoToDefinition"
526 );
527 assert_eq!(
528 normalize_action_query("editor::::GoToDefinition"),
529 "editor:GoToDefinition"
530 );
531 assert_eq!(
532 normalize_action_query("editor: :GoToDefinition"),
533 "editor: :GoToDefinition"
534 );
535 }
536
537 #[gpui::test]
538 async fn test_command_palette(cx: &mut TestAppContext) {
539 let app_state = init_test(cx);
540 let project = Project::test(app_state.fs.clone(), [], cx).await;
541 let (workspace, cx) =
542 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
543
544 let editor = cx.new_window_entity(|window, cx| {
545 let mut editor = Editor::single_line(window, cx);
546 editor.set_text("abc", window, cx);
547 editor
548 });
549
550 workspace.update_in(cx, |workspace, window, cx| {
551 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
552 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
553 });
554
555 cx.simulate_keystrokes("cmd-shift-p");
556
557 let palette = workspace.update(cx, |workspace, cx| {
558 workspace
559 .active_modal::<CommandPalette>(cx)
560 .unwrap()
561 .read(cx)
562 .picker
563 .clone()
564 });
565
566 palette.read_with(cx, |palette, _| {
567 assert!(palette.delegate.commands.len() > 5);
568 let is_sorted =
569 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
570 assert!(is_sorted(&palette.delegate.commands));
571 });
572
573 cx.simulate_input("bcksp");
574
575 palette.read_with(cx, |palette, _| {
576 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
577 });
578
579 cx.simulate_keystrokes("enter");
580
581 workspace.update(cx, |workspace, cx| {
582 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
583 assert_eq!(editor.read(cx).text(cx), "ab")
584 });
585
586 // Add namespace filter, and redeploy the palette
587 cx.update(|_window, cx| {
588 CommandPaletteFilter::update_global(cx, |filter, _| {
589 filter.hide_namespace("editor");
590 });
591 });
592
593 cx.simulate_keystrokes("cmd-shift-p");
594 cx.simulate_input("bcksp");
595
596 let palette = workspace.update(cx, |workspace, cx| {
597 workspace
598 .active_modal::<CommandPalette>(cx)
599 .unwrap()
600 .read(cx)
601 .picker
602 .clone()
603 });
604 palette.read_with(cx, |palette, _| {
605 assert!(palette.delegate.matches.is_empty())
606 });
607 }
608 #[gpui::test]
609 async fn test_normalized_matches(cx: &mut TestAppContext) {
610 let app_state = init_test(cx);
611 let project = Project::test(app_state.fs.clone(), [], cx).await;
612 let (workspace, cx) =
613 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
614
615 let editor = cx.new_window_entity(|window, cx| {
616 let mut editor = Editor::single_line(window, cx);
617 editor.set_text("abc", window, cx);
618 editor
619 });
620
621 workspace.update_in(cx, |workspace, window, cx| {
622 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
623 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
624 });
625
626 // Test normalize (trimming whitespace and double colons)
627 cx.simulate_keystrokes("cmd-shift-p");
628
629 let palette = workspace.update(cx, |workspace, cx| {
630 workspace
631 .active_modal::<CommandPalette>(cx)
632 .unwrap()
633 .read(cx)
634 .picker
635 .clone()
636 });
637
638 cx.simulate_input("Editor:: Backspace");
639 palette.read_with(cx, |palette, _| {
640 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
641 });
642 }
643
644 #[gpui::test]
645 async fn test_go_to_line(cx: &mut TestAppContext) {
646 let app_state = init_test(cx);
647 let project = Project::test(app_state.fs.clone(), [], cx).await;
648 let (workspace, cx) =
649 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
650
651 cx.simulate_keystrokes("cmd-n");
652
653 let editor = workspace.update(cx, |workspace, cx| {
654 workspace.active_item_as::<Editor>(cx).unwrap()
655 });
656 editor.update_in(cx, |editor, window, cx| {
657 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
658 });
659
660 cx.simulate_keystrokes("cmd-shift-p");
661 cx.simulate_input("go to line: Toggle");
662 cx.simulate_keystrokes("enter");
663
664 workspace.update(cx, |workspace, cx| {
665 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
666 });
667
668 cx.simulate_keystrokes("3 enter");
669
670 editor.update_in(cx, |editor, window, cx| {
671 assert!(editor.focus_handle(cx).is_focused(window));
672 assert_eq!(
673 editor.selections.last::<Point>(cx).range().start,
674 Point::new(2, 0)
675 );
676 });
677 }
678
679 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
680 cx.update(|cx| {
681 let app_state = AppState::test(cx);
682 theme::init(theme::LoadThemes::JustBase, cx);
683 language::init(cx);
684 editor::init(cx);
685 menu::init();
686 go_to_line::init(cx);
687 workspace::init(app_state.clone(), cx);
688 init(cx);
689 Project::init_settings(cx);
690 cx.bind_keys(KeymapFile::load_panic_on_failure(
691 r#"[
692 {
693 "bindings": {
694 "cmd-n": "workspace::NewFile",
695 "enter": "menu::Confirm",
696 "cmd-shift-p": "command_palette::Toggle"
697 }
698 }
699 ]"#,
700 cx,
701 ));
702 app_state
703 })
704 }
705}