slash_command_picker.rs

  1use std::sync::Arc;
  2
  3use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView};
  4use picker::{Picker, PickerDelegate, PickerEditorPosition};
  5use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger};
  6
  7use crate::assistant_panel::ContextEditor;
  8use crate::SlashCommandWorkingSet;
  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                    .selected(selected)
180                    .child(
181                        v_flex()
182                            .group(format!("command-entry-label-{ix}"))
183                            .w_full()
184                            .min_w(px(250.))
185                            .child(
186                                h_flex()
187                                    .gap_1p5()
188                                    .child(Icon::new(info.icon).size(IconSize::XSmall))
189                                    .child(div().font_buffer(cx).child({
190                                        let mut label = format!("{}", info.name);
191                                        if let Some(args) = info.args.as_ref().filter(|_| selected)
192                                        {
193                                            label.push_str(&args);
194                                        }
195                                        Label::new(label).size(LabelSize::Small)
196                                    }))
197                                    .children(info.args.clone().filter(|_| !selected).map(
198                                        |args| {
199                                            div()
200                                                .font_buffer(cx)
201                                                .child(
202                                                    Label::new(args)
203                                                        .size(LabelSize::Small)
204                                                        .color(Color::Muted),
205                                                )
206                                                .visible_on_hover(format!(
207                                                    "command-entry-label-{ix}"
208                                                ))
209                                        },
210                                    )),
211                            )
212                            .child(
213                                Label::new(info.description.clone())
214                                    .size(LabelSize::Small)
215                                    .color(Color::Muted),
216                            ),
217                    ),
218            ),
219            SlashCommandEntry::Advert { renderer, .. } => Some(
220                ListItem::new(ix)
221                    .inset(true)
222                    .spacing(ListItemSpacing::Dense)
223                    .selected(selected)
224                    .child(renderer(cx)),
225            ),
226        }
227    }
228}
229
230impl<T: PopoverTrigger> RenderOnce for SlashCommandSelector<T> {
231    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
232        let all_models = self
233            .working_set
234            .featured_command_names(cx)
235            .into_iter()
236            .filter_map(|command_name| {
237                let command = self.working_set.command(&command_name, cx)?;
238                let menu_text = SharedString::from(Arc::from(command.menu_text()));
239                let label = command.label(cx);
240                let args = label.filter_range.end.ne(&label.text.len()).then(|| {
241                    SharedString::from(
242                        label.text[label.filter_range.end..label.text.len()].to_owned(),
243                    )
244                });
245                Some(SlashCommandEntry::Info(SlashCommandInfo {
246                    name: command_name.into(),
247                    description: menu_text,
248                    args,
249                    icon: command.icon(),
250                }))
251            })
252            .chain([SlashCommandEntry::Advert {
253                name: "create-your-command".into(),
254                renderer: |cx| {
255                    v_flex()
256                        .w_full()
257                        .child(
258                            h_flex()
259                                .w_full()
260                                .font_buffer(cx)
261                                .items_center()
262                                .justify_between()
263                                .child(
264                                    h_flex()
265                                        .items_center()
266                                        .gap_1p5()
267                                        .child(Icon::new(IconName::Plus).size(IconSize::XSmall))
268                                        .child(
269                                            div().font_buffer(cx).child(
270                                                Label::new("create-your-command")
271                                                    .size(LabelSize::Small),
272                                            ),
273                                        ),
274                                )
275                                .child(
276                                    Icon::new(IconName::ArrowUpRight)
277                                        .size(IconSize::XSmall)
278                                        .color(Color::Muted),
279                                ),
280                        )
281                        .child(
282                            Label::new("Create your custom command")
283                                .size(LabelSize::Small)
284                                .color(Color::Muted),
285                        )
286                        .into_any_element()
287                },
288                on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
289            }])
290            .collect::<Vec<_>>();
291
292        let delegate = SlashCommandDelegate {
293            all_commands: all_models.clone(),
294            active_context_editor: self.active_context_editor.clone(),
295            filtered_commands: all_models,
296            selected_index: 0,
297        };
298
299        let picker_view = cx.new_view(|cx| {
300            let picker = Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into()));
301            picker
302        });
303
304        let handle = self
305            .active_context_editor
306            .update(cx, |this, _| this.slash_menu_handle.clone())
307            .ok();
308        PopoverMenu::new("model-switcher")
309            .menu(move |_cx| Some(picker_view.clone()))
310            .trigger(self.trigger)
311            .attach(gpui::AnchorCorner::TopLeft)
312            .anchor(gpui::AnchorCorner::BottomLeft)
313            .offset(gpui::Point {
314                x: px(0.0),
315                y: px(-16.0),
316            })
317            .when_some(handle, |this, handle| this.with_handle(handle))
318    }
319}