slash_command_picker.rs

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