command_palette.rs

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