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