command_palette.rs

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