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_results = CommandPaletteInterceptor::try_global(cx)
202            .map(|interceptor| interceptor.intercept(&query, cx))
203            .unwrap_or_default();
204
205        if parse_zed_link(&query, cx).is_some() {
206            intercept_results = vec![CommandInterceptResult {
207                action: OpenZedUrl { url: query.clone() }.boxed_clone(),
208                string: query.clone(),
209                positions: vec![],
210            }]
211        }
212
213        let mut new_matches = Vec::new();
214
215        for CommandInterceptResult {
216            action,
217            string,
218            positions,
219        } in intercept_results
220        {
221            if let Some(idx) = matches
222                .iter()
223                .position(|m| commands[m.candidate_id].action.partial_eq(&*action))
224            {
225                matches.remove(idx);
226            }
227            commands.push(Command {
228                name: string.clone(),
229                action,
230            });
231            new_matches.push(StringMatch {
232                candidate_id: commands.len() - 1,
233                string,
234                positions,
235                score: 0.0,
236            })
237        }
238        new_matches.append(&mut matches);
239        self.commands = commands;
240        self.matches = new_matches;
241        if self.matches.is_empty() {
242            self.selected_ix = 0;
243        } else {
244            self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
245        }
246    }
247}
248
249impl PickerDelegate for CommandPaletteDelegate {
250    type ListItem = ListItem;
251
252    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
253        "Execute a command...".into()
254    }
255
256    fn match_count(&self) -> usize {
257        self.matches.len()
258    }
259
260    fn selected_index(&self) -> usize {
261        self.selected_ix
262    }
263
264    fn set_selected_index(
265        &mut self,
266        ix: usize,
267        _window: &mut Window,
268        _: &mut Context<Picker<Self>>,
269    ) {
270        self.selected_ix = ix;
271    }
272
273    fn update_matches(
274        &mut self,
275        mut query: String,
276        window: &mut Window,
277        cx: &mut Context<Picker<Self>>,
278    ) -> gpui::Task<()> {
279        let settings = WorkspaceSettings::get_global(cx);
280        if let Some(alias) = settings.command_aliases.get(&query) {
281            query = alias.to_string();
282        }
283        let (mut tx, mut rx) = postage::dispatch::channel(1);
284        let task = cx.background_spawn({
285            let mut commands = self.all_commands.clone();
286            let hit_counts = cx.global::<HitCounts>().clone();
287            let executor = cx.background_executor().clone();
288            let query = normalize_query(query.as_str());
289            async move {
290                commands.sort_by_key(|action| {
291                    (
292                        Reverse(hit_counts.0.get(&action.name).cloned()),
293                        action.name.clone(),
294                    )
295                });
296
297                let candidates = commands
298                    .iter()
299                    .enumerate()
300                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
301                    .collect::<Vec<_>>();
302                let matches = if query.is_empty() {
303                    candidates
304                        .into_iter()
305                        .enumerate()
306                        .map(|(index, candidate)| StringMatch {
307                            candidate_id: index,
308                            string: candidate.string,
309                            positions: Vec::new(),
310                            score: 0.0,
311                        })
312                        .collect()
313                } else {
314                    fuzzy::match_strings(
315                        &candidates,
316                        &query,
317                        true,
318                        10000,
319                        &Default::default(),
320                        executor,
321                    )
322                    .await
323                };
324
325                tx.send((commands, matches)).await.log_err();
326            }
327        });
328        self.updating_matches = Some((task, rx.clone()));
329
330        cx.spawn_in(window, async move |picker, cx| {
331            let Some((commands, matches)) = rx.recv().await else {
332                return;
333            };
334
335            picker
336                .update(cx, |picker, cx| {
337                    picker
338                        .delegate
339                        .matches_updated(query, commands, matches, cx)
340                })
341                .log_err();
342        })
343    }
344
345    fn finalize_update_matches(
346        &mut self,
347        query: String,
348        duration: Duration,
349        _: &mut Window,
350        cx: &mut Context<Picker<Self>>,
351    ) -> bool {
352        let Some((task, rx)) = self.updating_matches.take() else {
353            return true;
354        };
355
356        match cx
357            .background_executor()
358            .block_with_timeout(duration, rx.clone().recv())
359        {
360            Ok(Some((commands, matches))) => {
361                self.matches_updated(query, commands, matches, cx);
362                true
363            }
364            _ => {
365                self.updating_matches = Some((task, rx));
366                false
367            }
368        }
369    }
370
371    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
372        self.command_palette
373            .update(cx, |_, cx| cx.emit(DismissEvent))
374            .log_err();
375    }
376
377    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
378        if self.matches.is_empty() {
379            self.dismissed(window, cx);
380            return;
381        }
382        let action_ix = self.matches[self.selected_ix].candidate_id;
383        let command = self.commands.swap_remove(action_ix);
384        telemetry::event!(
385            "Action Invoked",
386            source = "command palette",
387            action = command.name
388        );
389        self.matches.clear();
390        self.commands.clear();
391        HitCounts::update_global(cx, |hit_counts, _cx| {
392            *hit_counts.0.entry(command.name).or_default() += 1;
393        });
394        let action = command.action;
395        window.focus(&self.previous_focus_handle);
396        self.dismissed(window, cx);
397        window.dispatch_action(action, cx);
398    }
399
400    fn render_match(
401        &self,
402        ix: usize,
403        selected: bool,
404        window: &mut Window,
405        cx: &mut Context<Picker<Self>>,
406    ) -> Option<Self::ListItem> {
407        let r#match = self.matches.get(ix)?;
408        let command = self.commands.get(r#match.candidate_id)?;
409        Some(
410            ListItem::new(ix)
411                .inset(true)
412                .spacing(ListItemSpacing::Sparse)
413                .toggle_state(selected)
414                .child(
415                    h_flex()
416                        .w_full()
417                        .py_px()
418                        .justify_between()
419                        .child(HighlightedLabel::new(
420                            command.name.clone(),
421                            r#match.positions.clone(),
422                        ))
423                        .children(KeyBinding::for_action_in(
424                            &*command.action,
425                            &self.previous_focus_handle,
426                            window,
427                            cx,
428                        )),
429                ),
430        )
431    }
432}
433
434fn humanize_action_name(name: &str) -> String {
435    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
436    let mut result = String::with_capacity(capacity);
437    for char in name.chars() {
438        if char == ':' {
439            if result.ends_with(':') {
440                result.push(' ');
441            } else {
442                result.push(':');
443            }
444        } else if char == '_' {
445            result.push(' ');
446        } else if char.is_uppercase() {
447            if !result.ends_with(' ') {
448                result.push(' ');
449            }
450            result.extend(char.to_lowercase());
451        } else {
452            result.push(char);
453        }
454    }
455    result
456}
457
458impl std::fmt::Debug for Command {
459    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460        f.debug_struct("Command")
461            .field("name", &self.name)
462            .finish_non_exhaustive()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use std::sync::Arc;
469
470    use super::*;
471    use editor::Editor;
472    use go_to_line::GoToLine;
473    use gpui::TestAppContext;
474    use language::Point;
475    use project::Project;
476    use settings::KeymapFile;
477    use workspace::{AppState, Workspace};
478
479    #[test]
480    fn test_humanize_action_name() {
481        assert_eq!(
482            humanize_action_name("editor::GoToDefinition"),
483            "editor: go to definition"
484        );
485        assert_eq!(
486            humanize_action_name("editor::Backspace"),
487            "editor: backspace"
488        );
489        assert_eq!(
490            humanize_action_name("go_to_line::Deploy"),
491            "go to line: deploy"
492        );
493    }
494
495    #[test]
496    fn test_normalize_query() {
497        assert_eq!(normalize_query("editor: backspace"), "editor: backspace");
498        assert_eq!(normalize_query("editor:  backspace"), "editor: backspace");
499        assert_eq!(normalize_query("editor:    backspace"), "editor: backspace");
500        assert_eq!(
501            normalize_query("editor::GoToDefinition"),
502            "editor:GoToDefinition"
503        );
504        assert_eq!(
505            normalize_query("editor::::GoToDefinition"),
506            "editor:GoToDefinition"
507        );
508        assert_eq!(
509            normalize_query("editor: :GoToDefinition"),
510            "editor: :GoToDefinition"
511        );
512    }
513
514    #[gpui::test]
515    async fn test_command_palette(cx: &mut TestAppContext) {
516        let app_state = init_test(cx);
517        let project = Project::test(app_state.fs.clone(), [], cx).await;
518        let (workspace, cx) =
519            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
520
521        let editor = cx.new_window_entity(|window, cx| {
522            let mut editor = Editor::single_line(window, cx);
523            editor.set_text("abc", window, cx);
524            editor
525        });
526
527        workspace.update_in(cx, |workspace, window, cx| {
528            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
529            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
530        });
531
532        cx.simulate_keystrokes("cmd-shift-p");
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
543        palette.update(cx, |palette, _| {
544            assert!(palette.delegate.commands.len() > 5);
545            let is_sorted =
546                |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
547            assert!(is_sorted(&palette.delegate.commands));
548        });
549
550        cx.simulate_input("bcksp");
551
552        palette.update(cx, |palette, _| {
553            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
554        });
555
556        cx.simulate_keystrokes("enter");
557
558        workspace.update(cx, |workspace, cx| {
559            assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
560            assert_eq!(editor.read(cx).text(cx), "ab")
561        });
562
563        // Add namespace filter, and redeploy the palette
564        cx.update(|_window, cx| {
565            CommandPaletteFilter::update_global(cx, |filter, _| {
566                filter.hide_namespace("editor");
567            });
568        });
569
570        cx.simulate_keystrokes("cmd-shift-p");
571        cx.simulate_input("bcksp");
572
573        let palette = workspace.update(cx, |workspace, cx| {
574            workspace
575                .active_modal::<CommandPalette>(cx)
576                .unwrap()
577                .read(cx)
578                .picker
579                .clone()
580        });
581        palette.update(cx, |palette, _| {
582            assert!(palette.delegate.matches.is_empty())
583        });
584    }
585    #[gpui::test]
586    async fn test_normalized_matches(cx: &mut TestAppContext) {
587        let app_state = init_test(cx);
588        let project = Project::test(app_state.fs.clone(), [], cx).await;
589        let (workspace, cx) =
590            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
591
592        let editor = cx.new_window_entity(|window, cx| {
593            let mut editor = Editor::single_line(window, cx);
594            editor.set_text("abc", window, cx);
595            editor
596        });
597
598        workspace.update_in(cx, |workspace, window, cx| {
599            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
600            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
601        });
602
603        // Test normalize (trimming whitespace and double colons)
604        cx.simulate_keystrokes("cmd-shift-p");
605
606        let palette = workspace.update(cx, |workspace, cx| {
607            workspace
608                .active_modal::<CommandPalette>(cx)
609                .unwrap()
610                .read(cx)
611                .picker
612                .clone()
613        });
614
615        cx.simulate_input("Editor::    Backspace");
616        palette.update(cx, |palette, _| {
617            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
618        });
619    }
620
621    #[gpui::test]
622    async fn test_go_to_line(cx: &mut TestAppContext) {
623        let app_state = init_test(cx);
624        let project = Project::test(app_state.fs.clone(), [], cx).await;
625        let (workspace, cx) =
626            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
627
628        cx.simulate_keystrokes("cmd-n");
629
630        let editor = workspace.update(cx, |workspace, cx| {
631            workspace.active_item_as::<Editor>(cx).unwrap()
632        });
633        editor.update_in(cx, |editor, window, cx| {
634            editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
635        });
636
637        cx.simulate_keystrokes("cmd-shift-p");
638        cx.simulate_input("go to line: Toggle");
639        cx.simulate_keystrokes("enter");
640
641        workspace.update(cx, |workspace, cx| {
642            assert!(workspace.active_modal::<GoToLine>(cx).is_some())
643        });
644
645        cx.simulate_keystrokes("3 enter");
646
647        editor.update_in(cx, |editor, window, cx| {
648            assert!(editor.focus_handle(cx).is_focused(window));
649            assert_eq!(
650                editor.selections.last::<Point>(cx).range().start,
651                Point::new(2, 0)
652            );
653        });
654    }
655
656    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
657        cx.update(|cx| {
658            let app_state = AppState::test(cx);
659            theme::init(theme::LoadThemes::JustBase, cx);
660            language::init(cx);
661            editor::init(cx);
662            menu::init();
663            go_to_line::init(cx);
664            workspace::init(app_state.clone(), cx);
665            init(cx);
666            Project::init_settings(cx);
667            cx.bind_keys(KeymapFile::load_panic_on_failure(
668                r#"[
669                    {
670                        "bindings": {
671                            "cmd-n": "workspace::NewFile",
672                            "enter": "menu::Confirm",
673                            "cmd-shift-p": "command_palette::Toggle"
674                        }
675                    }
676                ]"#,
677                cx,
678            ));
679            app_state
680        })
681    }
682}