slash_command_picker.rs

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