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) const 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 if let SlashCommandEntry::Advert { .. } = command {
144 previous_is_advert = true;
145 if index != 0 {
146 ret.push(index - 1);
147 }
148 }
149 }
150 ret
151 }
152
153 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
154 if let Some(command) = self.filtered_commands.get(self.selected_index) {
155 match command {
156 SlashCommandEntry::Info(info) => {
157 self.active_context_editor
158 .update(cx, |context_editor, cx| {
159 context_editor.insert_command(&info.name, window, cx)
160 })
161 .ok();
162 }
163 SlashCommandEntry::Advert { on_confirm, .. } => {
164 on_confirm(window, cx);
165 }
166 }
167 cx.emit(DismissEvent);
168 }
169 }
170
171 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
172
173 fn editor_position(&self) -> PickerEditorPosition {
174 PickerEditorPosition::End
175 }
176
177 fn render_match(
178 &self,
179 ix: usize,
180 selected: bool,
181 window: &mut Window,
182 cx: &mut Context<Picker<Self>>,
183 ) -> Option<Self::ListItem> {
184 let command_info = self.filtered_commands.get(ix)?;
185
186 match command_info {
187 SlashCommandEntry::Info(info) => Some(
188 ListItem::new(ix)
189 .inset(true)
190 .spacing(ListItemSpacing::Dense)
191 .toggle_state(selected)
192 .tooltip({
193 let description = info.description.clone();
194 move |_, cx| cx.new(|_| Tooltip::new(description.clone())).into()
195 })
196 .child(
197 v_flex()
198 .group(format!("command-entry-label-{ix}"))
199 .w_full()
200 .py_0p5()
201 .min_w(px(250.))
202 .max_w(px(400.))
203 .child(
204 h_flex()
205 .gap_1p5()
206 .child(
207 Icon::new(info.icon)
208 .size(IconSize::XSmall)
209 .color(Color::Muted),
210 )
211 .child({
212 let mut label = format!("{}", info.name);
213 if let Some(args) = info.args.as_ref().filter(|_| selected)
214 {
215 label.push_str(args);
216 }
217 Label::new(label)
218 .single_line()
219 .size(LabelSize::Small)
220 .buffer_font(cx)
221 })
222 .children(info.args.clone().filter(|_| !selected).map(
223 |args| {
224 div()
225 .child(
226 Label::new(args)
227 .single_line()
228 .size(LabelSize::Small)
229 .color(Color::Muted)
230 .buffer_font(cx),
231 )
232 .visible_on_hover(format!(
233 "command-entry-label-{ix}"
234 ))
235 },
236 )),
237 )
238 .child(
239 Label::new(info.description.clone())
240 .size(LabelSize::Small)
241 .color(Color::Muted)
242 .truncate(),
243 ),
244 ),
245 ),
246 SlashCommandEntry::Advert { renderer, .. } => Some(
247 ListItem::new(ix)
248 .inset(true)
249 .spacing(ListItemSpacing::Dense)
250 .toggle_state(selected)
251 .child(renderer(window, cx)),
252 ),
253 }
254 }
255}
256
257impl<T, TT> RenderOnce for SlashCommandSelector<T, TT>
258where
259 T: PopoverTrigger + ButtonCommon,
260 TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
261{
262 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
263 let all_models = self
264 .working_set
265 .featured_command_names(cx)
266 .into_iter()
267 .filter_map(|command_name| {
268 let command = self.working_set.command(&command_name, cx)?;
269 let menu_text = SharedString::from(Arc::from(command.menu_text()));
270 let label = command.label(cx);
271 let args = label.filter_range.end.ne(&label.text.len()).then(|| {
272 SharedString::from(
273 label.text[label.filter_range.end..label.text.len()].to_owned(),
274 )
275 });
276 Some(SlashCommandEntry::Info(SlashCommandInfo {
277 name: command_name.into(),
278 description: menu_text,
279 args,
280 icon: command.icon(),
281 }))
282 })
283 .chain([SlashCommandEntry::Advert {
284 name: "create-your-command".into(),
285 renderer: |_, cx| {
286 v_flex()
287 .w_full()
288 .child(
289 h_flex()
290 .w_full()
291 .font_buffer(cx)
292 .items_center()
293 .justify_between()
294 .child(
295 h_flex()
296 .items_center()
297 .gap_1p5()
298 .child(Icon::new(IconName::Plus).size(IconSize::XSmall))
299 .child(
300 Label::new("create-your-command")
301 .size(LabelSize::Small)
302 .buffer_font(cx),
303 ),
304 )
305 .child(
306 Icon::new(IconName::ArrowUpRight)
307 .size(IconSize::Small)
308 .color(Color::Muted),
309 ),
310 )
311 .child(
312 Label::new("Create your custom command")
313 .size(LabelSize::Small)
314 .color(Color::Muted),
315 )
316 .into_any_element()
317 },
318 on_confirm: |_, cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
319 }])
320 .collect::<Vec<_>>();
321
322 let delegate = SlashCommandDelegate {
323 all_commands: all_models.clone(),
324 active_context_editor: self.active_context_editor.clone(),
325 filtered_commands: all_models,
326 selected_index: 0,
327 };
328
329 let picker_view = cx.new(|cx| {
330 Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into()))
331 });
332
333 let handle = self
334 .active_context_editor
335 .read_with(cx, |this, _| this.slash_menu_handle.clone())
336 .ok();
337 PopoverMenu::new("model-switcher")
338 .menu(move |_window, _cx| Some(picker_view.clone()))
339 .trigger_with_tooltip(self.trigger, self.tooltip)
340 .attach(gpui::Corner::TopLeft)
341 .anchor(gpui::Corner::BottomLeft)
342 .offset(gpui::Point {
343 x: px(0.0),
344 y: px(-2.0),
345 })
346 .when_some(handle, |this, handle| this.with_handle(handle))
347 }
348}