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