use std::sync::Arc; use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView}; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; use crate::assistant_panel::ContextEditor; use crate::SlashCommandWorkingSet; #[derive(IntoElement)] pub(super) struct SlashCommandSelector { working_set: Arc, active_context_editor: WeakView, trigger: T, } #[derive(Clone)] struct SlashCommandInfo { name: SharedString, description: SharedString, args: Option, icon: IconName, } #[derive(Clone)] enum SlashCommandEntry { Info(SlashCommandInfo), Advert { name: SharedString, renderer: fn(&mut WindowContext<'_>) -> AnyElement, on_confirm: fn(&mut WindowContext<'_>), }, } impl AsRef for SlashCommandEntry { fn as_ref(&self) -> &str { match self { SlashCommandEntry::Info(SlashCommandInfo { name, .. }) | SlashCommandEntry::Advert { name, .. } => name, } } } pub(crate) struct SlashCommandDelegate { all_commands: Vec, filtered_commands: Vec, active_context_editor: WeakView, selected_index: usize, } impl SlashCommandSelector { pub(crate) fn new( working_set: Arc, active_context_editor: WeakView, trigger: T, ) -> Self { SlashCommandSelector { working_set, active_context_editor, trigger, } } } impl PickerDelegate for SlashCommandDelegate { type ListItem = ListItem; fn match_count(&self) -> usize { self.filtered_commands.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { self.selected_index = ix.min(self.filtered_commands.len().saturating_sub(1)); cx.notify(); } fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { "Select a command...".into() } fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { let all_commands = self.all_commands.clone(); cx.spawn(|this, mut cx| async move { let filtered_commands = cx .background_executor() .spawn(async move { if query.is_empty() { all_commands } else { all_commands .into_iter() .filter(|model_info| { model_info .as_ref() .to_lowercase() .contains(&query.to_lowercase()) }) .collect() } }) .await; this.update(&mut cx, |this, cx| { this.delegate.filtered_commands = filtered_commands; this.delegate.set_selected_index(0, cx); cx.notify(); }) .ok(); }) } fn separators_after_indices(&self) -> Vec { let mut ret = vec![]; let mut previous_is_advert = false; for (index, command) in self.filtered_commands.iter().enumerate() { if previous_is_advert { if let SlashCommandEntry::Info(_) = command { previous_is_advert = false; debug_assert_ne!( index, 0, "index cannot be zero, as we can never have a separator at 0th position" ); ret.push(index - 1); } } else { if let SlashCommandEntry::Advert { .. } = command { previous_is_advert = true; if index != 0 { ret.push(index - 1); } } } } ret } fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { if let Some(command) = self.filtered_commands.get(self.selected_index) { match command { SlashCommandEntry::Info(info) => { self.active_context_editor .update(cx, |context_editor, cx| { context_editor.insert_command(&info.name, cx) }) .ok(); } SlashCommandEntry::Advert { on_confirm, .. } => { on_confirm(cx); } } cx.emit(DismissEvent); } } fn dismissed(&mut self, _cx: &mut ViewContext>) {} fn editor_position(&self) -> PickerEditorPosition { PickerEditorPosition::End } fn render_match( &self, ix: usize, selected: bool, cx: &mut ViewContext>, ) -> Option { let command_info = self.filtered_commands.get(ix)?; match command_info { SlashCommandEntry::Info(info) => Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Dense) .toggle_state(selected) .tooltip({ let description = info.description.clone(); move |cx| cx.new_view(|_| Tooltip::new(description.clone())).into() }) .child( v_flex() .group(format!("command-entry-label-{ix}")) .w_full() .py_0p5() .min_w(px(250.)) .max_w(px(400.)) .child( h_flex() .gap_1p5() .child(Icon::new(info.icon).size(IconSize::XSmall)) .child(div().font_buffer(cx).child({ let mut label = format!("{}", info.name); if let Some(args) = info.args.as_ref().filter(|_| selected) { label.push_str(&args); } Label::new(label).single_line().size(LabelSize::Small) })) .children(info.args.clone().filter(|_| !selected).map( |args| { div() .font_buffer(cx) .child( Label::new(args) .single_line() .size(LabelSize::Small) .color(Color::Muted), ) .visible_on_hover(format!( "command-entry-label-{ix}" )) }, )), ) .child( div().overflow_hidden().text_ellipsis().child( Label::new(info.description.clone()) .size(LabelSize::Small) .color(Color::Muted), ), ), ), ), SlashCommandEntry::Advert { renderer, .. } => Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Dense) .toggle_state(selected) .child(renderer(cx)), ), } } } impl RenderOnce for SlashCommandSelector { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let all_models = self .working_set .featured_command_names(cx) .into_iter() .filter_map(|command_name| { let command = self.working_set.command(&command_name, cx)?; let menu_text = SharedString::from(Arc::from(command.menu_text())); let label = command.label(cx); let args = label.filter_range.end.ne(&label.text.len()).then(|| { SharedString::from( label.text[label.filter_range.end..label.text.len()].to_owned(), ) }); Some(SlashCommandEntry::Info(SlashCommandInfo { name: command_name.into(), description: menu_text, args, icon: command.icon(), })) }) .chain([SlashCommandEntry::Advert { name: "create-your-command".into(), renderer: |cx| { v_flex() .w_full() .child( h_flex() .w_full() .font_buffer(cx) .items_center() .justify_between() .child( h_flex() .items_center() .gap_1p5() .child(Icon::new(IconName::Plus).size(IconSize::XSmall)) .child( div().font_buffer(cx).child( Label::new("create-your-command") .size(LabelSize::Small), ), ), ) .child( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) .color(Color::Muted), ), ) .child( Label::new("Create your custom command") .size(LabelSize::Small) .color(Color::Muted), ) .into_any_element() }, on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"), }]) .collect::>(); let delegate = SlashCommandDelegate { all_commands: all_models.clone(), active_context_editor: self.active_context_editor.clone(), filtered_commands: all_models, selected_index: 0, }; let picker_view = cx.new_view(|cx| { let picker = Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into())); picker }); let handle = self .active_context_editor .update(cx, |this, _| this.slash_menu_handle.clone()) .ok(); PopoverMenu::new("model-switcher") .menu(move |_cx| Some(picker_view.clone())) .trigger(self.trigger) .attach(gpui::AnchorCorner::TopLeft) .anchor(gpui::AnchorCorner::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(-16.0), }) .when_some(handle, |this, handle| this.with_handle(handle)) } }