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}