command_palette.rs

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