1use std::{
2 cmp::{self, Reverse},
3 sync::Arc,
4 time::Duration,
5};
6
7use client::telemetry::Telemetry;
8use collections::HashMap;
9use copilot::CommandPaletteFilter;
10use fuzzy::{StringMatch, StringMatchCandidate};
11use gpui::{
12 actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global,
13 ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
14};
15use picker::{Picker, PickerDelegate};
16
17use postage::{sink::Sink, stream::Stream};
18use release_channel::parse_zed_link;
19use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
20use util::ResultExt;
21use workspace::{ModalView, Workspace};
22use zed_actions::OpenZedUrl;
23
24actions!(command_palette, [Toggle]);
25
26pub fn init(cx: &mut AppContext) {
27 cx.set_global(HitCounts::default());
28 cx.set_global(CommandPaletteFilter::default());
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
38impl CommandPalette {
39 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
40 workspace.register_action(|workspace, _: &Toggle, cx| {
41 let Some(previous_focus_handle) = cx.focused() else {
42 return;
43 };
44 let telemetry = workspace.client().telemetry().clone();
45 workspace.toggle_modal(cx, move |cx| {
46 CommandPalette::new(previous_focus_handle, telemetry, cx)
47 });
48 });
49 }
50
51 fn new(
52 previous_focus_handle: FocusHandle,
53 telemetry: Arc<Telemetry>,
54 cx: &mut ViewContext<Self>,
55 ) -> Self {
56 let filter = cx.try_global::<CommandPaletteFilter>();
57
58 let commands = cx
59 .available_actions()
60 .into_iter()
61 .filter_map(|action| {
62 let name = action.name();
63 let namespace = name.split("::").next().unwrap_or("malformed action name");
64 if filter.is_some_and(|f| {
65 f.hidden_namespaces.contains(namespace)
66 || f.hidden_action_types.contains(&action.type_id())
67 }) {
68 return None;
69 }
70
71 Some(Command {
72 name: humanize_action_name(&name),
73 action,
74 })
75 })
76 .collect();
77
78 let delegate = CommandPaletteDelegate::new(
79 cx.view().downgrade(),
80 commands,
81 telemetry,
82 previous_focus_handle,
83 );
84
85 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
86 Self { picker }
87 }
88}
89
90impl EventEmitter<DismissEvent> for CommandPalette {}
91
92impl FocusableView for CommandPalette {
93 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
94 self.picker.focus_handle(cx)
95 }
96}
97
98impl Render for CommandPalette {
99 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
100 v_flex().w(rems(34.)).child(self.picker.clone())
101 }
102}
103
104pub struct CommandPaletteInterceptor(
105 pub Box<dyn Fn(&str, &AppContext) -> Option<CommandInterceptResult>>,
106);
107
108impl Global for CommandPaletteInterceptor {}
109
110pub struct CommandInterceptResult {
111 pub action: Box<dyn Action>,
112 pub string: String,
113 pub positions: Vec<usize>,
114}
115
116pub struct CommandPaletteDelegate {
117 command_palette: WeakView<CommandPalette>,
118 all_commands: Vec<Command>,
119 commands: Vec<Command>,
120 matches: Vec<StringMatch>,
121 selected_ix: usize,
122 telemetry: Arc<Telemetry>,
123 previous_focus_handle: FocusHandle,
124 updating_matches: Option<(
125 Task<()>,
126 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
127 )>,
128}
129
130struct Command {
131 name: String,
132 action: Box<dyn Action>,
133}
134
135impl Clone for Command {
136 fn clone(&self) -> Self {
137 Self {
138 name: self.name.clone(),
139 action: self.action.boxed_clone(),
140 }
141 }
142}
143
144/// Hit count for each command in the palette.
145/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
146/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
147#[derive(Default, Clone)]
148struct HitCounts(HashMap<String, usize>);
149
150impl Global for HitCounts {}
151
152impl CommandPaletteDelegate {
153 fn new(
154 command_palette: WeakView<CommandPalette>,
155 commands: Vec<Command>,
156 telemetry: Arc<Telemetry>,
157 previous_focus_handle: FocusHandle,
158 ) -> Self {
159 Self {
160 command_palette,
161 all_commands: commands.clone(),
162 matches: vec![],
163 commands,
164 selected_ix: 0,
165 telemetry,
166 previous_focus_handle,
167 updating_matches: None,
168 }
169 }
170
171 fn matches_updated(
172 &mut self,
173 query: String,
174 mut commands: Vec<Command>,
175 mut matches: Vec<StringMatch>,
176 cx: &mut ViewContext<Picker<Self>>,
177 ) {
178 self.updating_matches.take();
179
180 let mut intercept_result =
181 if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
182 (interceptor.0)(&query, cx)
183 } else {
184 None
185 };
186
187 if parse_zed_link(&query).is_some() {
188 intercept_result = Some(CommandInterceptResult {
189 action: OpenZedUrl { url: query.clone() }.boxed_clone(),
190 string: query.clone(),
191 positions: vec![],
192 })
193 }
194
195 if let Some(CommandInterceptResult {
196 action,
197 string,
198 positions,
199 }) = intercept_result
200 {
201 if let Some(idx) = matches
202 .iter()
203 .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
204 {
205 matches.remove(idx);
206 }
207 commands.push(Command {
208 name: string.clone(),
209 action,
210 });
211 matches.insert(
212 0,
213 StringMatch {
214 candidate_id: commands.len() - 1,
215 string,
216 positions,
217 score: 0.0,
218 },
219 )
220 }
221 self.commands = commands;
222 self.matches = matches;
223 if self.matches.is_empty() {
224 self.selected_ix = 0;
225 } else {
226 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
227 }
228 }
229}
230
231impl PickerDelegate for CommandPaletteDelegate {
232 type ListItem = ListItem;
233
234 fn placeholder_text(&self) -> Arc<str> {
235 "Execute a command...".into()
236 }
237
238 fn match_count(&self) -> usize {
239 self.matches.len()
240 }
241
242 fn selected_index(&self) -> usize {
243 self.selected_ix
244 }
245
246 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
247 self.selected_ix = ix;
248 }
249
250 fn update_matches(
251 &mut self,
252 query: String,
253 cx: &mut ViewContext<Picker<Self>>,
254 ) -> gpui::Task<()> {
255 let (mut tx, mut rx) = postage::dispatch::channel(1);
256 let task = cx.background_executor().spawn({
257 let mut commands = self.all_commands.clone();
258 let hit_counts = cx.global::<HitCounts>().clone();
259 let executor = cx.background_executor().clone();
260 let query = query.clone();
261 async move {
262 commands.sort_by_key(|action| {
263 (
264 Reverse(hit_counts.0.get(&action.name).cloned()),
265 action.name.clone(),
266 )
267 });
268
269 let candidates = commands
270 .iter()
271 .enumerate()
272 .map(|(ix, command)| StringMatchCandidate {
273 id: ix,
274 string: command.name.to_string(),
275 char_bag: command.name.chars().collect(),
276 })
277 .collect::<Vec<_>>();
278
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 let ret = fuzzy::match_strings(
292 &candidates,
293 &query,
294 true,
295 10000,
296 &Default::default(),
297 executor,
298 )
299 .await;
300 ret
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 self.telemetry
363 .report_action_event("command palette", command.name.clone());
364
365 self.matches.clear();
366 self.commands.clear();
367 cx.update_global(|hit_counts: &mut HitCounts, _| {
368 *hit_counts.0.entry(command.name).or_default() += 1;
369 });
370 let action = command.action;
371 cx.focus(&self.previous_focus_handle);
372 self.dismissed(cx);
373 cx.dispatch_action(action);
374 }
375
376 fn render_match(
377 &self,
378 ix: usize,
379 selected: bool,
380 cx: &mut ViewContext<Picker<Self>>,
381 ) -> Option<Self::ListItem> {
382 let r#match = self.matches.get(ix)?;
383 let command = self.commands.get(r#match.candidate_id)?;
384 Some(
385 ListItem::new(ix)
386 .inset(true)
387 .spacing(ListItemSpacing::Sparse)
388 .selected(selected)
389 .child(
390 h_flex()
391 .w_full()
392 .justify_between()
393 .child(HighlightedLabel::new(
394 command.name.clone(),
395 r#match.positions.clone(),
396 ))
397 .children(KeyBinding::for_action_in(
398 &*command.action,
399 &self.previous_focus_handle,
400 cx,
401 )),
402 ),
403 )
404 }
405}
406
407fn humanize_action_name(name: &str) -> String {
408 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
409 let mut result = String::with_capacity(capacity);
410 for char in name.chars() {
411 if char == ':' {
412 if result.ends_with(':') {
413 result.push(' ');
414 } else {
415 result.push(':');
416 }
417 } else if char == '_' {
418 result.push(' ');
419 } else if char.is_uppercase() {
420 if !result.ends_with(' ') {
421 result.push(' ');
422 }
423 result.extend(char.to_lowercase());
424 } else {
425 result.push(char);
426 }
427 }
428 result
429}
430
431impl std::fmt::Debug for Command {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 f.debug_struct("Command")
434 .field("name", &self.name)
435 .finish_non_exhaustive()
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use std::sync::Arc;
442
443 use super::*;
444 use editor::Editor;
445 use go_to_line::GoToLine;
446 use gpui::TestAppContext;
447 use language::Point;
448 use project::Project;
449 use settings::KeymapFile;
450 use workspace::{AppState, Workspace};
451
452 #[test]
453 fn test_humanize_action_name() {
454 assert_eq!(
455 humanize_action_name("editor::GoToDefinition"),
456 "editor: go to definition"
457 );
458 assert_eq!(
459 humanize_action_name("editor::Backspace"),
460 "editor: backspace"
461 );
462 assert_eq!(
463 humanize_action_name("go_to_line::Deploy"),
464 "go to line: deploy"
465 );
466 }
467
468 #[gpui::test]
469 async fn test_command_palette(cx: &mut TestAppContext) {
470 let app_state = init_test(cx);
471 let project = Project::test(app_state.fs.clone(), [], cx).await;
472 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
473
474 let editor = cx.new_view(|cx| {
475 let mut editor = Editor::single_line(cx);
476 editor.set_text("abc", cx);
477 editor
478 });
479
480 workspace.update(cx, |workspace, cx| {
481 workspace.add_item(Box::new(editor.clone()), cx);
482 editor.update(cx, |editor, cx| editor.focus(cx))
483 });
484
485 cx.simulate_keystrokes("cmd-shift-p");
486
487 let palette = workspace.update(cx, |workspace, cx| {
488 workspace
489 .active_modal::<CommandPalette>(cx)
490 .unwrap()
491 .read(cx)
492 .picker
493 .clone()
494 });
495
496 palette.update(cx, |palette, _| {
497 assert!(palette.delegate.commands.len() > 5);
498 let is_sorted =
499 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
500 assert!(is_sorted(&palette.delegate.commands));
501 });
502
503 cx.simulate_input("bcksp");
504
505 palette.update(cx, |palette, _| {
506 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
507 });
508
509 cx.simulate_keystrokes("enter");
510
511 workspace.update(cx, |workspace, cx| {
512 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
513 assert_eq!(editor.read(cx).text(cx), "ab")
514 });
515
516 // Add namespace filter, and redeploy the palette
517 cx.update(|cx| {
518 cx.set_global(CommandPaletteFilter::default());
519 cx.update_global::<CommandPaletteFilter, _>(|filter, _| {
520 filter.hidden_namespaces.insert("editor");
521 })
522 });
523
524 cx.simulate_keystrokes("cmd-shift-p");
525 cx.simulate_input("bcksp");
526
527 let palette = workspace.update(cx, |workspace, cx| {
528 workspace
529 .active_modal::<CommandPalette>(cx)
530 .unwrap()
531 .read(cx)
532 .picker
533 .clone()
534 });
535 palette.update(cx, |palette, _| {
536 assert!(palette.delegate.matches.is_empty())
537 });
538 }
539
540 #[gpui::test]
541 async fn test_go_to_line(cx: &mut TestAppContext) {
542 let app_state = init_test(cx);
543 let project = Project::test(app_state.fs.clone(), [], cx).await;
544 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
545
546 cx.simulate_keystrokes("cmd-n");
547
548 let editor = workspace.update(cx, |workspace, cx| {
549 workspace.active_item_as::<Editor>(cx).unwrap()
550 });
551 editor.update(cx, |editor, cx| editor.set_text("1\n2\n3\n4\n5\n6\n", cx));
552
553 cx.simulate_keystrokes("cmd-shift-p");
554 cx.simulate_input("go to line: Toggle");
555 cx.simulate_keystrokes("enter");
556
557 workspace.update(cx, |workspace, cx| {
558 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
559 });
560
561 cx.simulate_keystrokes("3 enter");
562
563 editor.update(cx, |editor, cx| {
564 assert!(editor.focus_handle(cx).is_focused(cx));
565 assert_eq!(
566 editor.selections.last::<Point>(cx).range().start,
567 Point::new(2, 0)
568 );
569 });
570 }
571
572 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
573 cx.update(|cx| {
574 let app_state = AppState::test(cx);
575 theme::init(theme::LoadThemes::JustBase, cx);
576 language::init(cx);
577 editor::init(cx);
578 menu::init();
579 go_to_line::init(cx);
580 workspace::init(app_state.clone(), cx);
581 init(cx);
582 Project::init_settings(cx);
583 KeymapFile::parse(
584 r#"[
585 {
586 "bindings": {
587 "cmd-n": "workspace::NewFile",
588 "enter": "menu::Confirm",
589 "cmd-shift-p": "command_palette::Toggle"
590 }
591 }
592 ]"#,
593 )
594 .unwrap()
595 .add_to_cx(cx)
596 .unwrap();
597 app_state
598 })
599 }
600}