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