slash_command_picker.rs

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