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