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