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