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