command_palette.rs

  1use collections::{CommandPaletteFilter, HashMap};
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use gpui::{
  4    actions, anyhow::anyhow, elements::*, keymap_matcher::Keystroke, Action, AnyWindowHandle,
  5    AppContext, Element, MouseState, ViewContext,
  6};
  7use picker::{Picker, PickerDelegate, PickerEvent};
  8use std::cmp::{self, Reverse};
  9use util::ResultExt;
 10use workspace::Workspace;
 11
 12pub fn init(cx: &mut AppContext) {
 13    cx.add_action(toggle_command_palette);
 14    CommandPalette::init(cx);
 15}
 16
 17actions!(command_palette, [Toggle]);
 18
 19pub type CommandPalette = Picker<CommandPaletteDelegate>;
 20
 21pub struct CommandPaletteDelegate {
 22    actions: Vec<Command>,
 23    matches: Vec<StringMatch>,
 24    selected_ix: usize,
 25    focused_view_id: usize,
 26}
 27
 28pub enum Event {
 29    Dismissed,
 30    Confirmed {
 31        window: AnyWindowHandle,
 32        focused_view_id: usize,
 33        action: Box<dyn Action>,
 34    },
 35}
 36struct Command {
 37    name: String,
 38    action: Box<dyn Action>,
 39    keystrokes: Vec<Keystroke>,
 40}
 41
 42/// Hit count for each command in the palette.
 43/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
 44/// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
 45#[derive(Default)]
 46struct HitCounts(HashMap<String, usize>);
 47
 48fn toggle_command_palette(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
 49    let focused_view_id = cx.focused_view_id().unwrap_or_else(|| cx.view_id());
 50    workspace.toggle_modal(cx, |_, cx| {
 51        cx.add_view(|cx| Picker::new(CommandPaletteDelegate::new(focused_view_id), cx))
 52    });
 53}
 54
 55impl CommandPaletteDelegate {
 56    pub fn new(focused_view_id: usize) -> Self {
 57        Self {
 58            actions: Default::default(),
 59            matches: vec![],
 60            selected_ix: 0,
 61            focused_view_id,
 62        }
 63    }
 64}
 65
 66impl PickerDelegate for CommandPaletteDelegate {
 67    fn placeholder_text(&self) -> std::sync::Arc<str> {
 68        "Execute a command...".into()
 69    }
 70
 71    fn match_count(&self) -> usize {
 72        self.matches.len()
 73    }
 74
 75    fn selected_index(&self) -> usize {
 76        self.selected_ix
 77    }
 78
 79    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
 80        self.selected_ix = ix;
 81    }
 82
 83    fn update_matches(
 84        &mut self,
 85        query: String,
 86        cx: &mut ViewContext<Picker<Self>>,
 87    ) -> gpui::Task<()> {
 88        let view_id = self.focused_view_id;
 89        let window = cx.window();
 90        cx.spawn(move |picker, mut cx| async move {
 91            let mut actions = window
 92                .available_actions(view_id, &cx)
 93                .into_iter()
 94                .flatten()
 95                .filter_map(|(name, action, bindings)| {
 96                    let filtered = cx.read(|cx| {
 97                        if cx.has_global::<CommandPaletteFilter>() {
 98                            let filter = cx.global::<CommandPaletteFilter>();
 99                            filter.filtered_namespaces.contains(action.namespace())
100                        } else {
101                            false
102                        }
103                    });
104
105                    if filtered {
106                        None
107                    } else {
108                        Some(Command {
109                            name: humanize_action_name(name),
110                            action,
111                            keystrokes: bindings
112                                .iter()
113                                .map(|binding| binding.keystrokes())
114                                .last()
115                                .map_or(Vec::new(), |keystrokes| keystrokes.to_vec()),
116                        })
117                    }
118                })
119                .collect::<Vec<_>>();
120            let actions = cx.read(move |cx| {
121                let hit_counts = cx.optional_global::<HitCounts>();
122                actions.sort_by_key(|action| {
123                    (
124                        Reverse(hit_counts.and_then(|map| map.0.get(&action.name)).cloned()),
125                        action.name.clone(),
126                    )
127                });
128                actions
129            });
130            let candidates = actions
131                .iter()
132                .enumerate()
133                .map(|(ix, command)| StringMatchCandidate {
134                    id: ix,
135                    string: command.name.to_string(),
136                    char_bag: command.name.chars().collect(),
137                })
138                .collect::<Vec<_>>();
139            let matches = if query.is_empty() {
140                candidates
141                    .into_iter()
142                    .enumerate()
143                    .map(|(index, candidate)| StringMatch {
144                        candidate_id: index,
145                        string: candidate.string,
146                        positions: Vec::new(),
147                        score: 0.0,
148                    })
149                    .collect()
150            } else {
151                fuzzy::match_strings(
152                    &candidates,
153                    &query,
154                    true,
155                    10000,
156                    &Default::default(),
157                    cx.background(),
158                )
159                .await
160            };
161            picker
162                .update(&mut cx, |picker, _| {
163                    let delegate = picker.delegate_mut();
164                    delegate.actions = actions;
165                    delegate.matches = matches;
166                    if delegate.matches.is_empty() {
167                        delegate.selected_ix = 0;
168                    } else {
169                        delegate.selected_ix =
170                            cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
171                    }
172                })
173                .log_err();
174        })
175    }
176
177    fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
178
179    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
180        if !self.matches.is_empty() {
181            let window = cx.window();
182            let focused_view_id = self.focused_view_id;
183            let action_ix = self.matches[self.selected_ix].candidate_id;
184            let command = self.actions.remove(action_ix);
185            cx.update_default_global(|hit_counts: &mut HitCounts, _| {
186                *hit_counts.0.entry(command.name).or_default() += 1;
187            });
188            let action = command.action;
189
190            cx.app_context()
191                .spawn(move |mut cx| async move {
192                    window
193                        .dispatch_action(focused_view_id, action.as_ref(), &mut cx)
194                        .ok_or_else(|| anyhow!("window was closed"))
195                })
196                .detach_and_log_err(cx);
197        }
198        cx.emit(PickerEvent::Dismiss);
199    }
200
201    fn render_match(
202        &self,
203        ix: usize,
204        mouse_state: &mut MouseState,
205        selected: bool,
206        cx: &gpui::AppContext,
207    ) -> AnyElement<Picker<Self>> {
208        let mat = &self.matches[ix];
209        let command = &self.actions[mat.candidate_id];
210        let theme = theme::current(cx);
211        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
212        let key_style = &theme.command_palette.key.in_state(selected);
213        let keystroke_spacing = theme.command_palette.keystroke_spacing;
214
215        Flex::row()
216            .with_child(
217                Label::new(mat.string.clone(), style.label.clone())
218                    .with_highlights(mat.positions.clone()),
219            )
220            .with_children(command.keystrokes.iter().map(|keystroke| {
221                Flex::row()
222                    .with_children(
223                        [
224                            (keystroke.ctrl, "^"),
225                            (keystroke.alt, ""),
226                            (keystroke.cmd, ""),
227                            (keystroke.shift, ""),
228                        ]
229                        .into_iter()
230                        .filter_map(|(modifier, label)| {
231                            if modifier {
232                                Some(
233                                    Label::new(label, key_style.label.clone())
234                                        .contained()
235                                        .with_style(key_style.container),
236                                )
237                            } else {
238                                None
239                            }
240                        }),
241                    )
242                    .with_child(
243                        Label::new(keystroke.key.clone(), key_style.label.clone())
244                            .contained()
245                            .with_style(key_style.container),
246                    )
247                    .contained()
248                    .with_margin_left(keystroke_spacing)
249                    .flex_float()
250            }))
251            .contained()
252            .with_style(style.container)
253            .into_any()
254    }
255}
256
257fn humanize_action_name(name: &str) -> String {
258    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
259    let mut result = String::with_capacity(capacity);
260    for char in name.chars() {
261        if char == ':' {
262            if result.ends_with(':') {
263                result.push(' ');
264            } else {
265                result.push(':');
266            }
267        } else if char == '_' {
268            result.push(' ');
269        } else if char.is_uppercase() {
270            if !result.ends_with(' ') {
271                result.push(' ');
272            }
273            result.extend(char.to_lowercase());
274        } else {
275            result.push(char);
276        }
277    }
278    result
279}
280
281impl std::fmt::Debug for Command {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("Command")
284            .field("name", &self.name)
285            .field("keystrokes", &self.keystrokes)
286            .finish()
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use std::sync::Arc;
293
294    use super::*;
295    use editor::Editor;
296    use gpui::{executor::Deterministic, TestAppContext};
297    use project::Project;
298    use workspace::{AppState, Workspace};
299
300    #[test]
301    fn test_humanize_action_name() {
302        assert_eq!(
303            humanize_action_name("editor::GoToDefinition"),
304            "editor: go to definition"
305        );
306        assert_eq!(
307            humanize_action_name("editor::Backspace"),
308            "editor: backspace"
309        );
310        assert_eq!(
311            humanize_action_name("go_to_line::Deploy"),
312            "go to line: deploy"
313        );
314    }
315
316    #[gpui::test]
317    async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
318        let app_state = init_test(cx);
319
320        let project = Project::test(app_state.fs.clone(), [], cx).await;
321        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
322        let workspace = window.root(cx);
323        let editor = window.add_view(cx, |cx| {
324            let mut editor = Editor::single_line(None, cx);
325            editor.set_text("abc", cx);
326            editor
327        });
328
329        workspace.update(cx, |workspace, cx| {
330            cx.focus(&editor);
331            workspace.add_item(Box::new(editor.clone()), cx)
332        });
333
334        workspace.update(cx, |workspace, cx| {
335            toggle_command_palette(workspace, &Toggle, cx);
336        });
337
338        let palette = workspace.read_with(cx, |workspace, _| {
339            workspace.modal::<CommandPalette>().unwrap()
340        });
341
342        palette
343            .update(cx, |palette, cx| {
344                // Fill up palette's command list by running an empty query;
345                // we only need it to subsequently assert that the palette is initially
346                // sorted by command's name.
347                palette.delegate_mut().update_matches("".to_string(), cx)
348            })
349            .await;
350
351        palette.update(cx, |palette, _| {
352            let is_sorted =
353                |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
354            assert!(is_sorted(&palette.delegate().actions));
355        });
356
357        palette
358            .update(cx, |palette, cx| {
359                palette
360                    .delegate_mut()
361                    .update_matches("bcksp".to_string(), cx)
362            })
363            .await;
364
365        palette.update(cx, |palette, cx| {
366            assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
367            palette.confirm(&Default::default(), cx);
368        });
369        deterministic.run_until_parked();
370        editor.read_with(cx, |editor, cx| {
371            assert_eq!(editor.text(cx), "ab");
372        });
373
374        // Add namespace filter, and redeploy the palette
375        cx.update(|cx| {
376            cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
377                filter.filtered_namespaces.insert("editor");
378            })
379        });
380
381        workspace.update(cx, |workspace, cx| {
382            toggle_command_palette(workspace, &Toggle, cx);
383        });
384
385        // Assert editor command not present
386        let palette = workspace.read_with(cx, |workspace, _| {
387            workspace.modal::<CommandPalette>().unwrap()
388        });
389
390        palette
391            .update(cx, |palette, cx| {
392                palette
393                    .delegate_mut()
394                    .update_matches("bcksp".to_string(), cx)
395            })
396            .await;
397
398        palette.update(cx, |palette, _| {
399            assert!(palette.delegate().matches.is_empty())
400        });
401    }
402
403    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
404        cx.update(|cx| {
405            let app_state = AppState::test(cx);
406            theme::init((), cx);
407            language::init(cx);
408            editor::init(cx);
409            workspace::init(app_state.clone(), cx);
410            init(cx);
411            Project::init_settings(cx);
412            app_state
413        })
414    }
415}