1use std::{
2 cmp::{self, Reverse},
3 sync::Arc,
4 time::Duration,
5};
6
7use client::{parse_zed_link, telemetry::Telemetry};
8use collections::HashMap;
9use command_palette_hooks::{
10 CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
11};
12use fuzzy::{StringMatch, StringMatchCandidate};
13use gpui::{
14 actions, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Global,
15 ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
16};
17use picker::{Picker, PickerDelegate};
18
19use postage::{sink::Sink, stream::Stream};
20use ui::{h_flex, prelude::*, v_flex, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing};
21use util::ResultExt;
22use workspace::{ModalView, Workspace};
23use zed_actions::OpenZedUrl;
24
25actions!(command_palette, [Toggle]);
26
27pub fn init(cx: &mut AppContext) {
28 client::init_settings(cx);
29 cx.set_global(HitCounts::default());
30 cx.set_global(CommandPaletteFilter::default());
31 cx.observe_new_views(CommandPalette::register).detach();
32}
33
34impl ModalView for CommandPalette {}
35
36pub struct CommandPalette {
37 picker: View<Picker<CommandPaletteDelegate>>,
38}
39
40fn trim_consecutive_whitespaces(input: &str) -> String {
41 let mut result = String::with_capacity(input.len());
42 let mut last_char_was_whitespace = false;
43
44 for char in input.trim().chars() {
45 if char.is_whitespace() {
46 if !last_char_was_whitespace {
47 result.push(char);
48 }
49 last_char_was_whitespace = true;
50 } else {
51 result.push(char);
52 last_char_was_whitespace = false;
53 }
54 }
55 result
56}
57
58impl CommandPalette {
59 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
60 workspace.register_action(|workspace, _: &Toggle, cx| {
61 let Some(previous_focus_handle) = cx.focused() else {
62 return;
63 };
64 let telemetry = workspace.client().telemetry().clone();
65 workspace.toggle_modal(cx, move |cx| {
66 CommandPalette::new(previous_focus_handle, telemetry, cx)
67 });
68 });
69 }
70
71 fn new(
72 previous_focus_handle: FocusHandle,
73 telemetry: Arc<Telemetry>,
74 cx: &mut ViewContext<Self>,
75 ) -> Self {
76 let filter = cx.try_global::<CommandPaletteFilter>();
77
78 let commands = cx
79 .available_actions()
80 .into_iter()
81 .filter_map(|action| {
82 let name = action.name();
83 let namespace = name.split("::").next().unwrap_or("malformed action name");
84 if filter.is_some_and(|f| {
85 f.hidden_namespaces.contains(namespace)
86 || f.hidden_action_types.contains(&action.type_id())
87 }) {
88 return None;
89 }
90
91 Some(Command {
92 name: humanize_action_name(&name),
93 action,
94 })
95 })
96 .collect();
97
98 let delegate = CommandPaletteDelegate::new(
99 cx.view().downgrade(),
100 commands,
101 telemetry,
102 previous_focus_handle,
103 );
104
105 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
106 Self { picker }
107 }
108}
109
110impl EventEmitter<DismissEvent> for CommandPalette {}
111
112impl FocusableView for CommandPalette {
113 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
114 self.picker.focus_handle(cx)
115 }
116}
117
118impl Render for CommandPalette {
119 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
120 v_flex().w(rems(34.)).child(self.picker.clone())
121 }
122}
123
124pub struct CommandPaletteDelegate {
125 command_palette: WeakView<CommandPalette>,
126 all_commands: Vec<Command>,
127 commands: Vec<Command>,
128 matches: Vec<StringMatch>,
129 selected_ix: usize,
130 telemetry: Arc<Telemetry>,
131 previous_focus_handle: FocusHandle,
132 updating_matches: Option<(
133 Task<()>,
134 postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>)>,
135 )>,
136}
137
138struct Command {
139 name: String,
140 action: Box<dyn Action>,
141}
142
143impl Clone for Command {
144 fn clone(&self) -> Self {
145 Self {
146 name: self.name.clone(),
147 action: self.action.boxed_clone(),
148 }
149 }
150}
151
152/// Hit count for each command in the palette.
153/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
154/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
155#[derive(Default, Clone)]
156struct HitCounts(HashMap<String, usize>);
157
158impl Global for HitCounts {}
159
160impl CommandPaletteDelegate {
161 fn new(
162 command_palette: WeakView<CommandPalette>,
163 commands: Vec<Command>,
164 telemetry: Arc<Telemetry>,
165 previous_focus_handle: FocusHandle,
166 ) -> Self {
167 Self {
168 command_palette,
169 all_commands: commands.clone(),
170 matches: vec![],
171 commands,
172 selected_ix: 0,
173 telemetry,
174 previous_focus_handle,
175 updating_matches: None,
176 }
177 }
178
179 fn matches_updated(
180 &mut self,
181 query: String,
182 mut commands: Vec<Command>,
183 mut matches: Vec<StringMatch>,
184 cx: &mut ViewContext<Picker<Self>>,
185 ) {
186 self.updating_matches.take();
187
188 let mut intercept_result =
189 if let Some(interceptor) = cx.try_global::<CommandPaletteInterceptor>() {
190 (interceptor.0)(&query, cx)
191 } else {
192 None
193 };
194
195 if parse_zed_link(&query, cx).is_some() {
196 intercept_result = Some(CommandInterceptResult {
197 action: OpenZedUrl { url: query.clone() }.boxed_clone(),
198 string: query.clone(),
199 positions: vec![],
200 })
201 }
202
203 if let Some(CommandInterceptResult {
204 action,
205 string,
206 positions,
207 }) = intercept_result
208 {
209 if let Some(idx) = matches
210 .iter()
211 .position(|m| commands[m.candidate_id].action.type_id() == action.type_id())
212 {
213 matches.remove(idx);
214 }
215 commands.push(Command {
216 name: string.clone(),
217 action,
218 });
219 matches.insert(
220 0,
221 StringMatch {
222 candidate_id: commands.len() - 1,
223 string,
224 positions,
225 score: 0.0,
226 },
227 )
228 }
229 self.commands = commands;
230 self.matches = matches;
231 if self.matches.is_empty() {
232 self.selected_ix = 0;
233 } else {
234 self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
235 }
236 }
237}
238
239impl PickerDelegate for CommandPaletteDelegate {
240 type ListItem = ListItem;
241
242 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
243 "Execute a command...".into()
244 }
245
246 fn match_count(&self) -> usize {
247 self.matches.len()
248 }
249
250 fn selected_index(&self) -> usize {
251 self.selected_ix
252 }
253
254 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
255 self.selected_ix = ix;
256 }
257
258 fn update_matches(
259 &mut self,
260 query: String,
261 cx: &mut ViewContext<Picker<Self>>,
262 ) -> gpui::Task<()> {
263 let (mut tx, mut rx) = postage::dispatch::channel(1);
264 let task = cx.background_executor().spawn({
265 let mut commands = self.all_commands.clone();
266 let hit_counts = cx.global::<HitCounts>().clone();
267 let executor = cx.background_executor().clone();
268 let query = trim_consecutive_whitespaces(&query.as_str());
269 async move {
270 commands.sort_by_key(|action| {
271 (
272 Reverse(hit_counts.0.get(&action.name).cloned()),
273 action.name.clone(),
274 )
275 });
276
277 let candidates = commands
278 .iter()
279 .enumerate()
280 .map(|(ix, command)| StringMatchCandidate {
281 id: ix,
282 string: command.name.to_string(),
283 char_bag: command.name.chars().collect(),
284 })
285 .collect::<Vec<_>>();
286 let matches = if query.is_empty() {
287 candidates
288 .into_iter()
289 .enumerate()
290 .map(|(index, candidate)| StringMatch {
291 candidate_id: index,
292 string: candidate.string,
293 positions: Vec::new(),
294 score: 0.0,
295 })
296 .collect()
297 } else {
298 let ret = fuzzy::match_strings(
299 &candidates,
300 &query,
301 true,
302 10000,
303 &Default::default(),
304 executor,
305 )
306 .await;
307 ret
308 };
309
310 tx.send((commands, matches)).await.log_err();
311 }
312 });
313 self.updating_matches = Some((task, rx.clone()));
314
315 cx.spawn(move |picker, mut cx| async move {
316 let Some((commands, matches)) = rx.recv().await else {
317 return;
318 };
319
320 picker
321 .update(&mut cx, |picker, cx| {
322 picker
323 .delegate
324 .matches_updated(query, commands, matches, cx)
325 })
326 .log_err();
327 })
328 }
329
330 fn finalize_update_matches(
331 &mut self,
332 query: String,
333 duration: Duration,
334 cx: &mut ViewContext<Picker<Self>>,
335 ) -> bool {
336 let Some((task, rx)) = self.updating_matches.take() else {
337 return true;
338 };
339
340 match cx
341 .background_executor()
342 .block_with_timeout(duration, rx.clone().recv())
343 {
344 Ok(Some((commands, matches))) => {
345 self.matches_updated(query, commands, matches, cx);
346 true
347 }
348 _ => {
349 self.updating_matches = Some((task, rx));
350 false
351 }
352 }
353 }
354
355 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
356 self.command_palette
357 .update(cx, |_, cx| cx.emit(DismissEvent))
358 .log_err();
359 }
360
361 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
362 if self.matches.is_empty() {
363 self.dismissed(cx);
364 return;
365 }
366 let action_ix = self.matches[self.selected_ix].candidate_id;
367 let command = self.commands.swap_remove(action_ix);
368
369 self.telemetry
370 .report_action_event("command palette", command.name.clone());
371
372 self.matches.clear();
373 self.commands.clear();
374 cx.update_global(|hit_counts: &mut HitCounts, _| {
375 *hit_counts.0.entry(command.name).or_default() += 1;
376 });
377 let action = command.action;
378 cx.focus(&self.previous_focus_handle);
379 self.dismissed(cx);
380 cx.dispatch_action(action);
381 }
382
383 fn render_match(
384 &self,
385 ix: usize,
386 selected: bool,
387 cx: &mut ViewContext<Picker<Self>>,
388 ) -> Option<Self::ListItem> {
389 let r#match = self.matches.get(ix)?;
390 let command = self.commands.get(r#match.candidate_id)?;
391 Some(
392 ListItem::new(ix)
393 .inset(true)
394 .spacing(ListItemSpacing::Sparse)
395 .selected(selected)
396 .child(
397 h_flex()
398 .w_full()
399 .py_px()
400 .justify_between()
401 .child(HighlightedLabel::new(
402 command.name.clone(),
403 r#match.positions.clone(),
404 ))
405 .children(KeyBinding::for_action_in(
406 &*command.action,
407 &self.previous_focus_handle,
408 cx,
409 )),
410 ),
411 )
412 }
413}
414
415fn humanize_action_name(name: &str) -> String {
416 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
417 let mut result = String::with_capacity(capacity);
418 for char in name.chars() {
419 if char == ':' {
420 if result.ends_with(':') {
421 result.push(' ');
422 } else {
423 result.push(':');
424 }
425 } else if char == '_' {
426 result.push(' ');
427 } else if char.is_uppercase() {
428 if !result.ends_with(' ') {
429 result.push(' ');
430 }
431 result.extend(char.to_lowercase());
432 } else {
433 result.push(char);
434 }
435 }
436 result
437}
438
439impl std::fmt::Debug for Command {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 f.debug_struct("Command")
442 .field("name", &self.name)
443 .finish_non_exhaustive()
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use std::sync::Arc;
450
451 use super::*;
452 use editor::Editor;
453 use go_to_line::GoToLine;
454 use gpui::TestAppContext;
455 use language::Point;
456 use project::Project;
457 use settings::KeymapFile;
458 use workspace::{AppState, Workspace};
459
460 #[test]
461 fn test_humanize_action_name() {
462 assert_eq!(
463 humanize_action_name("editor::GoToDefinition"),
464 "editor: go to definition"
465 );
466 assert_eq!(
467 humanize_action_name("editor::Backspace"),
468 "editor: backspace"
469 );
470 assert_eq!(
471 humanize_action_name("go_to_line::Deploy"),
472 "go to line: deploy"
473 );
474 }
475
476 #[gpui::test]
477 async fn test_command_palette(cx: &mut TestAppContext) {
478 let app_state = init_test(cx);
479 let project = Project::test(app_state.fs.clone(), [], cx).await;
480 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
481
482 let editor = cx.new_view(|cx| {
483 let mut editor = Editor::single_line(cx);
484 editor.set_text("abc", cx);
485 editor
486 });
487
488 workspace.update(cx, |workspace, cx| {
489 workspace.add_item_to_active_pane(Box::new(editor.clone()), cx);
490 editor.update(cx, |editor, cx| editor.focus(cx))
491 });
492
493 cx.simulate_keystrokes("cmd-shift-p");
494
495 let palette = workspace.update(cx, |workspace, cx| {
496 workspace
497 .active_modal::<CommandPalette>(cx)
498 .unwrap()
499 .read(cx)
500 .picker
501 .clone()
502 });
503
504 palette.update(cx, |palette, _| {
505 assert!(palette.delegate.commands.len() > 5);
506 let is_sorted =
507 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
508 assert!(is_sorted(&palette.delegate.commands));
509 });
510
511 cx.simulate_input("bcksp");
512
513 palette.update(cx, |palette, _| {
514 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
515 });
516
517 cx.simulate_keystrokes("enter");
518
519 workspace.update(cx, |workspace, cx| {
520 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
521 assert_eq!(editor.read(cx).text(cx), "ab")
522 });
523
524 // Add namespace filter, and redeploy the palette
525 cx.update(|cx| {
526 cx.set_global(CommandPaletteFilter::default());
527 cx.update_global::<CommandPaletteFilter, _>(|filter, _| {
528 filter.hidden_namespaces.insert("editor");
529 })
530 });
531
532 cx.simulate_keystrokes("cmd-shift-p");
533 cx.simulate_input("bcksp");
534
535 let palette = workspace.update(cx, |workspace, cx| {
536 workspace
537 .active_modal::<CommandPalette>(cx)
538 .unwrap()
539 .read(cx)
540 .picker
541 .clone()
542 });
543 palette.update(cx, |palette, _| {
544 assert!(palette.delegate.matches.is_empty())
545 });
546 }
547
548 #[gpui::test]
549 async fn test_go_to_line(cx: &mut TestAppContext) {
550 let app_state = init_test(cx);
551 let project = Project::test(app_state.fs.clone(), [], cx).await;
552 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
553
554 cx.simulate_keystrokes("cmd-n");
555
556 let editor = workspace.update(cx, |workspace, cx| {
557 workspace.active_item_as::<Editor>(cx).unwrap()
558 });
559 editor.update(cx, |editor, cx| editor.set_text("1\n2\n3\n4\n5\n6\n", cx));
560
561 cx.simulate_keystrokes("cmd-shift-p");
562 cx.simulate_input("go to line: Toggle");
563 cx.simulate_keystrokes("enter");
564
565 workspace.update(cx, |workspace, cx| {
566 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
567 });
568
569 cx.simulate_keystrokes("3 enter");
570
571 editor.update(cx, |editor, cx| {
572 assert!(editor.focus_handle(cx).is_focused(cx));
573 assert_eq!(
574 editor.selections.last::<Point>(cx).range().start,
575 Point::new(2, 0)
576 );
577 });
578 }
579
580 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
581 cx.update(|cx| {
582 let app_state = AppState::test(cx);
583 theme::init(theme::LoadThemes::JustBase, cx);
584 language::init(cx);
585 editor::init(cx);
586 menu::init();
587 go_to_line::init(cx);
588 workspace::init(app_state.clone(), cx);
589 init(cx);
590 Project::init_settings(cx);
591 KeymapFile::parse(
592 r#"[
593 {
594 "bindings": {
595 "cmd-n": "workspace::NewFile",
596 "enter": "menu::Confirm",
597 "cmd-shift-p": "command_palette::Toggle"
598 }
599 }
600 ]"#,
601 )
602 .unwrap()
603 .add_to_cx(cx)
604 .unwrap();
605 app_state
606 })
607 }
608}