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