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 an 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 cx.window_context()
321 .spawn(move |mut cx| async move { cx.update(|cx| cx.dispatch_action(action)) })
322 .detach_and_log_err(cx);
323 self.dismissed(cx);
324 }
325
326 fn render_match(
327 &self,
328 ix: usize,
329 selected: bool,
330 cx: &mut ViewContext<Picker<Self>>,
331 ) -> Option<Self::ListItem> {
332 let r#match = self.matches.get(ix)?;
333 let command = self.commands.get(r#match.candidate_id)?;
334 Some(
335 ListItem::new(ix)
336 .inset(true)
337 .spacing(ListItemSpacing::Sparse)
338 .selected(selected)
339 .child(
340 h_flex()
341 .w_full()
342 .justify_between()
343 .child(HighlightedLabel::new(
344 command.name.clone(),
345 r#match.positions.clone(),
346 ))
347 .children(KeyBinding::for_action_in(
348 &*command.action,
349 &self.previous_focus_handle,
350 cx,
351 )),
352 ),
353 )
354 }
355}
356
357fn humanize_action_name(name: &str) -> String {
358 let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
359 let mut result = String::with_capacity(capacity);
360 for char in name.chars() {
361 if char == ':' {
362 if result.ends_with(':') {
363 result.push(' ');
364 } else {
365 result.push(':');
366 }
367 } else if char == '_' {
368 result.push(' ');
369 } else if char.is_uppercase() {
370 if !result.ends_with(' ') {
371 result.push(' ');
372 }
373 result.extend(char.to_lowercase());
374 } else {
375 result.push(char);
376 }
377 }
378 result
379}
380
381impl std::fmt::Debug for Command {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("Command")
384 .field("name", &self.name)
385 .finish_non_exhaustive()
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use std::sync::Arc;
392
393 use super::*;
394 use editor::Editor;
395 use go_to_line::GoToLine;
396 use gpui::TestAppContext;
397 use language::Point;
398 use project::Project;
399 use settings::KeymapFile;
400 use workspace::{AppState, Workspace};
401
402 #[test]
403 fn test_humanize_action_name() {
404 assert_eq!(
405 humanize_action_name("editor::GoToDefinition"),
406 "editor: go to definition"
407 );
408 assert_eq!(
409 humanize_action_name("editor::Backspace"),
410 "editor: backspace"
411 );
412 assert_eq!(
413 humanize_action_name("go_to_line::Deploy"),
414 "go to line: deploy"
415 );
416 }
417
418 #[gpui::test]
419 async fn test_command_palette(cx: &mut TestAppContext) {
420 let app_state = init_test(cx);
421 let project = Project::test(app_state.fs.clone(), [], cx).await;
422 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
423
424 let editor = cx.new_view(|cx| {
425 let mut editor = Editor::single_line(cx);
426 editor.set_text("abc", cx);
427 editor
428 });
429
430 workspace.update(cx, |workspace, cx| {
431 workspace.add_item(Box::new(editor.clone()), cx);
432 editor.update(cx, |editor, cx| editor.focus(cx))
433 });
434
435 cx.simulate_keystrokes("cmd-shift-p");
436
437 let palette = workspace.update(cx, |workspace, cx| {
438 workspace
439 .active_modal::<CommandPalette>(cx)
440 .unwrap()
441 .read(cx)
442 .picker
443 .clone()
444 });
445
446 palette.update(cx, |palette, _| {
447 assert!(palette.delegate.commands.len() > 5);
448 let is_sorted =
449 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
450 assert!(is_sorted(&palette.delegate.commands));
451 });
452
453 cx.simulate_input("bcksp");
454
455 palette.update(cx, |palette, _| {
456 assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
457 });
458
459 cx.simulate_keystrokes("enter");
460
461 workspace.update(cx, |workspace, cx| {
462 assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
463 assert_eq!(editor.read(cx).text(cx), "ab")
464 });
465
466 // Add namespace filter, and redeploy the palette
467 cx.update(|cx| {
468 cx.set_global(CommandPaletteFilter::default());
469 cx.update_global::<CommandPaletteFilter, _>(|filter, _| {
470 filter.hidden_namespaces.insert("editor");
471 })
472 });
473
474 cx.simulate_keystrokes("cmd-shift-p");
475 cx.simulate_input("bcksp");
476
477 let palette = workspace.update(cx, |workspace, cx| {
478 workspace
479 .active_modal::<CommandPalette>(cx)
480 .unwrap()
481 .read(cx)
482 .picker
483 .clone()
484 });
485 palette.update(cx, |palette, _| {
486 assert!(palette.delegate.matches.is_empty())
487 });
488 }
489
490 #[gpui::test]
491 async fn test_go_to_line(cx: &mut TestAppContext) {
492 let app_state = init_test(cx);
493 let project = Project::test(app_state.fs.clone(), [], cx).await;
494 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
495
496 cx.simulate_keystrokes("cmd-n");
497
498 let editor = workspace.update(cx, |workspace, cx| {
499 workspace.active_item_as::<Editor>(cx).unwrap()
500 });
501 editor.update(cx, |editor, cx| editor.set_text("1\n2\n3\n4\n5\n6\n", cx));
502
503 cx.simulate_keystrokes("cmd-shift-p");
504 cx.simulate_input("go to line: Toggle");
505 cx.simulate_keystrokes("enter");
506
507 workspace.update(cx, |workspace, cx| {
508 assert!(workspace.active_modal::<GoToLine>(cx).is_some())
509 });
510
511 cx.simulate_keystrokes("3 enter");
512
513 editor.update(cx, |editor, cx| {
514 assert!(editor.focus_handle(cx).is_focused(cx));
515 assert_eq!(
516 editor.selections.last::<Point>(cx).range().start,
517 Point::new(2, 0)
518 );
519 });
520 }
521
522 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
523 cx.update(|cx| {
524 let app_state = AppState::test(cx);
525 theme::init(theme::LoadThemes::JustBase, cx);
526 language::init(cx);
527 editor::init(cx);
528 menu::init();
529 go_to_line::init(cx);
530 workspace::init(app_state.clone(), cx);
531 init(cx);
532 Project::init_settings(cx);
533 KeymapFile::parse(
534 r#"[
535 {
536 "bindings": {
537 "cmd-n": "workspace::NewFile",
538 "enter": "menu::Confirm",
539 "cmd-shift-p": "command_palette::Toggle"
540 }
541 }
542 ]"#,
543 )
544 .unwrap()
545 .add_to_cx(cx)
546 .unwrap();
547 app_state
548 })
549 }
550}