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