slash_command_picker.rs

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