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