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