1use std::sync::Arc;
2
3use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView};
4use picker::{Picker, PickerDelegate, PickerEditorPosition};
5use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip};
6
7use crate::assistant_panel::ContextEditor;
8use crate::SlashCommandWorkingSet;
9
10#[derive(IntoElement)]
11pub(super) struct SlashCommandSelector<T: PopoverTrigger> {
12 working_set: Arc<SlashCommandWorkingSet>,
13 active_context_editor: WeakView<ContextEditor>,
14 trigger: T,
15}
16
17#[derive(Clone)]
18struct SlashCommandInfo {
19 name: SharedString,
20 description: SharedString,
21 args: Option<SharedString>,
22 icon: IconName,
23}
24
25#[derive(Clone)]
26enum SlashCommandEntry {
27 Info(SlashCommandInfo),
28 Advert {
29 name: SharedString,
30 renderer: fn(&mut WindowContext<'_>) -> AnyElement,
31 on_confirm: fn(&mut WindowContext<'_>),
32 },
33}
34
35impl AsRef<str> for SlashCommandEntry {
36 fn as_ref(&self) -> &str {
37 match self {
38 SlashCommandEntry::Info(SlashCommandInfo { name, .. })
39 | SlashCommandEntry::Advert { name, .. } => name,
40 }
41 }
42}
43
44pub(crate) struct SlashCommandDelegate {
45 all_commands: Vec<SlashCommandEntry>,
46 filtered_commands: Vec<SlashCommandEntry>,
47 active_context_editor: WeakView<ContextEditor>,
48 selected_index: usize,
49}
50
51impl<T: PopoverTrigger> SlashCommandSelector<T> {
52 pub(crate) fn new(
53 working_set: Arc<SlashCommandWorkingSet>,
54 active_context_editor: WeakView<ContextEditor>,
55 trigger: T,
56 ) -> Self {
57 SlashCommandSelector {
58 working_set,
59 active_context_editor,
60 trigger,
61 }
62 }
63}
64
65impl PickerDelegate for SlashCommandDelegate {
66 type ListItem = ListItem;
67
68 fn match_count(&self) -> usize {
69 self.filtered_commands.len()
70 }
71
72 fn selected_index(&self) -> usize {
73 self.selected_index
74 }
75
76 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
77 self.selected_index = ix.min(self.filtered_commands.len().saturating_sub(1));
78 cx.notify();
79 }
80
81 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
82 "Select a command...".into()
83 }
84
85 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
86 let all_commands = self.all_commands.clone();
87 cx.spawn(|this, mut cx| async move {
88 let filtered_commands = cx
89 .background_executor()
90 .spawn(async move {
91 if query.is_empty() {
92 all_commands
93 } else {
94 all_commands
95 .into_iter()
96 .filter(|model_info| {
97 model_info
98 .as_ref()
99 .to_lowercase()
100 .contains(&query.to_lowercase())
101 })
102 .collect()
103 }
104 })
105 .await;
106
107 this.update(&mut cx, |this, cx| {
108 this.delegate.filtered_commands = filtered_commands;
109 this.delegate.set_selected_index(0, cx);
110 cx.notify();
111 })
112 .ok();
113 })
114 }
115
116 fn separators_after_indices(&self) -> Vec<usize> {
117 let mut ret = vec![];
118 let mut previous_is_advert = false;
119
120 for (index, command) in self.filtered_commands.iter().enumerate() {
121 if previous_is_advert {
122 if let SlashCommandEntry::Info(_) = command {
123 previous_is_advert = false;
124 debug_assert_ne!(
125 index, 0,
126 "index cannot be zero, as we can never have a separator at 0th position"
127 );
128 ret.push(index - 1);
129 }
130 } else {
131 if let SlashCommandEntry::Advert { .. } = command {
132 previous_is_advert = true;
133 if index != 0 {
134 ret.push(index - 1);
135 }
136 }
137 }
138 }
139 ret
140 }
141
142 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
143 if let Some(command) = self.filtered_commands.get(self.selected_index) {
144 match command {
145 SlashCommandEntry::Info(info) => {
146 self.active_context_editor
147 .update(cx, |context_editor, cx| {
148 context_editor.insert_command(&info.name, cx)
149 })
150 .ok();
151 }
152 SlashCommandEntry::Advert { on_confirm, .. } => {
153 on_confirm(cx);
154 }
155 }
156 cx.emit(DismissEvent);
157 }
158 }
159
160 fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
161
162 fn editor_position(&self) -> PickerEditorPosition {
163 PickerEditorPosition::End
164 }
165
166 fn render_match(
167 &self,
168 ix: usize,
169 selected: bool,
170 cx: &mut ViewContext<Picker<Self>>,
171 ) -> Option<Self::ListItem> {
172 let command_info = self.filtered_commands.get(ix)?;
173
174 match command_info {
175 SlashCommandEntry::Info(info) => Some(
176 ListItem::new(ix)
177 .inset(true)
178 .spacing(ListItemSpacing::Dense)
179 .selected(selected)
180 .tooltip({
181 let description = info.description.clone();
182 move |cx| cx.new_view(|_| Tooltip::new(description.clone())).into()
183 })
184 .child(
185 v_flex()
186 .group(format!("command-entry-label-{ix}"))
187 .w_full()
188 .py_0p5()
189 .min_w(px(250.))
190 .max_w(px(400.))
191 .child(
192 h_flex()
193 .gap_1p5()
194 .child(Icon::new(info.icon).size(IconSize::XSmall))
195 .child(div().font_buffer(cx).child({
196 let mut label = format!("{}", info.name);
197 if let Some(args) = info.args.as_ref().filter(|_| selected)
198 {
199 label.push_str(&args);
200 }
201 Label::new(label).single_line().size(LabelSize::Small)
202 }))
203 .children(info.args.clone().filter(|_| !selected).map(
204 |args| {
205 div()
206 .font_buffer(cx)
207 .child(
208 Label::new(args)
209 .single_line()
210 .size(LabelSize::Small)
211 .color(Color::Muted),
212 )
213 .visible_on_hover(format!(
214 "command-entry-label-{ix}"
215 ))
216 },
217 )),
218 )
219 .child(
220 div().overflow_hidden().text_ellipsis().child(
221 Label::new(info.description.clone())
222 .size(LabelSize::Small)
223 .color(Color::Muted),
224 ),
225 ),
226 ),
227 ),
228 SlashCommandEntry::Advert { renderer, .. } => Some(
229 ListItem::new(ix)
230 .inset(true)
231 .spacing(ListItemSpacing::Dense)
232 .selected(selected)
233 .child(renderer(cx)),
234 ),
235 }
236 }
237}
238
239impl<T: PopoverTrigger> RenderOnce for SlashCommandSelector<T> {
240 fn render(self, cx: &mut WindowContext) -> impl IntoElement {
241 let all_models = self
242 .working_set
243 .featured_command_names(cx)
244 .into_iter()
245 .filter_map(|command_name| {
246 let command = self.working_set.command(&command_name, cx)?;
247 let menu_text = SharedString::from(Arc::from(command.menu_text()));
248 let label = command.label(cx);
249 let args = label.filter_range.end.ne(&label.text.len()).then(|| {
250 SharedString::from(
251 label.text[label.filter_range.end..label.text.len()].to_owned(),
252 )
253 });
254 Some(SlashCommandEntry::Info(SlashCommandInfo {
255 name: command_name.into(),
256 description: menu_text,
257 args,
258 icon: command.icon(),
259 }))
260 })
261 .chain([SlashCommandEntry::Advert {
262 name: "create-your-command".into(),
263 renderer: |cx| {
264 v_flex()
265 .w_full()
266 .child(
267 h_flex()
268 .w_full()
269 .font_buffer(cx)
270 .items_center()
271 .justify_between()
272 .child(
273 h_flex()
274 .items_center()
275 .gap_1p5()
276 .child(Icon::new(IconName::Plus).size(IconSize::XSmall))
277 .child(
278 div().font_buffer(cx).child(
279 Label::new("create-your-command")
280 .size(LabelSize::Small),
281 ),
282 ),
283 )
284 .child(
285 Icon::new(IconName::ArrowUpRight)
286 .size(IconSize::XSmall)
287 .color(Color::Muted),
288 ),
289 )
290 .child(
291 Label::new("Create your custom command")
292 .size(LabelSize::Small)
293 .color(Color::Muted),
294 )
295 .into_any_element()
296 },
297 on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
298 }])
299 .collect::<Vec<_>>();
300
301 let delegate = SlashCommandDelegate {
302 all_commands: all_models.clone(),
303 active_context_editor: self.active_context_editor.clone(),
304 filtered_commands: all_models,
305 selected_index: 0,
306 };
307
308 let picker_view = cx.new_view(|cx| {
309 let picker = Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into()));
310 picker
311 });
312
313 let handle = self
314 .active_context_editor
315 .update(cx, |this, _| this.slash_menu_handle.clone())
316 .ok();
317 PopoverMenu::new("model-switcher")
318 .menu(move |_cx| Some(picker_view.clone()))
319 .trigger(self.trigger)
320 .attach(gpui::AnchorCorner::TopLeft)
321 .anchor(gpui::AnchorCorner::BottomLeft)
322 .offset(gpui::Point {
323 x: px(0.0),
324 y: px(-16.0),
325 })
326 .when_some(handle, |this, handle| this.with_handle(handle))
327 }
328}