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