Skip to main content

gpui_kit/agent/
server_list.rs

1//! What is connected, and what each connection offers.
2//!
3//! # Wording
4//!
5//! Nothing here names a protocol, a vendor, or a product. A connected thing is
6//! a *server*, what it offers are *tools*, *skills* and *resources*, and the
7//! act of asking is *asking*. Those words describe the shape of the surface
8//! rather than the wire format underneath it, so a host speaking any protocol
9//! renders the same component — which is the whole reason this crate refuses
10//! product vocabulary.
11//!
12//! # Five states, and none of them is a shade of another
13//!
14//! Connected, connecting, disconnected, failed, and turned off by the reader
15//! are five different sentences. Collapsing the last two loses the difference
16//! between something that broke and something nobody wanted; collapsing the
17//! first two claims a connection that has not been made. A failed server keeps
18//! its reason on screen, in the host's own words, and offers exactly one
19//! control, which reports a retry and retries nothing.
20//!
21//! # Offering nothing is not the same as not having been asked
22//!
23//! [`Catalog::Offers`] with an empty list means the server answered and the
24//! answer was empty. [`Catalog::Unasked`] means nobody asked. Rendering the
25//! second as the first would tell the reader a server is useless when the
26//! truth is that the application has not got round to it yet.
27
28use 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/// Where a connection stands.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ServerState {
57    Connected,
58    Connecting,
59    /// Not connected, and nothing went wrong.
60    Disconnected,
61    /// The attempt failed. The reason is the host's and is shown word for
62    /// word; this crate never authors one.
63    Failed {
64        reason: SharedString,
65    },
66    /// The reader turned this one off. A refusal, not a failure, which is why
67    /// it is a state of its own rather than a disconnection with a note.
68    Disabled {
69        reason: Option<SharedString>,
70    },
71}
72
73impl ServerState {
74    /// The name the node publishes. It is the state, never its colour.
75    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    /// What the host said about this state, if anything. A failure always has
106    /// one; a state the reader chose may not.
107    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/// Which of the three kinds of thing a server offers.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
118pub enum OfferingKind {
119    /// Something the application can call.
120    Tool,
121    /// A procedure the application can follow.
122    Skill,
123    /// Something the application can read.
124    Resource,
125}
126
127impl OfferingKind {
128    /// The published name.
129    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/// One thing a server offers.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Offering {
157    id: SharedString,
158    kind: OfferingKind,
159    name: SharedString,
160    summary: Option<SharedString>,
161    /// The one extra fact that tells two similarly named things apart — a
162    /// resource's locator, a tool's signature — written by the host.
163    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/// What is known about what a server offers.
225///
226/// [`Catalog::Offers`] holding an empty list is an answer; [`Catalog::Unasked`]
227/// is the absence of a question. The two are separate variants rather than one
228/// emptiness because a surface that cannot tell them apart will show the wrong
229/// one to somebody who is trying to work out whether their server is broken.
230#[derive(Debug, Clone, PartialEq, Eq, Default)]
231pub enum Catalog {
232    /// Nobody has asked this server what it offers.
233    #[default]
234    Unasked,
235    /// The question is in flight.
236    Asking,
237    /// The server answered. An empty list is an answer.
238    Offers(Vec<Offering>),
239    /// The question could not be answered, for the host's stated reason.
240    Unavailable(SharedString),
241}
242
243/// One connection.
244#[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    /// A second line naming the connection: where it runs, which account it
265    /// uses. The host writes it; this crate never derives one.
266    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/// The connections an application holds, and what each one offers.
291#[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    /// The servers whose offerings are shown. Everything else is folded away,
343    /// and a folded server publishes none of what it offers.
344    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    /// Reports that a failed connection should be attempted again. This crate
371    /// connects to nothing.
372    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    /// Reports that a server's offerings should be shown or folded away.
381    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        // A connection the reader turned off is refused, not dimmed: nothing
446        // on its row installs a handler, so it cannot be operated by mistake.
447        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        // A failure keeps its reason on screen next to the thing that failed.
551        // So does a refusal the reader chose, when the host said why.
552        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            // The answer was empty, which is an answer. It is drawn as one:
644            // the reader is told the server offers nothing, not that nobody
645            // has looked.
646            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    /// One offering, named under the server that offers it.
707    ///
708    /// Two servers may offer the same name, so the id carries the attribution
709    /// and a test never has to guess which one it reached.
710    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}