command_palette.rs

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