command_palette.rs

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