command_palette.rs

  1use std::{
  2    cmp::{self, Reverse},
  3    sync::Arc,
  4    time::Duration,
  5};
  6
  7use client::telemetry::Telemetry;
  8use collections::HashMap;
  9use command_palette_hooks::{
 10    CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
 11};
 12use fuzzy::{StringMatch, StringMatchCandidate};
 13use gpui::{
 14    actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global,
 15    ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
 16};
 17use picker::{Picker, PickerDelegate};
 18
 19use postage::{sink::Sink, stream::Stream};
 20use release_channel::parse_zed_link;
 21use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
 22use util::ResultExt;
 23use workspace::{ModalView, Workspace};
 24use zed_actions::OpenZedUrl;
 25
 26actions!(command_palette, [Toggle]);
 27
 28pub fn init(cx: &mut AppContext) {
 29    cx.set_global(HitCounts::default());
 30    cx.set_global(CommandPaletteFilter::default());
 31    cx.observe_new_views(CommandPalette::register).detach();
 32}
 33
 34impl ModalView for CommandPalette {}
 35
 36pub struct CommandPalette {
 37    picker: View<Picker<CommandPaletteDelegate>>,
 38}
 39
 40fn trim_consecutive_whitespaces(input: &str) -> String {
 41    let mut result = String::with_capacity(input.len());
 42    let mut last_char_was_whitespace = false;
 43
 44    for char in input.trim().chars() {
 45        if char.is_whitespace() {
 46            if !last_char_was_whitespace {
 47                result.push(char);
 48            }
 49            last_char_was_whitespace = true;
 50        } else {
 51            result.push(char);
 52            last_char_was_whitespace = false;
 53        }
 54    }
 55    result
 56}
 57
 58impl CommandPalette {
 59    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
 60        workspace.register_action(|workspace, _: &Toggle, cx| {
 61            let Some(previous_focus_handle) = cx.focused() else {
 62                return;
 63            };
 64            let telemetry = workspace.client().telemetry().clone();
 65            workspace.toggle_modal(cx, move |cx| {
 66                CommandPalette::new(previous_focus_handle, telemetry, cx)
 67            });
 68        });
 69    }
 70
 71    fn new(
 72        previous_focus_handle: FocusHandle,
 73        telemetry: Arc<Telemetry>,
 74        cx: &mut ViewContext<Self>,
 75    ) -> Self {
 76        let filter = cx.try_global::<CommandPaletteFilter>();
 77
 78        let commands = cx
 79            .available_actions()
 80            .into_iter()
 81            .filter_map(|action| {
 82                let name = action.name();
 83                let namespace = name.split("::").next().unwrap_or("malformed action name");
 84                if filter.is_some_and(|f| {
 85                    f.hidden_namespaces.contains(namespace)
 86                        || f.hidden_action_types.contains(&action.type_id())
 87                }) {
 88                    return None;
 89                }
 90
 91                Some(Command {
 92                    name: humanize_action_name(&name),
 93                    action,
 94                })
 95            })
 96            .collect();
 97
 98        let delegate = CommandPaletteDelegate::new(
 99            cx.view().downgrade(),
100            commands,
101            telemetry,
102            previous_focus_handle,
103        );
104
105        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
106        Self { picker }
107    }
108}
109
110impl EventEmitter<DismissEvent> for CommandPalette {}
111
112impl FocusableView for CommandPalette {
113    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
114        self.picker.focus_handle(cx)
115    }
116}
117
118impl Render for CommandPalette {
119    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
120        v_flex().w(rems(34.)).child(self.picker.clone())
121    }
122}
123
124pub struct CommandPaletteDelegate {
125    command_palette: WeakView<CommandPalette>,
126    all_commands: Vec<Command>,
127    commands: Vec<Command>,
128    matches: Vec<StringMatch>,
129    selected_ix: usize,
130    telemetry: Arc<Telemetry>,
131    previous_focus_handle: FocusHandle,
132    updating_matches: Option<(
133        Task<()>,
134        postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
135    )>,
136}
137
138struct Command {
139    name: String,
140    action: Box<dyn Action>,
141}
142
143impl Clone for Command {
144    fn clone(&self) -> Self {
145        Self {
146            name: self.name.clone(),
147            action: self.action.boxed_clone(),
148        }
149    }
150}
151
152/// Hit count for each command in the palette.
153/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
154/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
155#[derive(Default, Clone)]
156struct HitCounts(HashMap<String, usize>);
157
158impl Global for HitCounts {}
159
160impl CommandPaletteDelegate {
161    fn new(
162        command_palette: WeakView<CommandPalette>,
163        commands: Vec<Command>,
164        telemetry: Arc<Telemetry>,
165        previous_focus_handle: FocusHandle,
166    ) -> Self {
167        Self {
168            command_palette,
169            all_commands: commands.clone(),
170            matches: vec![],
171            commands,
172            selected_ix: 0,
173            telemetry,
174            previous_focus_handle,
175            updating_matches: None,
176        }
177    }
178
179    fn matches_updated(
180        &mut self,
181        query: String,
182        mut commands: Vec<Command>,
183        mut matches: Vec<StringMatch>,
184        cx: &mut ViewContext<Picker<Self>>,
185    ) {
186        self.updating_matches.take();
187
188        let mut intercept_result =
189            if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
190                (interceptor.0)(&query, cx)
191            } else {
192                None
193            };
194
195        if parse_zed_link(&query).is_some() {
196            intercept_result = Some(CommandInterceptResult {
197                action: OpenZedUrl { url: query.clone() }.boxed_clone(),
198                string: query.clone(),
199                positions: vec![],
200            })
201        }
202
203        if let Some(CommandInterceptResult {
204            action,
205            string,
206            positions,
207        }) = intercept_result
208        {
209            if let Some(idx) = matches
210                .iter()
211                .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
212            {
213                matches.remove(idx);
214            }
215            commands.push(Command {
216                name: string.clone(),
217                action,
218            });
219            matches.insert(
220                0,
221                StringMatch {
222                    candidate_id: commands.len() - 1,
223                    string,
224                    positions,
225                    score: 0.0,
226                },
227            )
228        }
229        self.commands = commands;
230        self.matches = matches;
231        if self.matches.is_empty() {
232            self.selected_ix = 0;
233        } else {
234            self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
235        }
236    }
237}
238
239impl PickerDelegate for CommandPaletteDelegate {
240    type ListItem = ListItem;
241
242    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
243        "Execute a command...".into()
244    }
245
246    fn match_count(&self) -> usize {
247        self.matches.len()
248    }
249
250    fn selected_index(&self) -> usize {
251        self.selected_ix
252    }
253
254    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
255        self.selected_ix = ix;
256    }
257
258    fn update_matches(
259        &mut self,
260        query: String,
261        cx: &mut ViewContext<Picker<Self>>,
262    ) -> gpui::Task<()> {
263        let (mut tx, mut rx) = postage::dispatch::channel(1);
264        let task = cx.background_executor().spawn({
265            let mut commands = self.all_commands.clone();
266            let hit_counts = cx.global::<HitCounts>().clone();
267            let executor = cx.background_executor().clone();
268            let query = trim_consecutive_whitespaces(&query.as_str());
269            async move {
270                commands.sort_by_key(|action| {
271                    (
272                        Reverse(hit_counts.0.get(&action.name).cloned()),
273                        action.name.clone(),
274                    )
275                });
276
277                let candidates = commands
278                    .iter()
279                    .enumerate()
280                    .map(|(ix, command)| StringMatchCandidate {
281                        id: ix,
282                        string: command.name.to_string(),
283                        char_bag: command.name.chars().collect(),
284                    })
285                    .collect::<Vec<_>>();
286                let matches = if query.is_empty() {
287                    candidates
288                        .into_iter()
289                        .enumerate()
290                        .map(|(index, candidate)| StringMatch {
291                            candidate_id: index,
292                            string: candidate.string,
293                            positions: Vec::new(),
294                            score: 0.0,
295                        })
296                        .collect()
297                } else {
298                    let ret = fuzzy::match_strings(
299                        &candidates,
300                        &query,
301                        true,
302                        10000,
303                        &Default::default(),
304                        executor,
305                    )
306                    .await;
307                    ret
308                };
309
310                tx.send((commands, matches)).await.log_err();
311            }
312        });
313        self.updating_matches = Some((task, rx.clone()));
314
315        cx.spawn(move |picker, mut cx| async move {
316            let Some((commands, matches)) = rx.recv().await else {
317                return;
318            };
319
320            picker
321                .update(&mut cx, |picker, cx| {
322                    picker
323                        .delegate
324                        .matches_updated(query, commands, matches, cx)
325                })
326                .log_err();
327        })
328    }
329
330    fn finalize_update_matches(
331        &mut self,
332        query: String,
333        duration: Duration,
334        cx: &mut ViewContext<Picker<Self>>,
335    ) -> bool {
336        let Some((task, rx)) = self.updating_matches.take() else {
337            return true;
338        };
339
340        match cx
341            .background_executor()
342            .block_with_timeout(duration, rx.clone().recv())
343        {
344            Ok(Some((commands, matches))) => {
345                self.matches_updated(query, commands, matches, cx);
346                true
347            }
348            _ => {
349                self.updating_matches = Some((task, rx));
350                false
351            }
352        }
353    }
354
355    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
356        self.command_palette
357            .update(cx, |_, cx| cx.emit(DismissEvent))
358            .log_err();
359    }
360
361    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
362        if self.matches.is_empty() {
363            self.dismissed(cx);
364            return;
365        }
366        let action_ix = self.matches[self.selected_ix].candidate_id;
367        let command = self.commands.swap_remove(action_ix);
368
369        self.telemetry
370            .report_action_event("command palette", command.name.clone());
371
372        self.matches.clear();
373        self.commands.clear();
374        cx.update_global(|hit_counts: &mut HitCounts, _| {
375            *hit_counts.0.entry(command.name).or_default() += 1;
376        });
377        let action = command.action;
378        cx.focus(&self.previous_focus_handle);
379        self.dismissed(cx);
380        cx.dispatch_action(action);
381    }
382
383    fn render_match(
384        &self,
385        ix: usize,
386        selected: bool,
387        cx: &mut ViewContext<Picker<Self>>,
388    ) -> Option<Self::ListItem> {
389        let r#match = self.matches.get(ix)?;
390        let command = self.commands.get(r#match.candidate_id)?;
391        Some(
392            ListItem::new(ix)
393                .inset(true)
394                .spacing(ListItemSpacing::Sparse)
395                .selected(selected)
396                .child(
397                    h_flex()
398                        .w_full()
399                        .justify_between()
400                        .child(HighlightedLabel::new(
401                            command.name.clone(),
402                            r#match.positions.clone(),
403                        ))
404                        .children(KeyBinding::for_action_in(
405                            &*command.action,
406                            &self.previous_focus_handle,
407                            cx,
408                        )),
409                ),
410        )
411    }
412}
413
414fn humanize_action_name(name: &str) -> String {
415    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
416    let mut result = String::with_capacity(capacity);
417    for char in name.chars() {
418        if char == ':' {
419            if result.ends_with(':') {
420                result.push(' ');
421            } else {
422                result.push(':');
423            }
424        } else if char == '_' {
425            result.push(' ');
426        } else if char.is_uppercase() {
427            if !result.ends_with(' ') {
428                result.push(' ');
429            }
430            result.extend(char.to_lowercase());
431        } else {
432            result.push(char);
433        }
434    }
435    result
436}
437
438impl std::fmt::Debug for Command {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        f.debug_struct("Command")
441            .field("name", &self.name)
442            .finish_non_exhaustive()
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::sync::Arc;
449
450    use super::*;
451    use editor::Editor;
452    use go_to_line::GoToLine;
453    use gpui::TestAppContext;
454    use language::Point;
455    use project::Project;
456    use settings::KeymapFile;
457    use workspace::{AppState, Workspace};
458
459    #[test]
460    fn test_humanize_action_name() {
461        assert_eq!(
462            humanize_action_name("editor::GoToDefinition"),
463            "editor: go to definition"
464        );
465        assert_eq!(
466            humanize_action_name("editor::Backspace"),
467            "editor: backspace"
468        );
469        assert_eq!(
470            humanize_action_name("go_to_line::Deploy"),
471            "go to line: deploy"
472        );
473    }
474
475    #[gpui::test]
476    async fn test_command_palette(cx: &mut TestAppContext) {
477        let app_state = init_test(cx);
478        let project = Project::test(app_state.fs.clone(), [], cx).await;
479        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
480
481        let editor = cx.new_view(|cx| {
482            let mut editor = Editor::single_line(cx);
483            editor.set_text("abc", cx);
484            editor
485        });
486
487        workspace.update(cx, |workspace, cx| {
488            workspace.add_item_to_active_pane(Box::new(editor.clone()), cx);
489            editor.update(cx, |editor, cx| editor.focus(cx))
490        });
491
492        cx.simulate_keystrokes("cmd-shift-p");
493
494        let palette = workspace.update(cx, |workspace, cx| {
495            workspace
496                .active_modal::<CommandPalette>(cx)
497                .unwrap()
498                .read(cx)
499                .picker
500                .clone()
501        });
502
503        palette.update(cx, |palette, _| {
504            assert!(palette.delegate.commands.len() > 5);
505            let is_sorted =
506                |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
507            assert!(is_sorted(&palette.delegate.commands));
508        });
509
510        cx.simulate_input("bcksp");
511
512        palette.update(cx, |palette, _| {
513            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
514        });
515
516        cx.simulate_keystrokes("enter");
517
518        workspace.update(cx, |workspace, cx| {
519            assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
520            assert_eq!(editor.read(cx).text(cx), "ab")
521        });
522
523        // Add namespace filter, and redeploy the palette
524        cx.update(|cx| {
525            cx.set_global(CommandPaletteFilter::default());
526            cx.update_global::<CommandPaletteFilter, _>(|filter, _| {
527                filter.hidden_namespaces.insert("editor");
528            })
529        });
530
531        cx.simulate_keystrokes("cmd-shift-p");
532        cx.simulate_input("bcksp");
533
534        let palette = workspace.update(cx, |workspace, cx| {
535            workspace
536                .active_modal::<CommandPalette>(cx)
537                .unwrap()
538                .read(cx)
539                .picker
540                .clone()
541        });
542        palette.update(cx, |palette, _| {
543            assert!(palette.delegate.matches.is_empty())
544        });
545    }
546
547    #[gpui::test]
548    async fn test_go_to_line(cx: &mut TestAppContext) {
549        let app_state = init_test(cx);
550        let project = Project::test(app_state.fs.clone(), [], cx).await;
551        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
552
553        cx.simulate_keystrokes("cmd-n");
554
555        let editor = workspace.update(cx, |workspace, cx| {
556            workspace.active_item_as::<Editor>(cx).unwrap()
557        });
558        editor.update(cx, |editor, cx| editor.set_text("1\n2\n3\n4\n5\n6\n", cx));
559
560        cx.simulate_keystrokes("cmd-shift-p");
561        cx.simulate_input("go to line: Toggle");
562        cx.simulate_keystrokes("enter");
563
564        workspace.update(cx, |workspace, cx| {
565            assert!(workspace.active_modal::<GoToLine>(cx).is_some())
566        });
567
568        cx.simulate_keystrokes("3 enter");
569
570        editor.update(cx, |editor, cx| {
571            assert!(editor.focus_handle(cx).is_focused(cx));
572            assert_eq!(
573                editor.selections.last::<Point>(cx).range().start,
574                Point::new(2, 0)
575            );
576        });
577    }
578
579    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
580        cx.update(|cx| {
581            let app_state = AppState::test(cx);
582            theme::init(theme::LoadThemes::JustBase, cx);
583            language::init(cx);
584            editor::init(cx);
585            menu::init();
586            go_to_line::init(cx);
587            workspace::init(app_state.clone(), cx);
588            init(cx);
589            Project::init_settings(cx);
590            KeymapFile::parse(
591                r#"[
592                    {
593                        "bindings": {
594                            "cmd-n": "workspace::NewFile",
595                            "enter": "menu::Confirm",
596                            "cmd-shift-p": "command_palette::Toggle"
597                        }
598                    }
599                ]"#,
600            )
601            .unwrap()
602            .add_to_cx(cx)
603            .unwrap();
604            app_state
605        })
606    }
607}