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::new(ix, &command.name))
287 .collect::<Vec<_>>();
288 let matches = if query.is_empty() {
289 candidates
290 .into_iter()
291 .enumerate()
292 .map(|(index, candidate)| StringMatch {
293 candidate_id: index,
294 string: candidate.string,
295 positions: Vec::new(),
296 score: 0.0,
297 })
298 .collect()
299 } else {
300 fuzzy::match_strings(
301 &candidates,
302 &query,
303 true,
304 10000,
305 &Default::default(),
306 executor,
307 )
308 .await
309 };
310
311 tx.send((commands, matches)).await.log_err();
312 }
313 });
314 self.updating_matches = Some((task, rx.clone()));
315
316 cx.spawn(move |picker, mut cx| async move {
317 let Some((commands, matches)) = rx.recv().await else {
318 return;
319 };
320
321 picker
322 .update(&mut cx, |picker, cx| {
323 picker
324 .delegate
325 .matches_updated(query, commands, matches, cx)
326 })
327 .log_err();
328 })
329 }
330
331 fn finalize_update_matches(
332 &mut self,
333 query: String,
334 duration: Duration,
335 cx: &mut ViewContext<Picker<Self>>,
336 ) -> bool {
337 let Some((task, rx)) = self.updating_matches.take() else {
338 return true;
339 };
340
341 match cx
342 .background_executor()
343 .block_with_timeout(duration, rx.clone().recv())
344 {
345 Ok(Some((commands, matches))) => {
346 self.matches_updated(query, commands, matches, cx);
347 true
348 }
349 _ => {
350 self.updating_matches = Some((task, rx));
351 false
352 }
353 }
354 }
355
356 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
357 self.command_palette
358 .update(cx, |_, cx| cx.emit(DismissEvent))
359 .log_err();
360 }
361
362 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
363 if self.matches.is_empty() {
364 self.dismissed(cx);
365 return;
366 }
367 let action_ix = self.matches[self.selected_ix].candidate_id;
368 let command = self.commands.swap_remove(action_ix);
369
370 self.telemetry
371 .report_action_event("command palette", command.name.clone());
372
373 self.matches.clear();
374 self.commands.clear();
375 HitCounts::update_global(cx, |hit_counts, _cx| {
376 *hit_counts.0.entry(command.name).or_default() += 1;
377 });
378 let action = command.action;
379 cx.focus(&self.previous_focus_handle);
380 self.dismissed(cx);
381 cx.dispatch_action(action);
382 }
383
384 fn render_match(
385 &self,
386 ix: usize,
387 selected: bool,
388 cx: &mut ViewContext<Picker<Self>>,
389 ) -> Option<Self::ListItem> {
390 let r#match = self.matches.get(ix)?;
391 let command = self.commands.get(r#match.candidate_id)?;
392 Some(
393 ListItem::new(ix)
394 .inset(true)
395 .spacing(ListItemSpacing::Sparse)
396 .toggle_state(selected)
397 .child(
398 h_flex()
399 .w_full()
400 .py_px()
401 .justify_between()
402 .child(HighlightedLabel::new(
403 command.name.clone(),
404 r#match.positions.clone(),
405 ))
406 .children(KeyBinding::for_action_in(
407 &*command.action,
408 &self.previous_focus_handle,
409 cx,
410 )),
411 ),
412 )
413 }
414}
415
416fn humanize_action_name(name: &str) -> String {
417 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
418 let mut result = String::with_capacity(capacity);
419 for char in name.chars() {
420 if char == ':' {
421 if result.ends_with(':') {
422 result.push(' ');
423 } else {
424 result.push(':');
425 }
426 } else if char == '_' {
427 result.push(' ');
428 } else if char.is_uppercase() {
429 if !result.ends_with(' ') {
430 result.push(' ');
431 }
432 result.extend(char.to_lowercase());
433 } else {
434 result.push(char);
435 }
436 }
437 result
438}
439
440impl std::fmt::Debug for Command {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 f.debug_struct("Command")
443 .field("name", &self.name)
444 .finish_non_exhaustive()
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use std::sync::Arc;
451
452 use super::*;
453 use editor::Editor;
454 use go_to_line::GoToLine;
455 use gpui::TestAppContext;
456 use language::Point;
457 use project::Project;
458 use settings::KeymapFile;
459 use workspace::{AppState, Workspace};
460
461 #[test]
462 fn test_humanize_action_name() {
463 assert_eq!(
464 humanize_action_name("editor::GoToDefinition"),
465 "editor: go to definition"
466 );
467 assert_eq!(
468 humanize_action_name("editor::Backspace"),
469 "editor: backspace"
470 );
471 assert_eq!(
472 humanize_action_name("go_to_line::Deploy"),
473 "go to line: deploy"
474 );
475 }
476
477 #[gpui::test]
478 async fn test_command_palette(cx: &mut TestAppContext) {
479 let app_state = init_test(cx);
480 let project = Project::test(app_state.fs.clone(), [], cx).await;
481 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
482
483 let editor = cx.new_view(|cx| {
484 let mut editor = Editor::single_line(cx);
485 editor.set_text("abc", cx);
486 editor
487 });
488
489 workspace.update(cx, |workspace, cx| {
490 workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, cx);
491 editor.update(cx, |editor, cx| editor.focus(cx))
492 });
493
494 cx.simulate_keystrokes("cmd-shift-p");
495
496 let palette = workspace.update(cx, |workspace, cx| {
497 workspace
498 .active_modal::<CommandPalette>(cx)
499 .unwrap()
500 .read(cx)
501 .picker
502 .clone()
503 });
504
505 palette.update(cx, |palette, _| {
506 assert!(palette.delegate.commands.len() > 5);
507 let is_sorted =
508 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
509 assert!(is_sorted(&palette.delegate.commands));
510 });
511
512 cx.simulate_input("bcksp");
513
514 palette.update(cx, |palette, _| {
515 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
516 });
517
518 cx.simulate_keystrokes("enter");
519
520 workspace.update(cx, |workspace, cx| {
521 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
522 assert_eq!(editor.read(cx).text(cx), "ab")
523 });
524
525 // Add namespace filter, and redeploy the palette
526 cx.update(|cx| {
527 CommandPaletteFilter::update_global(cx, |filter, _| {
528 filter.hide_namespace("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}