command_palette.rs

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