command_palette.rs

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