use collections::{CommandPaletteFilter, HashMap}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, div, Action, AppContext, Component, Div, EventEmitter, FocusHandle, Keystroke, ParentElement, Render, StatelessInteractive, Styled, View, ViewContext, VisualContext, WeakView, WindowContext, }; use picker::{Picker, PickerDelegate}; use std::{ cmp::{self, Reverse}, sync::Arc, }; use theme::ActiveTheme; use ui::{h_stack, v_stack, HighlightedLabel, KeyBinding, StyledExt}; use util::{ channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL}, ResultExt, }; use workspace::{Modal, ModalEvent, Workspace}; use zed_actions::OpenZedURL; actions!(Toggle); pub fn init(cx: &mut AppContext) { cx.set_global(HitCounts::default()); cx.observe_new_views(CommandPalette::register).detach(); } pub struct CommandPalette { picker: View>, } impl CommandPalette { fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|workspace, _: &Toggle, cx| { let Some(previous_focus_handle) = cx.focused() else { return; }; workspace.toggle_modal(cx, move |cx| CommandPalette::new(previous_focus_handle, cx)); }); } fn new(previous_focus_handle: FocusHandle, cx: &mut ViewContext) -> Self { let filter = cx.try_global::(); let commands = cx .available_actions() .into_iter() .filter_map(|action| { let name = action.name(); let namespace = name.split("::").next().unwrap_or("malformed action name"); if filter.is_some_and(|f| f.filtered_namespaces.contains(namespace)) { return None; } Some(Command { name: humanize_action_name(&name), action, keystrokes: vec![], // todo!() }) }) .collect(); let delegate = CommandPaletteDelegate::new(cx.view().downgrade(), commands, previous_focus_handle); let picker = cx.build_view(|cx| Picker::new(delegate, cx)); Self { picker } } } impl EventEmitter for CommandPalette {} impl Modal for CommandPalette { fn focus(&self, cx: &mut WindowContext) { self.picker.update(cx, |picker, cx| picker.focus(cx)); } } impl Render for CommandPalette { type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { v_stack().w_96().child(self.picker.clone()) } } pub type CommandPaletteInterceptor = Box Option>; pub struct CommandInterceptResult { pub action: Box, pub string: String, pub positions: Vec, } pub struct CommandPaletteDelegate { command_palette: WeakView, commands: Vec, matches: Vec, selected_ix: usize, previous_focus_handle: FocusHandle, } struct Command { name: String, action: Box, keystrokes: Vec, } impl Clone for Command { fn clone(&self) -> Self { Self { name: self.name.clone(), action: self.action.boxed_clone(), keystrokes: self.keystrokes.clone(), } } } /// Hit count for each command in the palette. /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because /// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it. #[derive(Default)] struct HitCounts(HashMap); impl CommandPaletteDelegate { fn new( command_palette: WeakView, commands: Vec, previous_focus_handle: FocusHandle, ) -> Self { Self { command_palette, matches: vec![], commands, selected_ix: 0, previous_focus_handle, } } } impl PickerDelegate for CommandPaletteDelegate { type ListItem = Div>; fn placeholder_text(&self) -> Arc { "Execute a command...".into() } fn match_count(&self) -> usize { self.matches.len() } fn selected_index(&self) -> usize { self.selected_ix } fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { self.selected_ix = ix; } fn update_matches( &mut self, query: String, cx: &mut ViewContext>, ) -> gpui::Task<()> { let mut commands = self.commands.clone(); cx.spawn(move |picker, mut cx| async move { cx.read_global::(|hit_counts, _| { commands.sort_by_key(|action| { ( Reverse(hit_counts.0.get(&action.name).cloned()), action.name.clone(), ) }); }) .ok(); let candidates = commands .iter() .enumerate() .map(|(ix, command)| StringMatchCandidate { id: ix, string: command.name.to_string(), char_bag: command.name.chars().collect(), }) .collect::>(); let mut matches = if query.is_empty() { candidates .into_iter() .enumerate() .map(|(index, candidate)| StringMatch { candidate_id: index, string: candidate.string, positions: Vec::new(), score: 0.0, }) .collect() } else { fuzzy::match_strings( &candidates, &query, true, 10000, &Default::default(), cx.background_executor().clone(), ) .await }; let mut intercept_result = cx .try_read_global(|interceptor: &CommandPaletteInterceptor, cx| { (interceptor)(&query, cx) }) .flatten(); if *RELEASE_CHANNEL == ReleaseChannel::Dev { if parse_zed_link(&query).is_some() { intercept_result = Some(CommandInterceptResult { action: OpenZedURL { url: query.clone() }.boxed_clone(), string: query.clone(), positions: vec![], }) } } if let Some(CommandInterceptResult { action, string, positions, }) = intercept_result { if let Some(idx) = matches .iter() .position(|m| commands[m.candidate_id].action.type_id() == action.type_id()) { matches.remove(idx); } commands.push(Command { name: string.clone(), action, keystrokes: vec![], }); matches.insert( 0, StringMatch { candidate_id: commands.len() - 1, string, positions, score: 0.0, }, ) } picker .update(&mut cx, |picker, _| { let delegate = &mut picker.delegate; delegate.commands = commands; delegate.matches = matches; if delegate.matches.is_empty() { delegate.selected_ix = 0; } else { delegate.selected_ix = cmp::min(delegate.selected_ix, delegate.matches.len() - 1); } }) .log_err(); }) } fn dismissed(&mut self, cx: &mut ViewContext>) { self.command_palette .update(cx, |_, cx| cx.emit(ModalEvent::Dismissed)) .log_err(); } fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if self.matches.is_empty() { self.dismissed(cx); return; } let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); cx.update_global(|hit_counts: &mut HitCounts, _| { *hit_counts.0.entry(command.name).or_default() += 1; }); let action = command.action; cx.focus(&self.previous_focus_handle); cx.dispatch_action(action); self.dismissed(cx); } fn render_match( &self, ix: usize, selected: bool, cx: &mut ViewContext>, ) -> Self::ListItem { let colors = cx.theme().colors(); let Some(r#match) = self.matches.get(ix) else { return div(); }; let Some(command) = self.commands.get(r#match.candidate_id) else { return div(); }; div() .px_1() .text_color(colors.text) .text_ui() .bg(colors.ghost_element_background) .rounded_md() .when(selected, |this| this.bg(colors.ghost_element_selected)) .hover(|this| this.bg(colors.ghost_element_hover)) .child( h_stack() .justify_between() .child(HighlightedLabel::new( command.name.clone(), r#match.positions.clone(), )) .children(KeyBinding::for_action(&*command.action, cx)), ) } } fn humanize_action_name(name: &str) -> String { let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count(); let mut result = String::with_capacity(capacity); for char in name.chars() { if char == ':' { if result.ends_with(':') { result.push(' '); } else { result.push(':'); } } else if char == '_' { result.push(' '); } else if char.is_uppercase() { if !result.ends_with(' ') { result.push(' '); } result.extend(char.to_lowercase()); } else { result.push(char); } } result } impl std::fmt::Debug for Command { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Command") .field("name", &self.name) .field("keystrokes", &self.keystrokes) .finish() } } // #[cfg(test)] // mod tests { // use std::sync::Arc; // use super::*; // use editor::Editor; // use gpui::{executor::Deterministic, TestAppContext}; // use project::Project; // use workspace::{AppState, Workspace}; // #[test] // fn test_humanize_action_name() { // assert_eq!( // humanize_action_name("editor::GoToDefinition"), // "editor: go to definition" // ); // assert_eq!( // humanize_action_name("editor::Backspace"), // "editor: backspace" // ); // assert_eq!( // humanize_action_name("go_to_line::Deploy"), // "go to line: deploy" // ); // } // #[gpui::test] // async fn test_command_palette(deterministic: Arc, cx: &mut TestAppContext) { // let app_state = init_test(cx); // let project = Project::test(app_state.fs.clone(), [], cx).await; // let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); // let workspace = window.root(cx); // let editor = window.add_view(cx, |cx| { // let mut editor = Editor::single_line(None, cx); // editor.set_text("abc", cx); // editor // }); // workspace.update(cx, |workspace, cx| { // cx.focus(&editor); // workspace.add_item(Box::new(editor.clone()), cx) // }); // workspace.update(cx, |workspace, cx| { // toggle_command_palette(workspace, &Toggle, cx); // }); // let palette = workspace.read_with(cx, |workspace, _| { // workspace.modal::().unwrap() // }); // palette // .update(cx, |palette, cx| { // // Fill up palette's command list by running an empty query; // // we only need it to subsequently assert that the palette is initially // // sorted by command's name. // palette.delegate_mut().update_matches("".to_string(), cx) // }) // .await; // palette.update(cx, |palette, _| { // let is_sorted = // |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name); // assert!(is_sorted(&palette.delegate().actions)); // }); // palette // .update(cx, |palette, cx| { // palette // .delegate_mut() // .update_matches("bcksp".to_string(), cx) // }) // .await; // palette.update(cx, |palette, cx| { // assert_eq!(palette.delegate().matches[0].string, "editor: backspace"); // palette.confirm(&Default::default(), cx); // }); // deterministic.run_until_parked(); // editor.read_with(cx, |editor, cx| { // assert_eq!(editor.text(cx), "ab"); // }); // // Add namespace filter, and redeploy the palette // cx.update(|cx| { // cx.update_default_global::(|filter, _| { // filter.filtered_namespaces.insert("editor"); // }) // }); // workspace.update(cx, |workspace, cx| { // toggle_command_palette(workspace, &Toggle, cx); // }); // // Assert editor command not present // let palette = workspace.read_with(cx, |workspace, _| { // workspace.modal::().unwrap() // }); // palette // .update(cx, |palette, cx| { // palette // .delegate_mut() // .update_matches("bcksp".to_string(), cx) // }) // .await; // palette.update(cx, |palette, _| { // assert!(palette.delegate().matches.is_empty()) // }); // } // fn init_test(cx: &mut TestAppContext) -> Arc { // cx.update(|cx| { // let app_state = AppState::test(cx); // theme::init(cx); // language::init(cx); // editor::init(cx); // workspace::init(app_state.clone(), cx); // init(cx); // Project::init_settings(cx); // app_state // }) // } // }