command_palette.rs

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