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