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