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