1use std::rc::Rc;
29
30use gpui::{
31 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
32 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, radians,
33};
34use gpui_kit_assets::{Icon, icon};
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{
37 ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale,
38};
39
40use crate::controls::button::Button;
41use crate::display::badge::{Badge, Tone};
42use crate::display::empty::{EmptyKind, EmptyState};
43use crate::display::loading::PulseLoader;
44use crate::display::status::{Callout, StatusDot};
45use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
46use crate::strings::{ActiveStrings, StringKey};
47
48use std::f32::consts::FRAC_PI_2;
49
50type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
51type RetryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
52type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ServerState {
57 Connected,
58 Connecting,
59 Disconnected,
61 Failed {
64 reason: SharedString,
65 },
66 Disabled {
69 reason: Option<SharedString>,
70 },
71}
72
73impl ServerState {
74 pub fn name(&self) -> &'static str {
76 match self {
77 Self::Connected => "connected",
78 Self::Connecting => "connecting",
79 Self::Disconnected => "disconnected",
80 Self::Failed { .. } => "failed",
81 Self::Disabled { .. } => "disabled",
82 }
83 }
84
85 fn tone(&self) -> Tone {
86 match self {
87 Self::Connected => Tone::Success,
88 Self::Connecting => Tone::Info,
89 Self::Disconnected => Tone::Neutral,
90 Self::Failed { .. } => Tone::Danger,
91 Self::Disabled { .. } => Tone::Neutral,
92 }
93 }
94
95 fn label(&self, cx: &App) -> SharedString {
96 cx.strings().text(match self {
97 Self::Connected => StringKey::ServerConnected,
98 Self::Connecting => StringKey::ServerConnecting,
99 Self::Disconnected => StringKey::ServerDisconnected,
100 Self::Failed { .. } => StringKey::ServerFailed,
101 Self::Disabled { .. } => StringKey::ServerDisabled,
102 })
103 }
104
105 fn reason(&self) -> Option<&SharedString> {
108 match self {
109 Self::Failed { reason } => Some(reason),
110 Self::Disabled { reason } => reason.as_ref(),
111 _ => None,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
118pub enum OfferingKind {
119 Tool,
121 Skill,
123 Resource,
125}
126
127impl OfferingKind {
128 pub fn name(self) -> &'static str {
130 match self {
131 Self::Tool => "tool",
132 Self::Skill => "skill",
133 Self::Resource => "resource",
134 }
135 }
136
137 fn heading(self, cx: &App) -> SharedString {
138 cx.strings().text(match self {
139 Self::Tool => StringKey::ServerTools,
140 Self::Skill => StringKey::ServerSkills,
141 Self::Resource => StringKey::ServerResources,
142 })
143 }
144
145 fn glyph(self) -> Icon {
146 match self {
147 Self::Tool => Icon::Tuning,
148 Self::Skill => Icon::Command,
149 Self::Resource => Icon::Document,
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Offering {
157 id: SharedString,
158 kind: OfferingKind,
159 name: SharedString,
160 summary: Option<SharedString>,
161 qualifier: Option<SharedString>,
164}
165
166impl Offering {
167 pub fn new(
168 id: impl Into<SharedString>,
169 kind: OfferingKind,
170 name: impl Into<SharedString>,
171 ) -> Self {
172 Self {
173 id: id.into(),
174 kind,
175 name: name.into(),
176 summary: None,
177 qualifier: None,
178 }
179 }
180
181 pub fn tool(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
182 Self::new(id, OfferingKind::Tool, name)
183 }
184
185 pub fn skill(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
186 Self::new(id, OfferingKind::Skill, name)
187 }
188
189 pub fn resource(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
190 Self::new(id, OfferingKind::Resource, name)
191 }
192
193 pub fn summary(mut self, summary: impl Into<SharedString>) -> Self {
194 self.summary = Some(summary.into());
195 self
196 }
197
198 pub fn qualifier(mut self, qualifier: impl Into<SharedString>) -> Self {
199 self.qualifier = Some(qualifier.into());
200 self
201 }
202
203 pub fn id(&self) -> &SharedString {
204 &self.id
205 }
206
207 pub fn kind(&self) -> OfferingKind {
208 self.kind
209 }
210
211 pub fn name(&self) -> &SharedString {
212 &self.name
213 }
214
215 pub fn summary_text(&self) -> Option<&SharedString> {
216 self.summary.as_ref()
217 }
218
219 pub fn qualifier_text(&self) -> Option<&SharedString> {
220 self.qualifier.as_ref()
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Default)]
231pub enum Catalog {
232 #[default]
234 Unasked,
235 Asking,
237 Offers(Vec<Offering>),
239 Unavailable(SharedString),
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct ServerEntry {
246 id: SharedString,
247 name: SharedString,
248 detail: Option<SharedString>,
249 state: ServerState,
250 catalog: Catalog,
251}
252
253impl ServerEntry {
254 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
255 Self {
256 id: id.into(),
257 name: name.into(),
258 detail: None,
259 state: ServerState::Disconnected,
260 catalog: Catalog::Unasked,
261 }
262 }
263
264 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
267 self.detail = Some(detail.into());
268 self
269 }
270
271 pub fn state(mut self, state: ServerState) -> Self {
272 self.state = state;
273 self
274 }
275
276 pub fn catalog(mut self, catalog: Catalog) -> Self {
277 self.catalog = catalog;
278 self
279 }
280
281 pub fn offers(self, offerings: impl IntoIterator<Item = Offering>) -> Self {
282 self.catalog(Catalog::Offers(offerings.into_iter().collect()))
283 }
284
285 pub fn id(&self) -> &SharedString {
286 &self.id
287 }
288}
289
290#[derive(IntoElement)]
292pub struct ServerList {
293 ident: Ident,
294 servers: Vec<ServerEntry>,
295 expanded: Vec<SharedString>,
296 selected: Option<SharedString>,
297 size: ControlSize,
298 disabled: bool,
299 on_select: Option<SelectHandler>,
300 on_retry: Option<RetryHandler>,
301 on_toggle: Option<ToggleHandler>,
302}
303
304impl std::fmt::Debug for ServerList {
305 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 formatter
307 .debug_struct("ServerList")
308 .field("ident", &self.ident)
309 .field("servers", &self.servers.len())
310 .field("expanded", &self.expanded)
311 .field("selected", &self.selected)
312 .field("disabled", &self.disabled)
313 .finish()
314 }
315}
316
317impl ServerList {
318 pub fn new(ident: impl Into<Ident>) -> Self {
319 Self {
320 ident: ident.into(),
321 servers: Vec::new(),
322 expanded: Vec::new(),
323 selected: None,
324 size: ControlSize::Md,
325 disabled: false,
326 on_select: None,
327 on_retry: None,
328 on_toggle: None,
329 }
330 }
331
332 pub fn server(mut self, server: ServerEntry) -> Self {
333 self.servers.push(server);
334 self
335 }
336
337 pub fn servers(mut self, servers: impl IntoIterator<Item = ServerEntry>) -> Self {
338 self.servers.extend(servers);
339 self
340 }
341
342 pub fn expanded(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
345 self.expanded = ids.into_iter().collect();
346 self
347 }
348
349 pub fn expanded_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
350 self.expanded = ids
351 .iter()
352 .map(|id| SharedString::from(id.as_ref().to_string()))
353 .collect();
354 self
355 }
356
357 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
358 self.selected = Some(id.into());
359 self
360 }
361
362 pub fn on_select(
363 mut self,
364 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
365 ) -> Self {
366 self.on_select = Some(Rc::new(handler));
367 self
368 }
369
370 pub fn on_retry(
373 mut self,
374 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
375 ) -> Self {
376 self.on_retry = Some(Rc::new(handler));
377 self
378 }
379
380 pub fn on_toggle(
382 mut self,
383 handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
384 ) -> Self {
385 self.on_toggle = Some(Rc::new(handler));
386 self
387 }
388}
389
390impl Disableable for ServerList {
391 fn disabled(mut self, disabled: bool) -> Self {
392 self.disabled = disabled;
393 self
394 }
395}
396
397impl Sizable for ServerList {
398 fn control_size(mut self, size: ControlSize) -> Self {
399 self.size = size;
400 self
401 }
402}
403
404impl RenderOnce for ServerList {
405 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
406 let theme = cx.theme().clone();
407 let count = self.servers.len();
408
409 let body: Vec<AnyElement> = if self.servers.is_empty() {
410 vec![
411 EmptyState::new(
412 self.ident.child("empty"),
413 cx.strings().text(StringKey::ServerEmpty),
414 )
415 .kind(EmptyKind::Empty)
416 .detail(cx.strings().text(StringKey::ServerEmptyDetail))
417 .into_any_element(),
418 ]
419 } else {
420 self.servers
421 .iter()
422 .map(|server| self.server_element(server, &theme, cx))
423 .collect()
424 };
425
426 div()
427 .id(self.ident.element_id())
428 .column()
429 .w_full()
430 .gap_token(&theme, Space::Sm)
431 .children(body)
432 .semantic_in(
433 cx,
434 NodeSpec::new(self.ident.semantic_id(), Role::List).value(count.to_string()),
435 )
436 }
437}
438
439impl ServerList {
440 fn server_element(&self, server: &ServerEntry, theme: &Theme, cx: &mut App) -> AnyElement {
441 let ident = self.ident.child(server.id.as_ref());
442 let metrics = theme.control.get(self.size);
443 let open = self.expanded.contains(&server.id);
444 let turned_off = matches!(server.state, ServerState::Disabled { .. });
445 let refused = self.disabled || turned_off;
448 let selected = self.selected.as_ref() == Some(&server.id);
449 let selectable = !refused && self.on_select.is_some();
450 let toggleable = !refused && self.on_toggle.is_some();
451
452 let chevron = {
453 let toggle = ident.child("toggle");
454 let mut glyph = div()
455 .id(toggle.element_id())
456 .row()
457 .flex_none()
458 .size(px(metrics.icon_size))
459 .child(
460 icon(Icon::AltArrowRight)
461 .size(px(metrics.icon_size))
462 .text_color(theme.colors.text_muted)
463 .when(open, |glyph| {
464 glyph.with_transformation(gpui::Transformation::rotate(radians(
465 FRAC_PI_2,
466 )))
467 }),
468 )
469 .when(toggleable, |element| {
470 element
471 .cursor_pointer()
472 .tab_index(0)
473 .pressable(cx)
474 .focus_ring(theme)
475 });
476 if let (true, Some(handler)) = (toggleable, self.on_toggle.clone()) {
477 let id = server.id.clone();
478 glyph = glyph.on_click(move |_, window, cx| {
479 handler(id.clone(), !open, window, cx);
480 cx.stop_propagation();
481 });
482 }
483 glyph.semantic_in(
484 cx,
485 NodeSpec::new(toggle.semantic_id(), Role::Button)
486 .parent(ident.semantic_id())
487 .text(server.name.clone())
488 .expanded(open)
489 .disabled(!toggleable),
490 )
491 };
492
493 let mut header = div()
494 .id(ident.element_id())
495 .row()
496 .w_full()
497 .gap_token(theme, Space::Sm)
498 .p_token(theme, Space::Sm)
499 .when(selected, |element| element.bg(theme.colors.selected))
500 .when(turned_off, |element| {
501 element.opacity(theme.opacity.disabled)
502 })
503 .when(selectable, |element| {
504 element
505 .cursor_pointer()
506 .tab_index(0)
507 .pressable(cx)
508 .when(!selected, |element| {
509 element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
510 })
511 .focus_ring(theme)
512 })
513 .child(chevron)
514 .child(StatusDot::new(server.state.tone()))
515 .child(
516 div()
517 .flex_1()
518 .min_w_0()
519 .column()
520 .child(text(theme, TypeScale::Label, server.name.clone()))
521 .when_some(server.detail.clone(), |element, detail| {
522 element.child(
523 text(theme, TypeScale::Caption, detail)
524 .text_tone(theme, TextTone::Muted),
525 )
526 }),
527 )
528 .child(
529 Badge::new(server.state.label(cx))
530 .tone(server.state.tone())
531 .id(ident.child("state")),
532 );
533
534 if let (true, Some(handler)) = (selectable, self.on_select.clone()) {
535 let id = server.id.clone();
536 header = header.on_click(move |_, window, cx| handler(id.clone(), window, cx));
537 }
538
539 let header = header.semantic_in(
540 cx,
541 NodeSpec::new(ident.semantic_id(), Role::Row)
542 .parent(self.ident.semantic_id())
543 .text(server.name.clone())
544 .value(server.state.name())
545 .selected(selected)
546 .disabled(refused)
547 .expanded(open),
548 );
549
550 let reason = server.state.reason().map(|reason| {
553 let node = ident.child("reason");
554 div()
555 .px_token(theme, Space::Sm)
556 .child(
557 Callout::new(
558 reason.clone(),
559 match server.state {
560 ServerState::Failed { .. } => Tone::Danger,
561 _ => Tone::Neutral,
562 },
563 )
564 .id(node.child("callout")),
565 )
566 .semantic_in(
567 cx,
568 NodeSpec::new(node.semantic_id(), Role::Status)
569 .parent(ident.semantic_id())
570 .text(reason.clone())
571 .value(server.state.name()),
572 )
573 });
574
575 let retry = self
576 .on_retry
577 .clone()
578 .filter(|_| matches!(server.state, ServerState::Failed { .. }))
579 .filter(|_| !refused)
580 .map(|handler| {
581 let id = server.id.clone();
582 div().px_token(theme, Space::Sm).child(
583 Button::new(ident.child("retry"))
584 .label(cx.strings().text(StringKey::TryAgain))
585 .secondary()
586 .control_size(ControlSize::Sm)
587 .on_click(move |window, cx| handler(id.clone(), window, cx)),
588 )
589 });
590
591 let offerings = open.then(|| self.offerings_element(server, &ident, theme, cx));
592
593 div()
594 .column()
595 .w_full()
596 .gap_token(theme, Space::Xs)
597 .radius(theme, Radius::Card)
598 .frame(theme, Surface::Panel, Elevation::Raised)
599 .child(header)
600 .children(reason)
601 .children(retry)
602 .children(offerings)
603 .into_any_element()
604 }
605
606 fn offerings_element(
607 &self,
608 server: &ServerEntry,
609 server_ident: &Ident,
610 theme: &Theme,
611 cx: &mut App,
612 ) -> AnyElement {
613 let ident = server_ident.child("offerings");
614 match &server.catalog {
615 Catalog::Unasked => {
616 EmptyState::new(ident, cx.strings().text(StringKey::ServerOfferingsUnasked))
617 .kind(EmptyKind::Unstarted)
618 .detail(cx.strings().text(StringKey::ServerOfferingsUnaskedDetail))
619 .into_any_element()
620 }
621 Catalog::Asking => {
622 let label = cx.strings().text(StringKey::ServerOfferingsAsking);
623 div()
624 .p_token(theme, Space::Sm)
625 .child(PulseLoader::new(ident.child("loader")).label(label.clone()))
626 .semantic_in(
627 cx,
628 NodeSpec::new(ident.semantic_id(), Role::Status)
629 .parent(server_ident.semantic_id())
630 .text(label)
631 .busy(true)
632 .value("asking"),
633 )
634 .into_any_element()
635 }
636 Catalog::Unavailable(reason) => EmptyState::new(
637 ident,
638 cx.strings().text(StringKey::ServerOfferingsUnavailable),
639 )
640 .kind(EmptyKind::Unavailable)
641 .detail(reason.clone())
642 .into_any_element(),
643 Catalog::Offers(offerings) if offerings.is_empty() => {
647 EmptyState::new(ident, cx.strings().text(StringKey::ServerOfferingsNone))
648 .kind(EmptyKind::Empty)
649 .detail(cx.strings().text(StringKey::ServerOfferingsNoneDetail))
650 .into_any_element()
651 }
652 Catalog::Offers(offerings) => {
653 let mut groups: Vec<AnyElement> = Vec::new();
654 for kind in [
655 OfferingKind::Tool,
656 OfferingKind::Skill,
657 OfferingKind::Resource,
658 ] {
659 let members: Vec<&Offering> = offerings
660 .iter()
661 .filter(|offering| offering.kind == kind)
662 .collect();
663 if members.is_empty() {
664 continue;
665 }
666 let heading_ident = ident.child(kind.name());
667 let heading = kind.heading(cx);
668 groups.push(
669 div()
670 .column()
671 .w_full()
672 .child(
673 text(theme, TypeScale::Subtitle, heading.clone())
674 .px_token(theme, Space::Sm)
675 .py_token(theme, Space::Xs)
676 .text_tone(theme, TextTone::Faint)
677 .semantic_in(
678 cx,
679 NodeSpec::new(heading_ident.semantic_id(), Role::Heading)
680 .parent(ident.semantic_id())
681 .text(heading)
682 .value(members.len().to_string()),
683 ),
684 )
685 .children(members.into_iter().map(|offering| {
686 self.offering_element(server, offering, server_ident, theme, cx)
687 }))
688 .into_any_element(),
689 );
690 }
691 div()
692 .column()
693 .w_full()
694 .children(groups)
695 .semantic_in(
696 cx,
697 NodeSpec::new(ident.semantic_id(), Role::List)
698 .parent(server_ident.semantic_id())
699 .value(offerings.len().to_string()),
700 )
701 .into_any_element()
702 }
703 }
704 }
705
706 fn offering_element(
711 &self,
712 server: &ServerEntry,
713 offering: &Offering,
714 server_ident: &Ident,
715 theme: &Theme,
716 cx: &mut App,
717 ) -> AnyElement {
718 let ident = server_ident.child("offering").child(offering.id.as_ref());
719 let metrics = theme.control.get(self.size);
720 let _ = server;
721
722 div()
723 .row()
724 .w_full()
725 .items_start()
726 .gap_token(theme, Space::Sm)
727 .px_token(theme, Space::Sm)
728 .py_token(theme, Space::Xs)
729 .child(
730 icon(offering.kind.glyph())
731 .size(px(metrics.icon_size))
732 .text_color(theme.colors.text_faint),
733 )
734 .child(
735 div()
736 .flex_1()
737 .min_w_0()
738 .column()
739 .child(text(theme, TypeScale::Label, offering.name.clone()))
740 .when_some(offering.summary.clone(), |element, summary| {
741 element.child(
742 text(theme, TypeScale::Body, summary).text_tone(theme, TextTone::Muted),
743 )
744 })
745 .when_some(offering.qualifier.clone(), |element, qualifier| {
746 element.child(
747 text(theme, TypeScale::Code, qualifier)
748 .text_tone(theme, TextTone::Faint)
749 .font_family(theme.typography.mono.clone()),
750 )
751 }),
752 )
753 .semantic_in(
754 cx,
755 NodeSpec::new(ident.semantic_id(), Role::Row)
756 .parent(server_ident.child("offerings").semantic_id())
757 .text(offering.name.clone())
758 .value(offering.kind.name()),
759 )
760 .into_any_element()
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn every_state_publishes_its_own_name() {
770 let names: Vec<&str> = [
771 ServerState::Connected,
772 ServerState::Connecting,
773 ServerState::Disconnected,
774 ServerState::Failed {
775 reason: SharedString::new_static("x"),
776 },
777 ServerState::Disabled { reason: None },
778 ]
779 .iter()
780 .map(ServerState::name)
781 .collect();
782 let mut unique = names.clone();
783 unique.sort_unstable();
784 unique.dedup();
785 assert_eq!(unique.len(), names.len(), "two states share a name");
786 }
787
788 #[test]
789 fn an_empty_answer_is_not_an_unasked_question() {
790 assert_ne!(Catalog::Offers(Vec::new()), Catalog::Unasked);
791 assert_eq!(Catalog::default(), Catalog::Unasked);
792 }
793
794 #[test]
795 fn only_a_failure_and_a_stated_refusal_carry_a_reason() {
796 assert!(ServerState::Connected.reason().is_none());
797 assert!(ServerState::Disconnected.reason().is_none());
798 assert!(ServerState::Disabled { reason: None }.reason().is_none());
799 assert!(
800 ServerState::Failed {
801 reason: SharedString::new_static("no route")
802 }
803 .reason()
804 .is_some()
805 );
806 }
807}