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