ZIm/crates/assistant_context_editor/src/slash_command_picker.rs
Nate Butler 16ab8701a2
git_ui: Prevent button overflow due to long names (#25940)
- Fix component preview widths for git panel
- Fix buttons getting pushed off the screen in git  panel

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-03-03 18:38:15 +00:00

354 lines
13 KiB
Rust

use std::sync::Arc;
use assistant_slash_command::SlashCommandWorkingSet;
use gpui::{AnyElement, AnyView, DismissEvent, SharedString, Task, WeakEntity};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip};
use crate::context_editor::ContextEditor;
#[derive(IntoElement)]
pub(super) struct SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
working_set: Arc<SlashCommandWorkingSet>,
active_context_editor: WeakEntity<ContextEditor>,
trigger: T,
tooltip: TT,
}
#[derive(Clone)]
struct SlashCommandInfo {
name: SharedString,
description: SharedString,
args: Option<SharedString>,
icon: IconName,
}
#[derive(Clone)]
enum SlashCommandEntry {
Info(SlashCommandInfo),
Advert {
name: SharedString,
renderer: fn(&mut Window, &mut App) -> AnyElement,
on_confirm: fn(&mut Window, &mut App),
},
}
impl AsRef<str> for SlashCommandEntry {
fn as_ref(&self) -> &str {
match self {
SlashCommandEntry::Info(SlashCommandInfo { name, .. })
| SlashCommandEntry::Advert { name, .. } => name,
}
}
}
pub(crate) struct SlashCommandDelegate {
all_commands: Vec<SlashCommandEntry>,
filtered_commands: Vec<SlashCommandEntry>,
active_context_editor: WeakEntity<ContextEditor>,
selected_index: usize,
}
impl<T, TT> SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub(crate) fn new(
working_set: Arc<SlashCommandWorkingSet>,
active_context_editor: WeakEntity<ContextEditor>,
trigger: T,
tooltip: TT,
) -> Self {
SlashCommandSelector {
working_set,
active_context_editor,
trigger,
tooltip,
}
}
}
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, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = ix.min(self.filtered_commands.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a command...".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let all_commands = self.all_commands.clone();
cx.spawn_in(window, |this, mut cx| async move {
let filtered_commands = cx
.background_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_in(&mut cx, |this, window, cx| {
this.delegate.filtered_commands = filtered_commands;
this.delegate.set_selected_index(0, window, cx);
cx.notify();
})
.ok();
})
}
fn separators_after_indices(&self) -> Vec<usize> {
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, window: &mut Window, cx: &mut Context<Picker<Self>>) {
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, window, cx)
})
.ok();
}
SlashCommandEntry::Advert { on_confirm, .. } => {
on_confirm(window, cx);
}
}
cx.emit(DismissEvent);
}
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn editor_position(&self) -> PickerEditorPosition {
PickerEditorPosition::End
}
fn render_match(
&self,
ix: usize,
selected: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
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(|_| 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)
.color(Color::Muted),
)
.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)
.buffer_font(cx)
})
.children(info.args.clone().filter(|_| !selected).map(
|args| {
div()
.child(
Label::new(args)
.single_line()
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.visible_on_hover(format!(
"command-entry-label-{ix}"
))
},
)),
)
.child(
Label::new(info.description.clone())
.size(LabelSize::Small)
.color(Color::Muted)
.truncate(),
),
),
),
SlashCommandEntry::Advert { renderer, .. } => Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Dense)
.toggle_state(selected)
.child(renderer(window, cx)),
),
}
}
}
impl<T, TT> RenderOnce for SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> 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(
Label::new("create-your-command")
.size(LabelSize::Small)
.buffer_font(cx),
),
)
.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::<Vec<_>>();
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(|cx| {
let picker =
Picker::uniform_list(delegate, window, 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 |_window, _cx| Some(picker_view.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.attach(gpui::Corner::TopLeft)
.anchor(gpui::Corner::BottomLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
.when_some(handle, |this, handle| this.with_handle(handle))
}
}