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