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::new(ix, &command.name))
287                    .collect::<Vec<_>>();
288                let matches = if query.is_empty() {
289                    candidates
290                        .into_iter()
291                        .enumerate()
292                        .map(|(index, candidate)| StringMatch {
293                            candidate_id: index,
294                            string: candidate.string,
295                            positions: Vec::new(),
296                            score: 0.0,
297                        })
298                        .collect()
299                } else {
300                    fuzzy::match_strings(
301                        &candidates,
302                        &query,
303                        true,
304                        10000,
305                        &Default::default(),
306                        executor,
307                    )
308                    .await
309                };
310
311                tx.send((commands, matches)).await.log_err();
312            }
313        });
314        self.updating_matches = Some((task, rx.clone()));
315
316        cx.spawn(move |picker, mut cx| async move {
317            let Some((commands, matches)) = rx.recv().await else {
318                return;
319            };
320
321            picker
322                .update(&mut cx, |picker, cx| {
323                    picker
324                        .delegate
325                        .matches_updated(query, commands, matches, cx)
326                })
327                .log_err();
328        })
329    }
330
331    fn finalize_update_matches(
332        &mut self,
333        query: String,
334        duration: Duration,
335        cx: &mut ViewContext<Picker<Self>>,
336    ) -> bool {
337        let Some((task, rx)) = self.updating_matches.take() else {
338            return true;
339        };
340
341        match cx
342            .background_executor()
343            .block_with_timeout(duration, rx.clone().recv())
344        {
345            Ok(Some((commands, matches))) => {
346                self.matches_updated(query, commands, matches, cx);
347                true
348            }
349            _ => {
350                self.updating_matches = Some((task, rx));
351                false
352            }
353        }
354    }
355
356    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
357        self.command_palette
358            .update(cx, |_, cx| cx.emit(DismissEvent))
359            .log_err();
360    }
361
362    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
363        if self.matches.is_empty() {
364            self.dismissed(cx);
365            return;
366        }
367        let action_ix = self.matches[self.selected_ix].candidate_id;
368        let command = self.commands.swap_remove(action_ix);
369
370        self.telemetry
371            .report_action_event("command palette", command.name.clone());
372
373        self.matches.clear();
374        self.commands.clear();
375        HitCounts::update_global(cx, |hit_counts, _cx| {
376            *hit_counts.0.entry(command.name).or_default() += 1;
377        });
378        let action = command.action;
379        cx.focus(&self.previous_focus_handle);
380        self.dismissed(cx);
381        cx.dispatch_action(action);
382    }
383
384    fn render_match(
385        &self,
386        ix: usize,
387        selected: bool,
388        cx: &mut ViewContext<Picker<Self>>,
389    ) -> Option<Self::ListItem> {
390        let r#match = self.matches.get(ix)?;
391        let command = self.commands.get(r#match.candidate_id)?;
392        Some(
393            ListItem::new(ix)
394                .inset(true)
395                .spacing(ListItemSpacing::Sparse)
396                .toggle_state(selected)
397                .child(
398                    h_flex()
399                        .w_full()
400                        .py_px()
401                        .justify_between()
402                        .child(HighlightedLabel::new(
403                            command.name.clone(),
404                            r#match.positions.clone(),
405                        ))
406                        .children(KeyBinding::for_action_in(
407                            &*command.action,
408                            &self.previous_focus_handle,
409                            cx,
410                        )),
411                ),
412        )
413    }
414}
415
416fn humanize_action_name(name: &str) -> String {
417    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
418    let mut result = String::with_capacity(capacity);
419    for char in name.chars() {
420        if char == ':' {
421            if result.ends_with(':') {
422                result.push(' ');
423            } else {
424                result.push(':');
425            }
426        } else if char == '_' {
427            result.push(' ');
428        } else if char.is_uppercase() {
429            if !result.ends_with(' ') {
430                result.push(' ');
431            }
432            result.extend(char.to_lowercase());
433        } else {
434            result.push(char);
435        }
436    }
437    result
438}
439
440impl std::fmt::Debug for Command {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("Command")
443            .field("name", &self.name)
444            .finish_non_exhaustive()
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use std::sync::Arc;
451
452    use super::*;
453    use editor::Editor;
454    use go_to_line::GoToLine;
455    use gpui::TestAppContext;
456    use language::Point;
457    use project::Project;
458    use settings::KeymapFile;
459    use workspace::{AppState, Workspace};
460
461    #[test]
462    fn test_humanize_action_name() {
463        assert_eq!(
464            humanize_action_name("editor::GoToDefinition"),
465            "editor: go to definition"
466        );
467        assert_eq!(
468            humanize_action_name("editor::Backspace"),
469            "editor: backspace"
470        );
471        assert_eq!(
472            humanize_action_name("go_to_line::Deploy"),
473            "go to line: deploy"
474        );
475    }
476
477    #[gpui::test]
478    async fn test_command_palette(cx: &mut TestAppContext) {
479        let app_state = init_test(cx);
480        let project = Project::test(app_state.fs.clone(), [], cx).await;
481        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
482
483        let editor = cx.new_view(|cx| {
484            let mut editor = Editor::single_line(cx);
485            editor.set_text("abc", cx);
486            editor
487        });
488
489        workspace.update(cx, |workspace, cx| {
490            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, cx);
491            editor.update(cx, |editor, cx| editor.focus(cx))
492        });
493
494        cx.simulate_keystrokes("cmd-shift-p");
495
496        let palette = workspace.update(cx, |workspace, cx| {
497            workspace
498                .active_modal::<CommandPalette>(cx)
499                .unwrap()
500                .read(cx)
501                .picker
502                .clone()
503        });
504
505        palette.update(cx, |palette, _| {
506            assert!(palette.delegate.commands.len() > 5);
507            let is_sorted =
508                |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
509            assert!(is_sorted(&palette.delegate.commands));
510        });
511
512        cx.simulate_input("bcksp");
513
514        palette.update(cx, |palette, _| {
515            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
516        });
517
518        cx.simulate_keystrokes("enter");
519
520        workspace.update(cx, |workspace, cx| {
521            assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
522            assert_eq!(editor.read(cx).text(cx), "ab")
523        });
524
525        // Add namespace filter, and redeploy the palette
526        cx.update(|cx| {
527            CommandPaletteFilter::update_global(cx, |filter, _| {
528                filter.hide_namespace("editor");
529            });
530        });
531
532        cx.simulate_keystrokes("cmd-shift-p");
533        cx.simulate_input("bcksp");
534
535        let palette = workspace.update(cx, |workspace, cx| {
536            workspace
537                .active_modal::<CommandPalette>(cx)
538                .unwrap()
539                .read(cx)
540                .picker
541                .clone()
542        });
543        palette.update(cx, |palette, _| {
544            assert!(palette.delegate.matches.is_empty())
545        });
546    }
547
548    #[gpui::test]
549    async fn test_go_to_line(cx: &mut TestAppContext) {
550        let app_state = init_test(cx);
551        let project = Project::test(app_state.fs.clone(), [], cx).await;
552        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
553
554        cx.simulate_keystrokes("cmd-n");
555
556        let editor = workspace.update(cx, |workspace, cx| {
557            workspace.active_item_as::<Editor>(cx).unwrap()
558        });
559        editor.update(cx, |editor, cx| editor.set_text("1\n2\n3\n4\n5\n6\n", cx));
560
561        cx.simulate_keystrokes("cmd-shift-p");
562        cx.simulate_input("go to line: Toggle");
563        cx.simulate_keystrokes("enter");
564
565        workspace.update(cx, |workspace, cx| {
566            assert!(workspace.active_modal::<GoToLine>(cx).is_some())
567        });
568
569        cx.simulate_keystrokes("3 enter");
570
571        editor.update(cx, |editor, cx| {
572            assert!(editor.focus_handle(cx).is_focused(cx));
573            assert_eq!(
574                editor.selections.last::<Point>(cx).range().start,
575                Point::new(2, 0)
576            );
577        });
578    }
579
580    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
581        cx.update(|cx| {
582            let app_state = AppState::test(cx);
583            theme::init(theme::LoadThemes::JustBase, cx);
584            language::init(cx);
585            editor::init(cx);
586            menu::init();
587            go_to_line::init(cx);
588            workspace::init(app_state.clone(), cx);
589            init(cx);
590            Project::init_settings(cx);
591            KeymapFile::parse(
592                r#"[
593                    {
594                        "bindings": {
595                            "cmd-n": "workspace::NewFile",
596                            "enter": "menu::Confirm",
597                            "cmd-shift-p": "command_palette::Toggle"
598                        }
599                    }
600                ]"#,
601            )
602            .unwrap()
603            .add_to_cx(cx)
604            .unwrap();
605            app_state
606        })
607    }
608}