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