command_palette.rs

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