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