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