slash_command_picker.rs

  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}