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