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