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::{v_stack, HighlightedLabel, 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(HighlightedLabel::new(
322 command.name.clone(),
323 r#match.positions.clone(),
324 ))
325 }
326
327 // fn render_match(
328 // &self,
329 // ix: usize,
330 // mouse_state: &mut MouseState,
331 // selected: bool,
332 // cx: &gpui::AppContext,
333 // ) -> AnyElement<Picker<Self>> {
334 // let mat = &self.matches[ix];
335 // let command = &self.actions[mat.candidate_id];
336 // let theme = theme::current(cx);
337 // let style = theme.picker.item.in_state(selected).style_for(mouse_state);
338 // let key_style = &theme.command_palette.key.in_state(selected);
339 // let keystroke_spacing = theme.command_palette.keystroke_spacing;
340
341 // Flex::row()
342 // .with_child(
343 // Label::new(mat.string.clone(), style.label.clone())
344 // .with_highlights(mat.positions.clone()),
345 // )
346 // .with_children(command.keystrokes.iter().map(|keystroke| {
347 // Flex::row()
348 // .with_children(
349 // [
350 // (keystroke.ctrl, "^"),
351 // (keystroke.alt, "⌥"),
352 // (keystroke.cmd, "⌘"),
353 // (keystroke.shift, "⇧"),
354 // ]
355 // .into_iter()
356 // .filter_map(|(modifier, label)| {
357 // if modifier {
358 // Some(
359 // Label::new(label, key_style.label.clone())
360 // .contained()
361 // .with_style(key_style.container),
362 // )
363 // } else {
364 // None
365 // }
366 // }),
367 // )
368 // .with_child(
369 // Label::new(keystroke.key.clone(), key_style.label.clone())
370 // .contained()
371 // .with_style(key_style.container),
372 // )
373 // .contained()
374 // .with_margin_left(keystroke_spacing)
375 // .flex_float()
376 // }))
377 // .contained()
378 // .with_style(style.container)
379 // .into_any()
380 // }
381}
382
383fn humanize_action_name(name: &str) -> String {
384 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
385 let mut result = String::with_capacity(capacity);
386 for char in name.chars() {
387 if char == ':' {
388 if result.ends_with(':') {
389 result.push(' ');
390 } else {
391 result.push(':');
392 }
393 } else if char == '_' {
394 result.push(' ');
395 } else if char.is_uppercase() {
396 if !result.ends_with(' ') {
397 result.push(' ');
398 }
399 result.extend(char.to_lowercase());
400 } else {
401 result.push(char);
402 }
403 }
404 result
405}
406
407impl std::fmt::Debug for Command {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 f.debug_struct("Command")
410 .field("name", &self.name)
411 .field("keystrokes", &self.keystrokes)
412 .finish()
413 }
414}
415
416// #[cfg(test)]
417// mod tests {
418// use std::sync::Arc;
419
420// use super::*;
421// use editor::Editor;
422// use gpui::{executor::Deterministic, TestAppContext};
423// use project::Project;
424// use workspace::{AppState, Workspace};
425
426// #[test]
427// fn test_humanize_action_name() {
428// assert_eq!(
429// humanize_action_name("editor::GoToDefinition"),
430// "editor: go to definition"
431// );
432// assert_eq!(
433// humanize_action_name("editor::Backspace"),
434// "editor: backspace"
435// );
436// assert_eq!(
437// humanize_action_name("go_to_line::Deploy"),
438// "go to line: deploy"
439// );
440// }
441
442// #[gpui::test]
443// async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
444// let app_state = init_test(cx);
445
446// let project = Project::test(app_state.fs.clone(), [], cx).await;
447// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
448// let workspace = window.root(cx);
449// let editor = window.add_view(cx, |cx| {
450// let mut editor = Editor::single_line(None, cx);
451// editor.set_text("abc", cx);
452// editor
453// });
454
455// workspace.update(cx, |workspace, cx| {
456// cx.focus(&editor);
457// workspace.add_item(Box::new(editor.clone()), cx)
458// });
459
460// workspace.update(cx, |workspace, cx| {
461// toggle_command_palette(workspace, &Toggle, cx);
462// });
463
464// let palette = workspace.read_with(cx, |workspace, _| {
465// workspace.modal::<CommandPalette>().unwrap()
466// });
467
468// palette
469// .update(cx, |palette, cx| {
470// // Fill up palette's command list by running an empty query;
471// // we only need it to subsequently assert that the palette is initially
472// // sorted by command's name.
473// palette.delegate_mut().update_matches("".to_string(), cx)
474// })
475// .await;
476
477// palette.update(cx, |palette, _| {
478// let is_sorted =
479// |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
480// assert!(is_sorted(&palette.delegate().actions));
481// });
482
483// palette
484// .update(cx, |palette, cx| {
485// palette
486// .delegate_mut()
487// .update_matches("bcksp".to_string(), cx)
488// })
489// .await;
490
491// palette.update(cx, |palette, cx| {
492// assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
493// palette.confirm(&Default::default(), cx);
494// });
495// deterministic.run_until_parked();
496// editor.read_with(cx, |editor, cx| {
497// assert_eq!(editor.text(cx), "ab");
498// });
499
500// // Add namespace filter, and redeploy the palette
501// cx.update(|cx| {
502// cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
503// filter.filtered_namespaces.insert("editor");
504// })
505// });
506
507// workspace.update(cx, |workspace, cx| {
508// toggle_command_palette(workspace, &Toggle, cx);
509// });
510
511// // Assert editor command not present
512// let palette = workspace.read_with(cx, |workspace, _| {
513// workspace.modal::<CommandPalette>().unwrap()
514// });
515
516// palette
517// .update(cx, |palette, cx| {
518// palette
519// .delegate_mut()
520// .update_matches("bcksp".to_string(), cx)
521// })
522// .await;
523
524// palette.update(cx, |palette, _| {
525// assert!(palette.delegate().matches.is_empty())
526// });
527// }
528
529// fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
530// cx.update(|cx| {
531// let app_state = AppState::test(cx);
532// theme::init(cx);
533// language::init(cx);
534// editor::init(cx);
535// workspace::init(app_state.clone(), cx);
536// init(cx);
537// Project::init_settings(cx);
538// app_state
539// })
540// }
541// }