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