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