command_palette.rs

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