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