command_palette.rs

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