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