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