command_palette.rs

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