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