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