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