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