command_palette.rs

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