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