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