1use collections::{CommandPaletteFilter, HashMap};
2use fuzzy::{StringMatch, StringMatchCandidate};
3use gpui::{
4 actions, div, Action, AppContext, Component, Div, EventEmitter, FocusHandle, Keystroke,
5 ParentElement, Render, StatelessInteractive, Styled, View, ViewContext, VisualContext,
6 WeakView, WindowContext,
7};
8use picker::{Picker, PickerDelegate};
9use std::{
10 cmp::{self, Reverse},
11 sync::Arc,
12};
13use theme::ActiveTheme;
14use ui::{h_stack, v_stack, HighlightedLabel, KeyBinding, StyledExt};
15use util::{
16 channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
17 ResultExt,
18};
19use workspace::{Modal, ModalEvent, Workspace};
20use zed_actions::OpenZedURL;
21
22actions!(Toggle);
23
24pub fn init(cx: &mut AppContext) {
25 cx.set_global(HitCounts::default());
26 cx.observe_new_views(CommandPalette::register).detach();
27}
28
29pub struct CommandPalette {
30 picker: View<Picker<CommandPaletteDelegate>>,
31}
32
33impl CommandPalette {
34 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
35 workspace.register_action(|workspace, _: &Toggle, cx| {
36 let Some(previous_focus_handle) = cx.focused() else {
37 return;
38 };
39 workspace.toggle_modal(cx, move |cx| CommandPalette::new(previous_focus_handle, cx));
40 });
41 }
42
43 fn new(previous_focus_handle: FocusHandle, cx: &mut ViewContext<Self>) -> Self {
44 let filter = cx.try_global::<CommandPaletteFilter>();
45
46 let commands = cx
47 .available_actions()
48 .into_iter()
49 .filter_map(|action| {
50 let name = action.name();
51 let namespace = name.split("::").next().unwrap_or("malformed action name");
52 if filter.is_some_and(|f| f.filtered_namespaces.contains(namespace)) {
53 return None;
54 }
55
56 Some(Command {
57 name: humanize_action_name(&name),
58 action,
59 keystrokes: vec![], // todo!()
60 })
61 })
62 .collect();
63
64 let delegate =
65 CommandPaletteDelegate::new(cx.view().downgrade(), commands, previous_focus_handle);
66
67 let picker = cx.build_view(|cx| Picker::new(delegate, cx));
68 Self { picker }
69 }
70}
71
72impl EventEmitter<ModalEvent> for CommandPalette {}
73impl Modal for CommandPalette {
74 fn focus(&self, cx: &mut WindowContext) {
75 self.picker.update(cx, |picker, cx| picker.focus(cx));
76 }
77}
78
79impl Render for CommandPalette {
80 type Element = Div<Self>;
81
82 fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
83 v_stack().w_96().child(self.picker.clone())
84 }
85}
86
87pub type CommandPaletteInterceptor =
88 Box<dyn Fn(&str, &AppContext) -> Option<CommandInterceptResult>>;
89
90pub struct CommandInterceptResult {
91 pub action: Box<dyn Action>,
92 pub string: String,
93 pub positions: Vec<usize>,
94}
95
96pub struct CommandPaletteDelegate {
97 command_palette: WeakView<CommandPalette>,
98 commands: Vec<Command>,
99 matches: Vec<StringMatch>,
100 selected_ix: usize,
101 previous_focus_handle: FocusHandle,
102}
103
104struct Command {
105 name: String,
106 action: Box<dyn Action>,
107 keystrokes: Vec<Keystroke>,
108}
109
110impl Clone for Command {
111 fn clone(&self) -> Self {
112 Self {
113 name: self.name.clone(),
114 action: self.action.boxed_clone(),
115 keystrokes: self.keystrokes.clone(),
116 }
117 }
118}
119/// Hit count for each command in the palette.
120/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
121/// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
122#[derive(Default)]
123struct HitCounts(HashMap<String, usize>);
124
125impl CommandPaletteDelegate {
126 fn new(
127 command_palette: WeakView<CommandPalette>,
128 commands: Vec<Command>,
129 previous_focus_handle: FocusHandle,
130 ) -> Self {
131 Self {
132 command_palette,
133 matches: commands
134 .iter()
135 .enumerate()
136 .map(|(i, command)| StringMatch {
137 candidate_id: i,
138 string: command.name.clone(),
139 positions: Vec::new(),
140 score: 0.0,
141 })
142 .collect(),
143 commands,
144 selected_ix: 0,
145 previous_focus_handle,
146 }
147 }
148}
149
150impl PickerDelegate for CommandPaletteDelegate {
151 type ListItem = Div<Picker<Self>>;
152
153 fn placeholder_text(&self) -> Arc<str> {
154 "Execute a command...".into()
155 }
156
157 fn match_count(&self) -> usize {
158 self.matches.len()
159 }
160
161 fn selected_index(&self) -> usize {
162 self.selected_ix
163 }
164
165 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
166 self.selected_ix = ix;
167 }
168
169 fn update_matches(
170 &mut self,
171 query: String,
172 cx: &mut ViewContext<Picker<Self>>,
173 ) -> gpui::Task<()> {
174 let mut commands = self.commands.clone();
175
176 cx.spawn(move |picker, mut cx| async move {
177 cx.read_global::<HitCounts, _>(|hit_counts, _| {
178 commands.sort_by_key(|action| {
179 (
180 Reverse(hit_counts.0.get(&action.name).cloned()),
181 action.name.clone(),
182 )
183 });
184 })
185 .ok();
186
187 let candidates = commands
188 .iter()
189 .enumerate()
190 .map(|(ix, command)| StringMatchCandidate {
191 id: ix,
192 string: command.name.to_string(),
193 char_bag: command.name.chars().collect(),
194 })
195 .collect::<Vec<_>>();
196 let mut matches = if query.is_empty() {
197 candidates
198 .into_iter()
199 .enumerate()
200 .map(|(index, candidate)| StringMatch {
201 candidate_id: index,
202 string: candidate.string,
203 positions: Vec::new(),
204 score: 0.0,
205 })
206 .collect()
207 } else {
208 fuzzy::match_strings(
209 &candidates,
210 &query,
211 true,
212 10000,
213 &Default::default(),
214 cx.background_executor().clone(),
215 )
216 .await
217 };
218
219 let mut intercept_result = cx
220 .try_read_global(|interceptor: &CommandPaletteInterceptor, cx| {
221 (interceptor)(&query, cx)
222 })
223 .flatten();
224
225 if *RELEASE_CHANNEL == ReleaseChannel::Dev {
226 if parse_zed_link(&query).is_some() {
227 intercept_result = Some(CommandInterceptResult {
228 action: OpenZedURL { url: query.clone() }.boxed_clone(),
229 string: query.clone(),
230 positions: vec![],
231 })
232 }
233 }
234 if let Some(CommandInterceptResult {
235 action,
236 string,
237 positions,
238 }) = intercept_result
239 {
240 if let Some(idx) = matches
241 .iter()
242 .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
243 {
244 matches.remove(idx);
245 }
246 commands.push(Command {
247 name: string.clone(),
248 action,
249 keystrokes: vec![],
250 });
251 matches.insert(
252 0,
253 StringMatch {
254 candidate_id: commands.len() - 1,
255 string,
256 positions,
257 score: 0.0,
258 },
259 )
260 }
261 picker
262 .update(&mut cx, |picker, _| {
263 let delegate = &mut picker.delegate;
264 delegate.commands = commands;
265 delegate.matches = matches;
266 if delegate.matches.is_empty() {
267 delegate.selected_ix = 0;
268 } else {
269 delegate.selected_ix =
270 cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
271 }
272 })
273 .log_err();
274 })
275 }
276
277 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
278 self.command_palette
279 .update(cx, |_, cx| cx.emit(ModalEvent::Dismissed))
280 .log_err();
281 }
282
283 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
284 if self.matches.is_empty() {
285 self.dismissed(cx);
286 return;
287 }
288 let action_ix = self.matches[self.selected_ix].candidate_id;
289 let command = self.commands.swap_remove(action_ix);
290 cx.update_global(|hit_counts: &mut HitCounts, _| {
291 *hit_counts.0.entry(command.name).or_default() += 1;
292 });
293 let action = command.action;
294 cx.focus(&self.previous_focus_handle);
295 cx.dispatch_action(action);
296 self.dismissed(cx);
297 }
298
299 fn render_match(
300 &self,
301 ix: usize,
302 selected: bool,
303 cx: &mut ViewContext<Picker<Self>>,
304 ) -> Self::ListItem {
305 let colors = cx.theme().colors();
306 let Some(r#match) = self.matches.get(ix) else {
307 return div();
308 };
309 let Some(command) = self.commands.get(r#match.candidate_id) else {
310 return div();
311 };
312
313 div()
314 .px_1()
315 .text_color(colors.text)
316 .text_ui()
317 .bg(colors.ghost_element_background)
318 .rounded_md()
319 .when(selected, |this| this.bg(colors.ghost_element_selected))
320 .hover(|this| this.bg(colors.ghost_element_hover))
321 .child(
322 h_stack()
323 .justify_between()
324 .child(HighlightedLabel::new(
325 command.name.clone(),
326 r#match.positions.clone(),
327 ))
328 .children(KeyBinding::for_action(&*command.action, cx)),
329 )
330 }
331}
332
333fn humanize_action_name(name: &str) -> String {
334 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
335 let mut result = String::with_capacity(capacity);
336 for char in name.chars() {
337 if char == ':' {
338 if result.ends_with(':') {
339 result.push(' ');
340 } else {
341 result.push(':');
342 }
343 } else if char == '_' {
344 result.push(' ');
345 } else if char.is_uppercase() {
346 if !result.ends_with(' ') {
347 result.push(' ');
348 }
349 result.extend(char.to_lowercase());
350 } else {
351 result.push(char);
352 }
353 }
354 result
355}
356
357impl std::fmt::Debug for Command {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("Command")
360 .field("name", &self.name)
361 .field("keystrokes", &self.keystrokes)
362 .finish()
363 }
364}
365
366// #[cfg(test)]
367// mod tests {
368// use std::sync::Arc;
369
370// use super::*;
371// use editor::Editor;
372// use gpui::{executor::Deterministic, TestAppContext};
373// use project::Project;
374// use workspace::{AppState, Workspace};
375
376// #[test]
377// fn test_humanize_action_name() {
378// assert_eq!(
379// humanize_action_name("editor::GoToDefinition"),
380// "editor: go to definition"
381// );
382// assert_eq!(
383// humanize_action_name("editor::Backspace"),
384// "editor: backspace"
385// );
386// assert_eq!(
387// humanize_action_name("go_to_line::Deploy"),
388// "go to line: deploy"
389// );
390// }
391
392// #[gpui::test]
393// async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
394// let app_state = init_test(cx);
395
396// let project = Project::test(app_state.fs.clone(), [], cx).await;
397// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
398// let workspace = window.root(cx);
399// let editor = window.add_view(cx, |cx| {
400// let mut editor = Editor::single_line(None, cx);
401// editor.set_text("abc", cx);
402// editor
403// });
404
405// workspace.update(cx, |workspace, cx| {
406// cx.focus(&editor);
407// workspace.add_item(Box::new(editor.clone()), cx)
408// });
409
410// workspace.update(cx, |workspace, cx| {
411// toggle_command_palette(workspace, &Toggle, cx);
412// });
413
414// let palette = workspace.read_with(cx, |workspace, _| {
415// workspace.modal::<CommandPalette>().unwrap()
416// });
417
418// palette
419// .update(cx, |palette, cx| {
420// // Fill up palette's command list by running an empty query;
421// // we only need it to subsequently assert that the palette is initially
422// // sorted by command's name.
423// palette.delegate_mut().update_matches("".to_string(), cx)
424// })
425// .await;
426
427// palette.update(cx, |palette, _| {
428// let is_sorted =
429// |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
430// assert!(is_sorted(&palette.delegate().actions));
431// });
432
433// palette
434// .update(cx, |palette, cx| {
435// palette
436// .delegate_mut()
437// .update_matches("bcksp".to_string(), cx)
438// })
439// .await;
440
441// palette.update(cx, |palette, cx| {
442// assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
443// palette.confirm(&Default::default(), cx);
444// });
445// deterministic.run_until_parked();
446// editor.read_with(cx, |editor, cx| {
447// assert_eq!(editor.text(cx), "ab");
448// });
449
450// // Add namespace filter, and redeploy the palette
451// cx.update(|cx| {
452// cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
453// filter.filtered_namespaces.insert("editor");
454// })
455// });
456
457// workspace.update(cx, |workspace, cx| {
458// toggle_command_palette(workspace, &Toggle, cx);
459// });
460
461// // Assert editor command not present
462// let palette = workspace.read_with(cx, |workspace, _| {
463// workspace.modal::<CommandPalette>().unwrap()
464// });
465
466// palette
467// .update(cx, |palette, cx| {
468// palette
469// .delegate_mut()
470// .update_matches("bcksp".to_string(), cx)
471// })
472// .await;
473
474// palette.update(cx, |palette, _| {
475// assert!(palette.delegate().matches.is_empty())
476// });
477// }
478
479// fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
480// cx.update(|cx| {
481// let app_state = AppState::test(cx);
482// theme::init(cx);
483// language::init(cx);
484// editor::init(cx);
485// workspace::init(app_state.clone(), cx);
486// init(cx);
487// Project::init_settings(cx);
488// app_state
489// })
490// }
491// }