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