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