use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, radians,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{
ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale,
};
use crate::controls::button::Button;
use crate::display::badge::{Badge, Tone};
use crate::display::empty::{EmptyKind, EmptyState};
use crate::display::loading::PulseLoader;
use crate::display::status::{Callout, StatusDot};
use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
use std::f32::consts::FRAC_PI_2;
type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
type RetryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerState {
Connected,
Connecting,
Disconnected,
Failed {
reason: SharedString,
},
Disabled {
reason: Option<SharedString>,
},
}
impl ServerState {
pub fn name(&self) -> &'static str {
match self {
Self::Connected => "connected",
Self::Connecting => "connecting",
Self::Disconnected => "disconnected",
Self::Failed { .. } => "failed",
Self::Disabled { .. } => "disabled",
}
}
fn tone(&self) -> Tone {
match self {
Self::Connected => Tone::Success,
Self::Connecting => Tone::Info,
Self::Disconnected => Tone::Neutral,
Self::Failed { .. } => Tone::Danger,
Self::Disabled { .. } => Tone::Neutral,
}
}
fn label(&self, cx: &App) -> SharedString {
cx.strings().text(match self {
Self::Connected => StringKey::ServerConnected,
Self::Connecting => StringKey::ServerConnecting,
Self::Disconnected => StringKey::ServerDisconnected,
Self::Failed { .. } => StringKey::ServerFailed,
Self::Disabled { .. } => StringKey::ServerDisabled,
})
}
fn reason(&self) -> Option<&SharedString> {
match self {
Self::Failed { reason } => Some(reason),
Self::Disabled { reason } => reason.as_ref(),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum OfferingKind {
Tool,
Skill,
Resource,
}
impl OfferingKind {
pub fn name(self) -> &'static str {
match self {
Self::Tool => "tool",
Self::Skill => "skill",
Self::Resource => "resource",
}
}
fn heading(self, cx: &App) -> SharedString {
cx.strings().text(match self {
Self::Tool => StringKey::ServerTools,
Self::Skill => StringKey::ServerSkills,
Self::Resource => StringKey::ServerResources,
})
}
fn glyph(self) -> Icon {
match self {
Self::Tool => Icon::Tuning,
Self::Skill => Icon::Command,
Self::Resource => Icon::Document,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Offering {
id: SharedString,
kind: OfferingKind,
name: SharedString,
summary: Option<SharedString>,
qualifier: Option<SharedString>,
}
impl Offering {
pub fn new(
id: impl Into<SharedString>,
kind: OfferingKind,
name: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
kind,
name: name.into(),
summary: None,
qualifier: None,
}
}
pub fn tool(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self::new(id, OfferingKind::Tool, name)
}
pub fn skill(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self::new(id, OfferingKind::Skill, name)
}
pub fn resource(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self::new(id, OfferingKind::Resource, name)
}
pub fn summary(mut self, summary: impl Into<SharedString>) -> Self {
self.summary = Some(summary.into());
self
}
pub fn qualifier(mut self, qualifier: impl Into<SharedString>) -> Self {
self.qualifier = Some(qualifier.into());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn kind(&self) -> OfferingKind {
self.kind
}
pub fn name(&self) -> &SharedString {
&self.name
}
pub fn summary_text(&self) -> Option<&SharedString> {
self.summary.as_ref()
}
pub fn qualifier_text(&self) -> Option<&SharedString> {
self.qualifier.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Catalog {
#[default]
Unasked,
Asking,
Offers(Vec<Offering>),
Unavailable(SharedString),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerEntry {
id: SharedString,
name: SharedString,
detail: Option<SharedString>,
state: ServerState,
catalog: Catalog,
}
impl ServerEntry {
pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
detail: None,
state: ServerState::Disconnected,
catalog: Catalog::Unasked,
}
}
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn state(mut self, state: ServerState) -> Self {
self.state = state;
self
}
pub fn catalog(mut self, catalog: Catalog) -> Self {
self.catalog = catalog;
self
}
pub fn offers(self, offerings: impl IntoIterator<Item = Offering>) -> Self {
self.catalog(Catalog::Offers(offerings.into_iter().collect()))
}
pub fn id(&self) -> &SharedString {
&self.id
}
}
#[derive(IntoElement)]
pub struct ServerList {
ident: Ident,
servers: Vec<ServerEntry>,
expanded: Vec<SharedString>,
selected: Option<SharedString>,
size: ControlSize,
disabled: bool,
on_select: Option<SelectHandler>,
on_retry: Option<RetryHandler>,
on_toggle: Option<ToggleHandler>,
}
impl std::fmt::Debug for ServerList {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ServerList")
.field("ident", &self.ident)
.field("servers", &self.servers.len())
.field("expanded", &self.expanded)
.field("selected", &self.selected)
.field("disabled", &self.disabled)
.finish()
}
}
impl ServerList {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
servers: Vec::new(),
expanded: Vec::new(),
selected: None,
size: ControlSize::Md,
disabled: false,
on_select: None,
on_retry: None,
on_toggle: None,
}
}
pub fn server(mut self, server: ServerEntry) -> Self {
self.servers.push(server);
self
}
pub fn servers(mut self, servers: impl IntoIterator<Item = ServerEntry>) -> Self {
self.servers.extend(servers);
self
}
pub fn expanded(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
self.expanded = ids.into_iter().collect();
self
}
pub fn expanded_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
self.expanded = ids
.iter()
.map(|id| SharedString::from(id.as_ref().to_string()))
.collect();
self
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn on_retry(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
pub fn on_toggle(
mut self,
handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl Disableable for ServerList {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for ServerList {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for ServerList {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let count = self.servers.len();
let body: Vec<AnyElement> = if self.servers.is_empty() {
vec![
EmptyState::new(
self.ident.child("empty"),
cx.strings().text(StringKey::ServerEmpty),
)
.kind(EmptyKind::Empty)
.detail(cx.strings().text(StringKey::ServerEmptyDetail))
.into_any_element(),
]
} else {
self.servers
.iter()
.map(|server| self.server_element(server, &theme, cx))
.collect()
};
div()
.id(self.ident.element_id())
.column()
.w_full()
.gap_token(&theme, Space::Sm)
.children(body)
.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::List).value(count.to_string()),
)
}
}
impl ServerList {
fn server_element(&self, server: &ServerEntry, theme: &Theme, cx: &mut App) -> AnyElement {
let ident = self.ident.child(server.id.as_ref());
let metrics = theme.control.get(self.size);
let open = self.expanded.contains(&server.id);
let turned_off = matches!(server.state, ServerState::Disabled { .. });
let refused = self.disabled || turned_off;
let selected = self.selected.as_ref() == Some(&server.id);
let selectable = !refused && self.on_select.is_some();
let toggleable = !refused && self.on_toggle.is_some();
let chevron = {
let toggle = ident.child("toggle");
let mut glyph = div()
.id(toggle.element_id())
.row()
.flex_none()
.size(px(metrics.icon_size))
.child(
icon(Icon::AltArrowRight)
.size(px(metrics.icon_size))
.text_color(theme.colors.text_muted)
.when(open, |glyph| {
glyph.with_transformation(gpui::Transformation::rotate(radians(
FRAC_PI_2,
)))
}),
)
.when(toggleable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.focus_ring(theme)
});
if let (true, Some(handler)) = (toggleable, self.on_toggle.clone()) {
let id = server.id.clone();
glyph = glyph.on_click(move |_, window, cx| {
handler(id.clone(), !open, window, cx);
cx.stop_propagation();
});
}
glyph.semantic_in(
cx,
NodeSpec::new(toggle.semantic_id(), Role::Button)
.parent(ident.semantic_id())
.text(server.name.clone())
.expanded(open)
.disabled(!toggleable),
)
};
let mut header = div()
.id(ident.element_id())
.row()
.w_full()
.gap_token(theme, Space::Sm)
.p_token(theme, Space::Sm)
.when(selected, |element| element.bg(theme.colors.selected))
.when(turned_off, |element| {
element.opacity(theme.opacity.disabled)
})
.when(selectable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.when(!selected, |element| {
element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
})
.focus_ring(theme)
})
.child(chevron)
.child(StatusDot::new(server.state.tone()))
.child(
div()
.flex_1()
.min_w_0()
.column()
.child(text(theme, TypeScale::Label, server.name.clone()))
.when_some(server.detail.clone(), |element, detail| {
element.child(
text(theme, TypeScale::Caption, detail)
.text_tone(theme, TextTone::Muted),
)
}),
)
.child(
Badge::new(server.state.label(cx))
.tone(server.state.tone())
.id(ident.child("state")),
);
if let (true, Some(handler)) = (selectable, self.on_select.clone()) {
let id = server.id.clone();
header = header.on_click(move |_, window, cx| handler(id.clone(), window, cx));
}
let header = header.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(self.ident.semantic_id())
.text(server.name.clone())
.value(server.state.name())
.selected(selected)
.disabled(refused)
.expanded(open),
);
let reason = server.state.reason().map(|reason| {
let node = ident.child("reason");
div()
.px_token(theme, Space::Sm)
.child(
Callout::new(
reason.clone(),
match server.state {
ServerState::Failed { .. } => Tone::Danger,
_ => Tone::Neutral,
},
)
.id(node.child("callout")),
)
.semantic_in(
cx,
NodeSpec::new(node.semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(reason.clone())
.value(server.state.name()),
)
});
let retry = self
.on_retry
.clone()
.filter(|_| matches!(server.state, ServerState::Failed { .. }))
.filter(|_| !refused)
.map(|handler| {
let id = server.id.clone();
div().px_token(theme, Space::Sm).child(
Button::new(ident.child("retry"))
.label(cx.strings().text(StringKey::TryAgain))
.secondary()
.control_size(ControlSize::Sm)
.on_click(move |window, cx| handler(id.clone(), window, cx)),
)
});
let offerings = open.then(|| self.offerings_element(server, &ident, theme, cx));
div()
.column()
.w_full()
.gap_token(theme, Space::Xs)
.radius(theme, Radius::Card)
.frame(theme, Surface::Panel, Elevation::Raised)
.child(header)
.children(reason)
.children(retry)
.children(offerings)
.into_any_element()
}
fn offerings_element(
&self,
server: &ServerEntry,
server_ident: &Ident,
theme: &Theme,
cx: &mut App,
) -> AnyElement {
let ident = server_ident.child("offerings");
match &server.catalog {
Catalog::Unasked => {
EmptyState::new(ident, cx.strings().text(StringKey::ServerOfferingsUnasked))
.kind(EmptyKind::Unstarted)
.detail(cx.strings().text(StringKey::ServerOfferingsUnaskedDetail))
.into_any_element()
}
Catalog::Asking => {
let label = cx.strings().text(StringKey::ServerOfferingsAsking);
div()
.p_token(theme, Space::Sm)
.child(PulseLoader::new(ident.child("loader")).label(label.clone()))
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Status)
.parent(server_ident.semantic_id())
.text(label)
.busy(true)
.value("asking"),
)
.into_any_element()
}
Catalog::Unavailable(reason) => EmptyState::new(
ident,
cx.strings().text(StringKey::ServerOfferingsUnavailable),
)
.kind(EmptyKind::Unavailable)
.detail(reason.clone())
.into_any_element(),
Catalog::Offers(offerings) if offerings.is_empty() => {
EmptyState::new(ident, cx.strings().text(StringKey::ServerOfferingsNone))
.kind(EmptyKind::Empty)
.detail(cx.strings().text(StringKey::ServerOfferingsNoneDetail))
.into_any_element()
}
Catalog::Offers(offerings) => {
let mut groups: Vec<AnyElement> = Vec::new();
for kind in [
OfferingKind::Tool,
OfferingKind::Skill,
OfferingKind::Resource,
] {
let members: Vec<&Offering> = offerings
.iter()
.filter(|offering| offering.kind == kind)
.collect();
if members.is_empty() {
continue;
}
let heading_ident = ident.child(kind.name());
let heading = kind.heading(cx);
groups.push(
div()
.column()
.w_full()
.child(
text(theme, TypeScale::Subtitle, heading.clone())
.px_token(theme, Space::Sm)
.py_token(theme, Space::Xs)
.text_tone(theme, TextTone::Faint)
.semantic_in(
cx,
NodeSpec::new(heading_ident.semantic_id(), Role::Heading)
.parent(ident.semantic_id())
.text(heading)
.value(members.len().to_string()),
),
)
.children(members.into_iter().map(|offering| {
self.offering_element(server, offering, server_ident, theme, cx)
}))
.into_any_element(),
);
}
div()
.column()
.w_full()
.children(groups)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::List)
.parent(server_ident.semantic_id())
.value(offerings.len().to_string()),
)
.into_any_element()
}
}
}
fn offering_element(
&self,
server: &ServerEntry,
offering: &Offering,
server_ident: &Ident,
theme: &Theme,
cx: &mut App,
) -> AnyElement {
let ident = server_ident.child("offering").child(offering.id.as_ref());
let metrics = theme.control.get(self.size);
let _ = server;
div()
.row()
.w_full()
.items_start()
.gap_token(theme, Space::Sm)
.px_token(theme, Space::Sm)
.py_token(theme, Space::Xs)
.child(
icon(offering.kind.glyph())
.size(px(metrics.icon_size))
.text_color(theme.colors.text_faint),
)
.child(
div()
.flex_1()
.min_w_0()
.column()
.child(text(theme, TypeScale::Label, offering.name.clone()))
.when_some(offering.summary.clone(), |element, summary| {
element.child(
text(theme, TypeScale::Body, summary).text_tone(theme, TextTone::Muted),
)
})
.when_some(offering.qualifier.clone(), |element, qualifier| {
element.child(
text(theme, TypeScale::Code, qualifier)
.text_tone(theme, TextTone::Faint)
.font_family(theme.typography.mono.clone()),
)
}),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(server_ident.child("offerings").semantic_id())
.text(offering.name.clone())
.value(offering.kind.name()),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_state_publishes_its_own_name() {
let names: Vec<&str> = [
ServerState::Connected,
ServerState::Connecting,
ServerState::Disconnected,
ServerState::Failed {
reason: SharedString::new_static("x"),
},
ServerState::Disabled { reason: None },
]
.iter()
.map(ServerState::name)
.collect();
let mut unique = names.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), names.len(), "two states share a name");
}
#[test]
fn an_empty_answer_is_not_an_unasked_question() {
assert_ne!(Catalog::Offers(Vec::new()), Catalog::Unasked);
assert_eq!(Catalog::default(), Catalog::Unasked);
}
#[test]
fn only_a_failure_and_a_stated_refusal_carry_a_reason() {
assert!(ServerState::Connected.reason().is_none());
assert!(ServerState::Disconnected.reason().is_none());
assert!(ServerState::Disabled { reason: None }.reason().is_none());
assert!(
ServerState::Failed {
reason: SharedString::new_static("no route")
}
.reason()
.is_some()
);
}
}