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 _: &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 .child(KeyBinding::for_action_in(
466 &*command.action,
467 &self.previous_focus_handle,
468 cx,
469 )),
470 ),
471 )
472 }
473}
474
475pub fn humanize_action_name(name: &str) -> String {
476 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
477 let mut result = String::with_capacity(capacity);
478 for char in name.chars() {
479 if char == ':' {
480 if result.ends_with(':') {
481 result.push(' ');
482 } else {
483 result.push(':');
484 }
485 } else if char == '_' {
486 result.push(' ');
487 } else if char.is_uppercase() {
488 if !result.ends_with(' ') {
489 result.push(' ');
490 }
491 result.extend(char.to_lowercase());
492 } else {
493 result.push(char);
494 }
495 }
496 result
497}
498
499impl std::fmt::Debug for Command {
500 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501 f.debug_struct("Command")
502 .field("name", &self.name)
503 .finish_non_exhaustive()
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use std::sync::Arc;
510
511 use super::*;
512 use editor::Editor;
513 use go_to_line::GoToLine;
514 use gpui::TestAppContext;
515 use language::Point;
516 use project::Project;
517 use settings::KeymapFile;
518 use workspace::{AppState, Workspace};
519
520 #[test]
521 fn test_humanize_action_name() {
522 assert_eq!(
523 humanize_action_name("editor::GoToDefinition"),
524 "editor: go to definition"
525 );
526 assert_eq!(
527 humanize_action_name("editor::Backspace"),
528 "editor: backspace"
529 );
530 assert_eq!(
531 humanize_action_name("go_to_line::Deploy"),
532 "go to line: deploy"
533 );
534 }
535
536 #[test]
537 fn test_normalize_query() {
538 assert_eq!(
539 normalize_action_query("editor: backspace"),
540 "editor: backspace"
541 );
542 assert_eq!(
543 normalize_action_query("editor: backspace"),
544 "editor: backspace"
545 );
546 assert_eq!(
547 normalize_action_query("editor: backspace"),
548 "editor: backspace"
549 );
550 assert_eq!(
551 normalize_action_query("editor::GoToDefinition"),
552 "editor:GoToDefinition"
553 );
554 assert_eq!(
555 normalize_action_query("editor::::GoToDefinition"),
556 "editor:GoToDefinition"
557 );
558 assert_eq!(
559 normalize_action_query("editor: :GoToDefinition"),
560 "editor: :GoToDefinition"
561 );
562 }
563
564 #[gpui::test]
565 async fn test_command_palette(cx: &mut TestAppContext) {
566 let app_state = init_test(cx);
567 let project = Project::test(app_state.fs.clone(), [], cx).await;
568 let (workspace, cx) =
569 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
570
571 let editor = cx.new_window_entity(|window, cx| {
572 let mut editor = Editor::single_line(window, cx);
573 editor.set_text("abc", window, cx);
574 editor
575 });
576
577 workspace.update_in(cx, |workspace, window, cx| {
578 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
579 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
580 });
581
582 cx.simulate_keystrokes("cmd-shift-p");
583
584 let palette = workspace.update(cx, |workspace, cx| {
585 workspace
586 .active_modal::<CommandPalette>(cx)
587 .unwrap()
588 .read(cx)
589 .picker
590 .clone()
591 });
592
593 palette.read_with(cx, |palette, _| {
594 assert!(palette.delegate.commands.len() > 5);
595 let is_sorted =
596 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
597 assert!(is_sorted(&palette.delegate.commands));
598 });
599
600 cx.simulate_input("bcksp");
601
602 palette.read_with(cx, |palette, _| {
603 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
604 });
605
606 cx.simulate_keystrokes("enter");
607
608 workspace.update(cx, |workspace, cx| {
609 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
610 assert_eq!(editor.read(cx).text(cx), "ab")
611 });
612
613 // Add namespace filter, and redeploy the palette
614 cx.update(|_window, cx| {
615 CommandPaletteFilter::update_global(cx, |filter, _| {
616 filter.hide_namespace("editor");
617 });
618 });
619
620 cx.simulate_keystrokes("cmd-shift-p");
621 cx.simulate_input("bcksp");
622
623 let palette = workspace.update(cx, |workspace, cx| {
624 workspace
625 .active_modal::<CommandPalette>(cx)
626 .unwrap()
627 .read(cx)
628 .picker
629 .clone()
630 });
631 palette.read_with(cx, |palette, _| {
632 assert!(palette.delegate.matches.is_empty())
633 });
634 }
635 #[gpui::test]
636 async fn test_normalized_matches(cx: &mut TestAppContext) {
637 let app_state = init_test(cx);
638 let project = Project::test(app_state.fs.clone(), [], cx).await;
639 let (workspace, cx) =
640 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
641
642 let editor = cx.new_window_entity(|window, cx| {
643 let mut editor = Editor::single_line(window, cx);
644 editor.set_text("abc", window, cx);
645 editor
646 });
647
648 workspace.update_in(cx, |workspace, window, cx| {
649 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
650 editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
651 });
652
653 // Test normalize (trimming whitespace and double colons)
654 cx.simulate_keystrokes("cmd-shift-p");
655
656 let palette = workspace.update(cx, |workspace, cx| {
657 workspace
658 .active_modal::<CommandPalette>(cx)
659 .unwrap()
660 .read(cx)
661 .picker
662 .clone()
663 });
664
665 cx.simulate_input("Editor:: Backspace");
666 palette.read_with(cx, |palette, _| {
667 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
668 });
669 }
670
671 #[gpui::test]
672 async fn test_go_to_line(cx: &mut TestAppContext) {
673 let app_state = init_test(cx);
674 let project = Project::test(app_state.fs.clone(), [], cx).await;
675 let (workspace, cx) =
676 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
677
678 cx.simulate_keystrokes("cmd-n");
679
680 let editor = workspace.update(cx, |workspace, cx| {
681 workspace.active_item_as::<Editor>(cx).unwrap()
682 });
683 editor.update_in(cx, |editor, window, cx| {
684 editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
685 });
686
687 cx.simulate_keystrokes("cmd-shift-p");
688 cx.simulate_input("go to line: Toggle");
689 cx.simulate_keystrokes("enter");
690
691 workspace.update(cx, |workspace, cx| {
692 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
693 });
694
695 cx.simulate_keystrokes("3 enter");
696
697 editor.update_in(cx, |editor, window, cx| {
698 assert!(editor.focus_handle(cx).is_focused(window));
699 assert_eq!(
700 editor
701 .selections
702 .last::<Point>(&editor.display_snapshot(cx))
703 .range()
704 .start,
705 Point::new(2, 0)
706 );
707 });
708 }
709
710 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
711 cx.update(|cx| {
712 let app_state = AppState::test(cx);
713 theme::init(theme::LoadThemes::JustBase, cx);
714 language::init(cx);
715 editor::init(cx);
716 menu::init();
717 go_to_line::init(cx);
718 workspace::init(app_state.clone(), cx);
719 init(cx);
720 Project::init_settings(cx);
721 cx.bind_keys(KeymapFile::load_panic_on_failure(
722 r#"[
723 {
724 "bindings": {
725 "cmd-n": "workspace::NewFile",
726 "enter": "menu::Confirm",
727 "cmd-shift-p": "command_palette::Toggle"
728 }
729 }
730 ]"#,
731 cx,
732 ));
733 app_state
734 })
735 }
736}