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