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