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