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