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