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