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, prelude::*};
 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, _: &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    fn selected_command(&self) -> Option<&Command> {
266        let action_ix = self
267            .matches
268            .get(self.selected_ix)
269            .map(|m| m.candidate_id)
270            .unwrap_or(self.selected_ix);
271        // this gets called in headless tests where there are no commands loaded
272        // so we need to return an Option here
273        self.commands.get(action_ix)
274    }
275}
276
277impl PickerDelegate for CommandPaletteDelegate {
278    type ListItem = ListItem;
279
280    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
281        "Execute a command...".into()
282    }
283
284    fn match_count(&self) -> usize {
285        self.matches.len()
286    }
287
288    fn selected_index(&self) -> usize {
289        self.selected_ix
290    }
291
292    fn set_selected_index(
293        &mut self,
294        ix: usize,
295        _window: &mut Window,
296        _: &mut Context<Picker<Self>>,
297    ) {
298        self.selected_ix = ix;
299    }
300
301    fn update_matches(
302        &mut self,
303        mut query: String,
304        window: &mut Window,
305        cx: &mut Context<Picker<Self>>,
306    ) -> gpui::Task<()> {
307        let settings = WorkspaceSettings::get_global(cx);
308        if let Some(alias) = settings.command_aliases.get(&query) {
309            query = alias.to_string();
310        }
311
312        let workspace = self.workspace.clone();
313
314        let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
315
316        let (mut tx, mut rx) = postage::dispatch::channel(1);
317
318        let query_str = query.as_str();
319        let is_zed_link = parse_zed_link(query_str, cx).is_some();
320
321        let task = cx.background_spawn({
322            let mut commands = self.all_commands.clone();
323            let hit_counts = self.hit_counts();
324            let executor = cx.background_executor().clone();
325            let query = normalize_action_query(query_str);
326            let query_for_link = query_str.to_string();
327            async move {
328                commands.sort_by_key(|action| {
329                    (
330                        Reverse(hit_counts.get(&action.name).cloned()),
331                        action.name.clone(),
332                    )
333                });
334
335                let candidates = commands
336                    .iter()
337                    .enumerate()
338                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
339                    .collect::<Vec<_>>();
340
341                let matches = fuzzy::match_strings(
342                    &candidates,
343                    &query,
344                    true,
345                    true,
346                    10000,
347                    &Default::default(),
348                    executor,
349                )
350                .await;
351
352                let intercept_result = if is_zed_link {
353                    CommandInterceptResult {
354                        results: vec![CommandInterceptItem {
355                            action: OpenZedUrl {
356                                url: query_for_link.clone(),
357                            }
358                            .boxed_clone(),
359                            string: query_for_link,
360                            positions: vec![],
361                        }],
362                        exclusive: false,
363                    }
364                } else if let Some(task) = intercept_task {
365                    task.await
366                } else {
367                    CommandInterceptResult::default()
368                };
369
370                tx.send((commands, matches, intercept_result))
371                    .await
372                    .log_err();
373            }
374        });
375
376        self.updating_matches = Some((task, rx.clone()));
377
378        cx.spawn_in(window, async move |picker, cx| {
379            let Some((commands, matches, intercept_result)) = rx.recv().await else {
380                return;
381            };
382
383            picker
384                .update(cx, |picker, cx| {
385                    picker
386                        .delegate
387                        .matches_updated(query, commands, matches, intercept_result, cx)
388                })
389                .log_err();
390        })
391    }
392
393    fn finalize_update_matches(
394        &mut self,
395        query: String,
396        duration: Duration,
397        _: &mut Window,
398        cx: &mut Context<Picker<Self>>,
399    ) -> bool {
400        let Some((task, rx)) = self.updating_matches.take() else {
401            return true;
402        };
403
404        match cx
405            .background_executor()
406            .block_with_timeout(duration, rx.clone().recv())
407        {
408            Ok(Some((commands, matches, interceptor_result))) => {
409                self.matches_updated(query, commands, matches, interceptor_result, cx);
410                true
411            }
412            _ => {
413                self.updating_matches = Some((task, rx));
414                false
415            }
416        }
417    }
418
419    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
420        self.command_palette
421            .update(cx, |_, cx| cx.emit(DismissEvent))
422            .log_err();
423    }
424
425    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
426        if secondary {
427            let Some(selected_command) = self.selected_command() else {
428                return;
429            };
430            let action_name = selected_command.action.name();
431            let open_keymap = Box::new(zed_actions::ChangeKeybinding {
432                action: action_name.to_string(),
433            });
434            window.dispatch_action(open_keymap, cx);
435            self.dismissed(window, cx);
436            return;
437        }
438
439        if self.matches.is_empty() {
440            self.dismissed(window, cx);
441            return;
442        }
443        let action_ix = self.matches[self.selected_ix].candidate_id;
444        let command = self.commands.swap_remove(action_ix);
445        telemetry::event!(
446            "Action Invoked",
447            source = "command palette",
448            action = command.name
449        );
450        self.matches.clear();
451        self.commands.clear();
452        let command_name = command.name.clone();
453        let latest_query = self.latest_query.clone();
454        cx.background_spawn(async move {
455            COMMAND_PALETTE_HISTORY
456                .write_command_invocation(command_name, latest_query)
457                .await
458        })
459        .detach_and_log_err(cx);
460        let action = command.action;
461        window.focus(&self.previous_focus_handle);
462        self.dismissed(window, cx);
463        window.dispatch_action(action, cx);
464    }
465
466    fn render_match(
467        &self,
468        ix: usize,
469        selected: bool,
470        _: &mut Window,
471        cx: &mut Context<Picker<Self>>,
472    ) -> Option<Self::ListItem> {
473        let matching_command = self.matches.get(ix)?;
474        let command = self.commands.get(matching_command.candidate_id)?;
475
476        Some(
477            ListItem::new(ix)
478                .inset(true)
479                .spacing(ListItemSpacing::Sparse)
480                .toggle_state(selected)
481                .child(
482                    h_flex()
483                        .w_full()
484                        .py_px()
485                        .justify_between()
486                        .child(HighlightedLabel::new(
487                            command.name.clone(),
488                            matching_command.positions.clone(),
489                        ))
490                        .child(KeyBinding::for_action_in(
491                            &*command.action,
492                            &self.previous_focus_handle,
493                            cx,
494                        )),
495                ),
496        )
497    }
498
499    fn render_footer(
500        &self,
501        window: &mut Window,
502        cx: &mut Context<Picker<Self>>,
503    ) -> Option<AnyElement> {
504        let selected_command = self.selected_command()?;
505        let keybind =
506            KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
507
508        let focus_handle = &self.previous_focus_handle;
509        let keybinding_buttons = if keybind.has_binding(window) {
510            Button::new("change", "Change Keybinding…")
511                .key_binding(
512                    KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
513                        .map(|kb| kb.size(rems_from_px(12.))),
514                )
515                .on_click(move |_, window, cx| {
516                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
517                })
518        } else {
519            Button::new("add", "Add Keybinding…")
520                .key_binding(
521                    KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
522                        .map(|kb| kb.size(rems_from_px(12.))),
523                )
524                .on_click(move |_, window, cx| {
525                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
526                })
527        };
528
529        Some(
530            h_flex()
531                .w_full()
532                .p_1p5()
533                .gap_1()
534                .justify_end()
535                .border_t_1()
536                .border_color(cx.theme().colors().border_variant)
537                .child(keybinding_buttons)
538                .child(
539                    Button::new("run-action", "Run")
540                        .key_binding(
541                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
542                                .map(|kb| kb.size(rems_from_px(12.))),
543                        )
544                        .on_click(|_, window, cx| {
545                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
546                        }),
547                )
548                .into_any(),
549        )
550    }
551}
552
553pub fn humanize_action_name(name: &str) -> String {
554    let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
555    let mut result = String::with_capacity(capacity);
556    for char in name.chars() {
557        if char == ':' {
558            if result.ends_with(':') {
559                result.push(' ');
560            } else {
561                result.push(':');
562            }
563        } else if char == '_' {
564            result.push(' ');
565        } else if char.is_uppercase() {
566            if !result.ends_with(' ') {
567                result.push(' ');
568            }
569            result.extend(char.to_lowercase());
570        } else {
571            result.push(char);
572        }
573    }
574    result
575}
576
577impl std::fmt::Debug for Command {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        f.debug_struct("Command")
580            .field("name", &self.name)
581            .finish_non_exhaustive()
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use std::sync::Arc;
588
589    use super::*;
590    use editor::Editor;
591    use go_to_line::GoToLine;
592    use gpui::TestAppContext;
593    use language::Point;
594    use project::Project;
595    use settings::KeymapFile;
596    use workspace::{AppState, Workspace};
597
598    #[test]
599    fn test_humanize_action_name() {
600        assert_eq!(
601            humanize_action_name("editor::GoToDefinition"),
602            "editor: go to definition"
603        );
604        assert_eq!(
605            humanize_action_name("editor::Backspace"),
606            "editor: backspace"
607        );
608        assert_eq!(
609            humanize_action_name("go_to_line::Deploy"),
610            "go to line: deploy"
611        );
612    }
613
614    #[test]
615    fn test_normalize_query() {
616        assert_eq!(
617            normalize_action_query("editor: backspace"),
618            "editor: backspace"
619        );
620        assert_eq!(
621            normalize_action_query("editor:  backspace"),
622            "editor: backspace"
623        );
624        assert_eq!(
625            normalize_action_query("editor:    backspace"),
626            "editor: backspace"
627        );
628        assert_eq!(
629            normalize_action_query("editor::GoToDefinition"),
630            "editor:GoToDefinition"
631        );
632        assert_eq!(
633            normalize_action_query("editor::::GoToDefinition"),
634            "editor:GoToDefinition"
635        );
636        assert_eq!(
637            normalize_action_query("editor: :GoToDefinition"),
638            "editor: :GoToDefinition"
639        );
640    }
641
642    #[gpui::test]
643    async fn test_command_palette(cx: &mut TestAppContext) {
644        let app_state = init_test(cx);
645        let project = Project::test(app_state.fs.clone(), [], cx).await;
646        let (workspace, cx) =
647            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
648
649        let editor = cx.new_window_entity(|window, cx| {
650            let mut editor = Editor::single_line(window, cx);
651            editor.set_text("abc", window, cx);
652            editor
653        });
654
655        workspace.update_in(cx, |workspace, window, cx| {
656            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
657            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
658        });
659
660        cx.simulate_keystrokes("cmd-shift-p");
661
662        let palette = workspace.update(cx, |workspace, cx| {
663            workspace
664                .active_modal::<CommandPalette>(cx)
665                .unwrap()
666                .read(cx)
667                .picker
668                .clone()
669        });
670
671        palette.read_with(cx, |palette, _| {
672            assert!(palette.delegate.commands.len() > 5);
673            let is_sorted =
674                |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
675            assert!(is_sorted(&palette.delegate.commands));
676        });
677
678        cx.simulate_input("bcksp");
679
680        palette.read_with(cx, |palette, _| {
681            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
682        });
683
684        cx.simulate_keystrokes("enter");
685
686        workspace.update(cx, |workspace, cx| {
687            assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
688            assert_eq!(editor.read(cx).text(cx), "ab")
689        });
690
691        // Add namespace filter, and redeploy the palette
692        cx.update(|_window, cx| {
693            CommandPaletteFilter::update_global(cx, |filter, _| {
694                filter.hide_namespace("editor");
695            });
696        });
697
698        cx.simulate_keystrokes("cmd-shift-p");
699        cx.simulate_input("bcksp");
700
701        let palette = workspace.update(cx, |workspace, cx| {
702            workspace
703                .active_modal::<CommandPalette>(cx)
704                .unwrap()
705                .read(cx)
706                .picker
707                .clone()
708        });
709        palette.read_with(cx, |palette, _| {
710            assert!(palette.delegate.matches.is_empty())
711        });
712    }
713    #[gpui::test]
714    async fn test_normalized_matches(cx: &mut TestAppContext) {
715        let app_state = init_test(cx);
716        let project = Project::test(app_state.fs.clone(), [], cx).await;
717        let (workspace, cx) =
718            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
719
720        let editor = cx.new_window_entity(|window, cx| {
721            let mut editor = Editor::single_line(window, cx);
722            editor.set_text("abc", window, cx);
723            editor
724        });
725
726        workspace.update_in(cx, |workspace, window, cx| {
727            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
728            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
729        });
730
731        // Test normalize (trimming whitespace and double colons)
732        cx.simulate_keystrokes("cmd-shift-p");
733
734        let palette = workspace.update(cx, |workspace, cx| {
735            workspace
736                .active_modal::<CommandPalette>(cx)
737                .unwrap()
738                .read(cx)
739                .picker
740                .clone()
741        });
742
743        cx.simulate_input("Editor::    Backspace");
744        palette.read_with(cx, |palette, _| {
745            assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
746        });
747    }
748
749    #[gpui::test]
750    async fn test_go_to_line(cx: &mut TestAppContext) {
751        let app_state = init_test(cx);
752        let project = Project::test(app_state.fs.clone(), [], cx).await;
753        let (workspace, cx) =
754            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
755
756        cx.simulate_keystrokes("cmd-n");
757
758        let editor = workspace.update(cx, |workspace, cx| {
759            workspace.active_item_as::<Editor>(cx).unwrap()
760        });
761        editor.update_in(cx, |editor, window, cx| {
762            editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
763        });
764
765        cx.simulate_keystrokes("cmd-shift-p");
766        cx.simulate_input("go to line: Toggle");
767        cx.simulate_keystrokes("enter");
768
769        workspace.update(cx, |workspace, cx| {
770            assert!(workspace.active_modal::<GoToLine>(cx).is_some())
771        });
772
773        cx.simulate_keystrokes("3 enter");
774
775        editor.update_in(cx, |editor, window, cx| {
776            assert!(editor.focus_handle(cx).is_focused(window));
777            assert_eq!(
778                editor
779                    .selections
780                    .last::<Point>(&editor.display_snapshot(cx))
781                    .range()
782                    .start,
783                Point::new(2, 0)
784            );
785        });
786    }
787
788    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
789        cx.update(|cx| {
790            let app_state = AppState::test(cx);
791            theme::init(theme::LoadThemes::JustBase, cx);
792            language::init(cx);
793            editor::init(cx);
794            menu::init();
795            go_to_line::init(cx);
796            workspace::init(app_state.clone(), cx);
797            init(cx);
798            Project::init_settings(cx);
799            cx.bind_keys(KeymapFile::load_panic_on_failure(
800                r#"[
801                    {
802                        "bindings": {
803                            "cmd-n": "workspace::NewFile",
804                            "enter": "menu::Confirm",
805                            "cmd-shift-p": "command_palette::Toggle"
806                        }
807                    }
808                ]"#,
809                cx,
810            ));
811            app_state
812        })
813    }
814}