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