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