command_palette.rs

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