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