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