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