slash_command_picker.rs

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