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