1use anyhow::anyhow;
2use collections::{CommandPaletteFilter, HashMap};
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions, div, Action, AnyElement, AnyWindowHandle, AppContext, BorrowWindow, Div, Element,
6 EventEmitter, FocusHandle, Keystroke, ParentElement, Render, View, ViewContext, VisualContext,
7 WeakView,
8};
9use picker::{Picker, PickerDelegate};
10use std::cmp::{self, Reverse};
11use ui::modal;
12use util::{
13 channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
14 ResultExt,
15};
16use workspace::{ModalEvent, Workspace};
17use zed_actions::OpenZedURL;
18
19actions!(Toggle);
20
21pub fn init(cx: &mut AppContext) {
22 dbg!("init");
23 cx.set_global(HitCounts::default());
24
25 cx.observe_new_views(
26 |workspace: &mut Workspace, _: &mut ViewContext<Workspace>| {
27 dbg!("new workspace found");
28 workspace
29 .modal_layer()
30 .register_modal(Toggle, |workspace, cx| {
31 dbg!("hitting cmd-shift-p");
32 let Some(focus_handle) = cx.focused() else {
33 return None;
34 };
35
36 Some(cx.build_view(|cx| {
37 let delegate =
38 CommandPaletteDelegate::new(cx.view().downgrade(), focus_handle);
39 CommandPalette::new(delegate, cx)
40 }))
41 });
42 },
43 )
44 .detach();
45}
46
47pub struct CommandPalette {
48 picker: View<Picker<CommandPaletteDelegate>>,
49}
50
51impl CommandPalette {
52 fn new(delegate: CommandPaletteDelegate, cx: &mut ViewContext<Self>) -> Self {
53 let picker = cx.build_view(|cx| Picker::new(delegate, cx));
54 Self { picker }
55 }
56}
57impl EventEmitter<ModalEvent> for CommandPalette {}
58
59impl Render for CommandPalette {
60 type Element = Div<Self>;
61
62 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
63 dbg!("Rendering");
64 modal(cx).w_96().child(self.picker.clone())
65 }
66}
67
68pub type CommandPaletteInterceptor =
69 Box<dyn Fn(&str, &AppContext) -> Option<CommandInterceptResult>>;
70
71pub struct CommandInterceptResult {
72 pub action: Box<dyn Action>,
73 pub string: String,
74 pub positions: Vec<usize>,
75}
76
77pub struct CommandPaletteDelegate {
78 command_palette: WeakView<CommandPalette>,
79 actions: Vec<Command>,
80 matches: Vec<StringMatch>,
81 selected_ix: usize,
82 focus_handle: FocusHandle,
83}
84
85pub enum Event {
86 Dismissed,
87 Confirmed {
88 window: AnyWindowHandle,
89 focused_view_id: usize,
90 action: Box<dyn Action>,
91 },
92}
93
94struct Command {
95 name: String,
96 action: Box<dyn Action>,
97 keystrokes: Vec<Keystroke>,
98}
99
100impl Clone for Command {
101 fn clone(&self) -> Self {
102 Self {
103 name: self.name.clone(),
104 action: self.action.boxed_clone(),
105 keystrokes: self.keystrokes.clone(),
106 }
107 }
108}
109/// Hit count for each command in the palette.
110/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
111/// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
112#[derive(Default)]
113struct HitCounts(HashMap<String, usize>);
114
115impl CommandPaletteDelegate {
116 pub fn new(command_palette: WeakView<CommandPalette>, focus_handle: FocusHandle) -> Self {
117 Self {
118 command_palette,
119 actions: Default::default(),
120 matches: vec![StringMatch {
121 candidate_id: 0,
122 score: 0.,
123 positions: vec![],
124 string: "Foo my bar".into(),
125 }],
126 selected_ix: 0,
127 focus_handle,
128 }
129 }
130}
131
132impl PickerDelegate for CommandPaletteDelegate {
133 type ListItem = Div<Picker<Self>>;
134
135 fn match_count(&self) -> usize {
136 self.matches.len()
137 }
138
139 fn selected_index(&self) -> usize {
140 self.selected_ix
141 }
142
143 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
144 self.selected_ix = ix;
145 }
146
147 fn update_matches(
148 &mut self,
149 query: String,
150 cx: &mut ViewContext<Picker<Self>>,
151 ) -> gpui::Task<()> {
152 let view_id = &self.focus_handle;
153 let window = cx.window();
154 cx.spawn(move |picker, mut cx| async move {
155 let mut actions = picker
156 .update(&mut cx, |this, _| this.delegate.actions.clone())
157 .expect("todo: handle picker no longer being around");
158 // _ = window
159 // .available_actions(view_id, &cx)
160 // .into_iter()
161 // .flatten()
162 // .filter_map(|(name, action, bindings)| {
163 // let filtered = cx.read(|cx| {
164 // if cx.has_global::<CommandPaletteFilter>() {
165 // let filter = cx.global::<CommandPaletteFilter>();
166 // filter.filtered_namespaces.contains(action.namespace())
167 // } else {
168 // false
169 // }
170 // });
171
172 // if filtered {
173 // None
174 // } else {
175 // Some(Command {
176 // name: humanize_action_name(name),
177 // action,
178 // keystrokes: bindings
179 // .iter()
180 // .map(|binding| binding.keystrokes())
181 // .last()
182 // .map_or(Vec::new(), |keystrokes| keystrokes.to_vec()),
183 // })
184 // }
185 // })
186 // .collect::<Vec<_>>();
187
188 cx.read_global::<HitCounts, _>(|hit_counts, _| {
189 actions.sort_by_key(|action| {
190 (
191 Reverse(hit_counts.0.get(&action.name).cloned()),
192 action.name.clone(),
193 )
194 });
195 })
196 .ok();
197
198 let candidates = actions
199 .iter()
200 .enumerate()
201 .map(|(ix, command)| StringMatchCandidate {
202 id: ix,
203 string: command.name.to_string(),
204 char_bag: command.name.chars().collect(),
205 })
206 .collect::<Vec<_>>();
207 let mut matches = if query.is_empty() {
208 candidates
209 .into_iter()
210 .enumerate()
211 .map(|(index, candidate)| StringMatch {
212 candidate_id: index,
213 string: candidate.string,
214 positions: Vec::new(),
215 score: 0.0,
216 })
217 .collect()
218 } else {
219 fuzzy::match_strings(
220 &candidates,
221 &query,
222 true,
223 10000,
224 &Default::default(),
225 cx.background_executor().clone(),
226 )
227 .await
228 };
229 let mut intercept_result = None;
230 // todo!() for vim mode
231 // cx.read(|cx| {
232 // if cx.has_global::<CommandPaletteInterceptor>() {
233 // cx.global::<CommandPaletteInterceptor>()(&query, cx)
234 // } else {
235 // None
236 // }
237 // });
238 if *RELEASE_CHANNEL == ReleaseChannel::Dev {
239 if parse_zed_link(&query).is_some() {
240 intercept_result = Some(CommandInterceptResult {
241 action: OpenZedURL { url: query.clone() }.boxed_clone(),
242 string: query.clone(),
243 positions: vec![],
244 })
245 }
246 }
247 if let Some(CommandInterceptResult {
248 action,
249 string,
250 positions,
251 }) = intercept_result
252 {
253 if let Some(idx) = matches
254 .iter()
255 .position(|m| actions[m.candidate_id].action.type_id() == action.type_id())
256 {
257 matches.remove(idx);
258 }
259 actions.push(Command {
260 name: string.clone(),
261 action,
262 keystrokes: vec![],
263 });
264 matches.insert(
265 0,
266 StringMatch {
267 candidate_id: actions.len() - 1,
268 string,
269 positions,
270 score: 0.0,
271 },
272 )
273 }
274 picker
275 .update(&mut cx, |picker, _| {
276 let delegate = &mut picker.delegate;
277 delegate.actions = actions;
278 delegate.matches = matches;
279 if delegate.matches.is_empty() {
280 delegate.selected_ix = 0;
281 } else {
282 delegate.selected_ix =
283 cmp::min(delegate.selected_ix, delegate.matches.len() - 1);
284 }
285 })
286 .log_err();
287 })
288 }
289
290 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
291 dbg!("dismissed");
292 self.command_palette
293 .update(cx, |command_palette, cx| cx.emit(ModalEvent::Dismissed))
294 .log_err();
295 }
296
297 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
298 // if !self.matches.is_empty() {
299 // let window = cx.window();
300 // let focused_view_id = self.focused_view_id;
301 // let action_ix = self.matches[self.selected_ix].candidate_id;
302 // let command = self.actions.remove(action_ix);
303 // cx.update_default_global(|hit_counts: &mut HitCounts, _| {
304 // *hit_counts.0.entry(command.name).or_default() += 1;
305 // });
306 // let action = command.action;
307
308 // cx.app_context()
309 // .spawn(move |mut cx| async move {
310 // window
311 // .dispatch_action(focused_view_id, action.as_ref(), &mut cx)
312 // .ok_or_else(|| anyhow!("window was closed"))
313 // })
314 // .detach_and_log_err(cx);
315 // }
316 self.dismissed(cx)
317 }
318
319 fn render_match(
320 &self,
321 ix: usize,
322 selected: bool,
323 cx: &mut ViewContext<Picker<Self>>,
324 ) -> Self::ListItem {
325 div().child("ooh yeah")
326 }
327
328 // fn render_match(
329 // &self,
330 // ix: usize,
331 // mouse_state: &mut MouseState,
332 // selected: bool,
333 // cx: &gpui::AppContext,
334 // ) -> AnyElement<Picker<Self>> {
335 // let mat = &self.matches[ix];
336 // let command = &self.actions[mat.candidate_id];
337 // let theme = theme::current(cx);
338 // let style = theme.picker.item.in_state(selected).style_for(mouse_state);
339 // let key_style = &theme.command_palette.key.in_state(selected);
340 // let keystroke_spacing = theme.command_palette.keystroke_spacing;
341
342 // Flex::row()
343 // .with_child(
344 // Label::new(mat.string.clone(), style.label.clone())
345 // .with_highlights(mat.positions.clone()),
346 // )
347 // .with_children(command.keystrokes.iter().map(|keystroke| {
348 // Flex::row()
349 // .with_children(
350 // [
351 // (keystroke.ctrl, "^"),
352 // (keystroke.alt, "⌥"),
353 // (keystroke.cmd, "⌘"),
354 // (keystroke.shift, "⇧"),
355 // ]
356 // .into_iter()
357 // .filter_map(|(modifier, label)| {
358 // if modifier {
359 // Some(
360 // Label::new(label, key_style.label.clone())
361 // .contained()
362 // .with_style(key_style.container),
363 // )
364 // } else {
365 // None
366 // }
367 // }),
368 // )
369 // .with_child(
370 // Label::new(keystroke.key.clone(), key_style.label.clone())
371 // .contained()
372 // .with_style(key_style.container),
373 // )
374 // .contained()
375 // .with_margin_left(keystroke_spacing)
376 // .flex_float()
377 // }))
378 // .contained()
379 // .with_style(style.container)
380 // .into_any()
381 // }
382}
383
384fn humanize_action_name(name: &str) -> String {
385 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
386 let mut result = String::with_capacity(capacity);
387 for char in name.chars() {
388 if char == ':' {
389 if result.ends_with(':') {
390 result.push(' ');
391 } else {
392 result.push(':');
393 }
394 } else if char == '_' {
395 result.push(' ');
396 } else if char.is_uppercase() {
397 if !result.ends_with(' ') {
398 result.push(' ');
399 }
400 result.extend(char.to_lowercase());
401 } else {
402 result.push(char);
403 }
404 }
405 result
406}
407
408impl std::fmt::Debug for Command {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.debug_struct("Command")
411 .field("name", &self.name)
412 .field("keystrokes", &self.keystrokes)
413 .finish()
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use std::sync::Arc;
420
421 use super::*;
422 use editor::Editor;
423 use gpui::{executor::Deterministic, TestAppContext};
424 use project::Project;
425 use workspace::{AppState, Workspace};
426
427 #[test]
428 fn test_humanize_action_name() {
429 assert_eq!(
430 humanize_action_name("editor::GoToDefinition"),
431 "editor: go to definition"
432 );
433 assert_eq!(
434 humanize_action_name("editor::Backspace"),
435 "editor: backspace"
436 );
437 assert_eq!(
438 humanize_action_name("go_to_line::Deploy"),
439 "go to line: deploy"
440 );
441 }
442
443 #[gpui::test]
444 async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
445 let app_state = init_test(cx);
446
447 let project = Project::test(app_state.fs.clone(), [], cx).await;
448 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
449 let workspace = window.root(cx);
450 let editor = window.add_view(cx, |cx| {
451 let mut editor = Editor::single_line(None, cx);
452 editor.set_text("abc", cx);
453 editor
454 });
455
456 workspace.update(cx, |workspace, cx| {
457 cx.focus(&editor);
458 workspace.add_item(Box::new(editor.clone()), cx)
459 });
460
461 workspace.update(cx, |workspace, cx| {
462 toggle_command_palette(workspace, &Toggle, cx);
463 });
464
465 let palette = workspace.read_with(cx, |workspace, _| {
466 workspace.modal::<CommandPalette>().unwrap()
467 });
468
469 palette
470 .update(cx, |palette, cx| {
471 // Fill up palette's command list by running an empty query;
472 // we only need it to subsequently assert that the palette is initially
473 // sorted by command's name.
474 palette.delegate_mut().update_matches("".to_string(), cx)
475 })
476 .await;
477
478 palette.update(cx, |palette, _| {
479 let is_sorted =
480 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
481 assert!(is_sorted(&palette.delegate().actions));
482 });
483
484 palette
485 .update(cx, |palette, cx| {
486 palette
487 .delegate_mut()
488 .update_matches("bcksp".to_string(), cx)
489 })
490 .await;
491
492 palette.update(cx, |palette, cx| {
493 assert_eq!(palette.delegate().matches[0].string, "editor: backspace");
494 palette.confirm(&Default::default(), cx);
495 });
496 deterministic.run_until_parked();
497 editor.read_with(cx, |editor, cx| {
498 assert_eq!(editor.text(cx), "ab");
499 });
500
501 // Add namespace filter, and redeploy the palette
502 cx.update(|cx| {
503 cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
504 filter.filtered_namespaces.insert("editor");
505 })
506 });
507
508 workspace.update(cx, |workspace, cx| {
509 toggle_command_palette(workspace, &Toggle, cx);
510 });
511
512 // Assert editor command not present
513 let palette = workspace.read_with(cx, |workspace, _| {
514 workspace.modal::<CommandPalette>().unwrap()
515 });
516
517 palette
518 .update(cx, |palette, cx| {
519 palette
520 .delegate_mut()
521 .update_matches("bcksp".to_string(), cx)
522 })
523 .await;
524
525 palette.update(cx, |palette, _| {
526 assert!(palette.delegate().matches.is_empty())
527 });
528 }
529
530 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
531 cx.update(|cx| {
532 let app_state = AppState::test(cx);
533 theme::init(cx);
534 language::init(cx);
535 editor::init(cx);
536 workspace::init(app_state.clone(), cx);
537 init(cx);
538 Project::init_settings(cx);
539 app_state
540 })
541 }
542}