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