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