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
190 .command_palette
191 .key
192 .in_state(selected)
193 .style_for(mouse_state);
194 let keystroke_spacing = theme.command_palette.keystroke_spacing;
195
196 Flex::row()
197 .with_child(
198 Label::new(mat.string.clone(), style.label.clone())
199 .with_highlights(mat.positions.clone()),
200 )
201 .with_children(command.keystrokes.iter().map(|keystroke| {
202 Flex::row()
203 .with_children(
204 [
205 (keystroke.ctrl, "^"),
206 (keystroke.alt, "⎇"),
207 (keystroke.cmd, "⌘"),
208 (keystroke.shift, "⇧"),
209 ]
210 .into_iter()
211 .filter_map(|(modifier, label)| {
212 if modifier {
213 Some(
214 Label::new(label, key_style.label.clone())
215 .contained()
216 .with_style(key_style.container),
217 )
218 } else {
219 None
220 }
221 }),
222 )
223 .with_child(
224 Label::new(keystroke.key.clone(), key_style.label.clone())
225 .contained()
226 .with_style(key_style.container),
227 )
228 .contained()
229 .with_margin_left(keystroke_spacing)
230 .flex_float()
231 }))
232 .contained()
233 .with_style(style.container)
234 .into_any()
235 }
236}
237
238fn humanize_action_name(name: &str) -> String {
239 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
240 let mut result = String::with_capacity(capacity);
241 for char in name.chars() {
242 if char == ':' {
243 if result.ends_with(':') {
244 result.push(' ');
245 } else {
246 result.push(':');
247 }
248 } else if char == '_' {
249 result.push(' ');
250 } else if char.is_uppercase() {
251 if !result.ends_with(' ') {
252 result.push(' ');
253 }
254 result.extend(char.to_lowercase());
255 } else {
256 result.push(char);
257 }
258 }
259 result
260}
261
262impl std::fmt::Debug for Command {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("Command")
265 .field("name", &self.name)
266 .field("keystrokes", &self.keystrokes)
267 .finish()
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use std::sync::Arc;
274
275 use super::*;
276 use editor::Editor;
277 use gpui::{executor::Deterministic, TestAppContext};
278 use project::Project;
279 use workspace::{AppState, Workspace};
280
281 #[test]
282 fn test_humanize_action_name() {
283 assert_eq!(
284 humanize_action_name("editor::GoToDefinition"),
285 "editor: go to definition"
286 );
287 assert_eq!(
288 humanize_action_name("editor::Backspace"),
289 "editor: backspace"
290 );
291 assert_eq!(
292 humanize_action_name("go_to_line::Deploy"),
293 "go to line: deploy"
294 );
295 }
296
297 #[gpui::test]
298 async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
299 let app_state = init_test(cx);
300
301 let project = Project::test(app_state.fs.clone(), [], cx).await;
302 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
303 let editor = cx.add_view(window_id, |cx| {
304 let mut editor = Editor::single_line(None, cx);
305 editor.set_text("abc", cx);
306 editor
307 });
308
309 workspace.update(cx, |workspace, cx| {
310 cx.focus(&editor);
311 workspace.add_item(Box::new(editor.clone()), cx)
312 });
313
314 workspace.update(cx, |workspace, cx| {
315 toggle_command_palette(workspace, &Toggle, cx);
316 });
317
318 let palette = workspace.read_with(cx, |workspace, _| {
319 workspace.modal::<CommandPalette>().unwrap()
320 });
321
322 palette
323 .update(cx, |palette, cx| {
324 palette
325 .delegate_mut()
326 .update_matches("bcksp".to_string(), cx)
327 })
328 .await;
329
330 palette.update(cx, |palette, cx| {
331 assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
332 palette.confirm(&Default::default(), cx);
333 });
334 deterministic.run_until_parked();
335 editor.read_with(cx, |editor, cx| {
336 assert_eq!(editor.text(cx), "ab");
337 });
338
339 // Add namespace filter, and redeploy the palette
340 cx.update(|cx| {
341 cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
342 filter.filtered_namespaces.insert("editor");
343 })
344 });
345
346 workspace.update(cx, |workspace, cx| {
347 toggle_command_palette(workspace, &Toggle, cx);
348 });
349
350 // Assert editor command not present
351 let palette = workspace.read_with(cx, |workspace, _| {
352 workspace.modal::<CommandPalette>().unwrap()
353 });
354
355 palette
356 .update(cx, |palette, cx| {
357 palette
358 .delegate_mut()
359 .update_matches("bcksp".to_string(), cx)
360 })
361 .await;
362
363 palette.update(cx, |palette, _| {
364 assert!(palette.delegate().matches.is_empty())
365 });
366 }
367
368 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
369 cx.update(|cx| {
370 let app_state = AppState::test(cx);
371 theme::init((), cx);
372 language::init(cx);
373 editor::init(cx);
374 workspace::init(app_state.clone(), cx);
375 init(cx);
376 app_state
377 })
378 }
379}