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