ssh: Refine the modal UI (#19256)

Danilo Leal created

This PR refines the SSH modal UI, adjusting spacing and alignment. Via
these changes, I'm also introducing the ability for the `empty_message`
on the `List` component to receive not just a string but any element.
The custom way in which the SSH modal was designed made it feel like
this was needed for proper spacing.

<img width="700" alt="Screenshot 2024-10-16 at 1 20 54 AM"
src="https://github.com/user-attachments/assets/f2e0586b-4c9f-4497-b4cb-e90c8157512b">


Release Notes:

- N/A

Change summary

crates/recent_projects/src/dev_servers.rs | 157 ++++++++++++------------
crates/ui/src/components/list/list.rs     |  36 ++++
crates/ui/src/components/modal.rs         |   2 
3 files changed, 111 insertions(+), 84 deletions(-)

Detailed changes

crates/recent_projects/src/dev_servers.rs 🔗

@@ -35,7 +35,7 @@ use task::RevealStrategy;
 use task::SpawnInTerminal;
 use terminal_view::terminal_panel::TerminalPanel;
 use ui::Section;
-use ui::{prelude::*, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
+use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
 use util::ResultExt;
 use workspace::notifications::NotificationId;
 use workspace::OpenOptions;
@@ -604,19 +604,16 @@ impl DevServerProjects {
         };
         v_flex()
             .w_full()
-            .border_b_1()
-            .border_color(cx.theme().colors().border_variant)
-            .mb_1()
+            .child(ListSeparator)
             .child(
                 h_flex()
                     .group("ssh-server")
                     .w_full()
                     .pt_0p5()
-                    .px_2p5()
+                    .px_3()
                     .gap_1()
                     .overflow_hidden()
                     .whitespace_nowrap()
-                    .w_full()
                     .child(
                         Label::new(main_label)
                             .size(LabelSize::Small)
@@ -630,68 +627,63 @@ impl DevServerProjects {
                     ),
             )
             .child(
-                v_flex().w_full().gap_1().mb_1().child(
-                    List::new()
-                        .empty_message("No projects.")
-                        .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
-                            v_flex().gap_0p5().child(self.render_ssh_project(
-                                ix,
-                                &ssh_connection,
-                                pix,
-                                p,
-                                cx,
-                            ))
-                        }))
-                        .child(h_flex().map(|this| {
-                            self.selectable_items.add_item(Box::new({
-                                let ssh_connection = ssh_connection.clone();
-                                move |this, cx| {
-                                    this.create_ssh_project(ix, ssh_connection.clone(), cx);
-                                }
-                            }));
-                            let is_selected = self.selectable_items.is_selected();
-                            this.child(
-                                ListItem::new(("new-remote-project", ix))
-                                    .selected(is_selected)
-                                    .inset(true)
-                                    .spacing(ui::ListItemSpacing::Sparse)
-                                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
-                                    .child(Label::new("Open Folder"))
-                                    .on_click(cx.listener({
-                                        let ssh_connection = ssh_connection.clone();
-                                        move |this, _, cx| {
-                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
-                                        }
-                                    })),
-                            )
-                        }))
-                        .child(h_flex().map(|this| {
-                            self.selectable_items.add_item(Box::new({
-                                let ssh_connection = ssh_connection.clone();
-                                move |this, cx| {
-                                    this.view_server_options((ix, ssh_connection.clone()), cx);
-                                }
-                            }));
-                            let is_selected = self.selectable_items.is_selected();
-                            this.child(
-                                ListItem::new(("server-options", ix))
-                                    .selected(is_selected)
-                                    .inset(true)
-                                    .spacing(ui::ListItemSpacing::Sparse)
-                                    .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
-                                    .child(Label::new("View Server Options"))
-                                    .on_click(cx.listener({
-                                        let ssh_connection = ssh_connection.clone();
-                                        move |this, _, cx| {
-                                            this.view_server_options(
-                                                (ix, ssh_connection.clone()),
-                                                cx,
-                                            );
-                                        }
-                                    })),
-                            )
-                        })),
-                ),
+                List::new()
+                    .empty_message("No projects.")
+                    .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
+                        v_flex().gap_0p5().child(self.render_ssh_project(
+                            ix,
+                            &ssh_connection,
+                            pix,
+                            p,
+                            cx,
+                        ))
+                    }))
+                    .child(h_flex().map(|this| {
+                        self.selectable_items.add_item(Box::new({
+                            let ssh_connection = ssh_connection.clone();
+                            move |this, cx| {
+                                this.create_ssh_project(ix, ssh_connection.clone(), cx);
+                            }
+                        }));
+                        let is_selected = self.selectable_items.is_selected();
+                        this.child(
+                            ListItem::new(("new-remote-project", ix))
+                                .selected(is_selected)
+                                .inset(true)
+                                .spacing(ui::ListItemSpacing::Sparse)
+                                .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
+                                .child(Label::new("Open Folder"))
+                                .on_click(cx.listener({
+                                    let ssh_connection = ssh_connection.clone();
+                                    move |this, _, cx| {
+                                        this.create_ssh_project(ix, ssh_connection.clone(), cx);
+                                    }
+                                })),
+                        )
+                    }))
+                    .child(h_flex().map(|this| {
+                        self.selectable_items.add_item(Box::new({
+                            let ssh_connection = ssh_connection.clone();
+                            move |this, cx| {
+                                this.view_server_options((ix, ssh_connection.clone()), cx);
+                            }
+                        }));
+                        let is_selected = self.selectable_items.is_selected();
+                        this.child(
+                            ListItem::new(("server-options", ix))
+                                .selected(is_selected)
+                                .inset(true)
+                                .spacing(ui::ListItemSpacing::Sparse)
+                                .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
+                                .child(Label::new("View Server Options"))
+                                .on_click(cx.listener({
+                                    let ssh_connection = ssh_connection.clone();
+                                    move |this, _, cx| {
+                                        this.view_server_options((ix, ssh_connection.clone()), cx);
+                                    }
+                                })),
+                        )
+                    })),
             )
     }
 
@@ -762,6 +754,7 @@ impl DevServerProjects {
             .end_hover_slot::<AnyElement>(Some(
                 IconButton::new("remove-remote-project", IconName::TrashAlt)
                     .icon_size(IconSize::Small)
+                    .shape(IconButtonShape::Square)
                     .on_click(
                         cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
                     )
@@ -1117,6 +1110,7 @@ impl DevServerProjects {
         }));
 
         let is_selected = self.selectable_items.is_selected();
+
         let connect_button = ListItem::new("register-dev-server-button")
             .selected(is_selected)
             .inset(true)
@@ -1130,16 +1124,21 @@ impl DevServerProjects {
                 cx.notify();
             }));
 
-        let footer = format!("Servers: {}", ssh_connections.len() + dev_servers.len());
         let mut modal_section = v_flex()
             .id("ssh-server-list")
             .overflow_y_scroll()
             .size_full()
             .child(connect_button)
-            .child(ListSeparator)
             .child(
                 List::new()
-                    .empty_message("No dev servers registered yet.")
+                    .empty_message(
+                        v_flex()
+                            .child(ListSeparator)
+                            .child(div().px_3().child(
+                                Label::new("No dev servers registered yet.").color(Color::Muted),
+                            ))
+                            .into_any_element(),
+                    )
                     .children(ssh_connections.iter().cloned().enumerate().map(
                         |(ix, connection)| {
                             self.render_ssh_connection(ix, connection, cx)
@@ -1149,23 +1148,25 @@ impl DevServerProjects {
             )
             .into_any_element();
 
+        let server_count = format!("Servers: {}", ssh_connections.len() + dev_servers.len());
+
         Modal::new("remote-projects", Some(self.scroll_handle.clone()))
             .header(
                 ModalHeader::new().child(
                     h_flex()
+                        .items_center()
                         .justify_between()
                         .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
-                        .child(Label::new(footer).size(LabelSize::Small)),
+                        .child(Label::new(server_count).size(LabelSize::Small)),
                 ),
             )
             .section(
                 Section::new().padded(false).child(
                     v_flex()
-                        .min_h(rems(28.))
+                        .min_h(rems(20.))
+                        .flex_1()
                         .size_full()
-                        .pt_1p5()
-                        .border_y_1()
-                        .border_color(cx.theme().colors().border_variant)
+                        .child(ListSeparator)
                         .child(
                             canvas(
                                 |bounds, cx| {
@@ -1180,9 +1181,7 @@ impl DevServerProjects {
                                     modal_section.paint(cx);
                                 },
                             )
-                            .size_full()
-                            .min_h_full()
-                            .flex_1(),
+                            .size_full(),
                         ),
                 ),
             )

crates/ui/src/components/list/list.rs 🔗

@@ -5,11 +5,16 @@ use smallvec::SmallVec;
 
 use crate::{prelude::*, v_flex, Label, ListHeader};
 
+pub enum EmptyMessage {
+    Text(SharedString),
+    Element(AnyElement),
+}
+
 #[derive(IntoElement)]
 pub struct List {
     /// Message to display when the list is empty
     /// Defaults to "No items"
-    empty_message: SharedString,
+    empty_message: EmptyMessage,
     header: Option<ListHeader>,
     toggle: Option<bool>,
     children: SmallVec<[AnyElement; 2]>,
@@ -24,15 +29,15 @@ impl Default for List {
 impl List {
     pub fn new() -> Self {
         Self {
-            empty_message: "No items".into(),
+            empty_message: EmptyMessage::Text("No items".into()),
             header: None,
             toggle: None,
             children: SmallVec::new(),
         }
     }
 
-    pub fn empty_message(mut self, empty_message: impl Into<SharedString>) -> Self {
-        self.empty_message = empty_message.into();
+    pub fn empty_message(mut self, message: impl Into<EmptyMessage>) -> Self {
+        self.empty_message = message.into();
         self
     }
 
@@ -53,6 +58,24 @@ impl ParentElement for List {
     }
 }
 
+impl From<String> for EmptyMessage {
+    fn from(s: String) -> Self {
+        EmptyMessage::Text(SharedString::from(s))
+    }
+}
+
+impl From<&str> for EmptyMessage {
+    fn from(s: &str) -> Self {
+        EmptyMessage::Text(SharedString::from(s.to_owned()))
+    }
+}
+
+impl From<AnyElement> for EmptyMessage {
+    fn from(e: AnyElement) -> Self {
+        EmptyMessage::Element(e)
+    }
+}
+
 impl RenderOnce for List {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         v_flex()
@@ -62,7 +85,10 @@ impl RenderOnce for List {
             .map(|this| match (self.children.is_empty(), self.toggle) {
                 (false, _) => this.children(self.children),
                 (true, Some(false)) => this,
-                (true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)),
+                (true, _) => match self.empty_message {
+                    EmptyMessage::Text(text) => this.child(Label::new(text).color(Color::Muted)),
+                    EmptyMessage::Element(element) => this.child(element),
+                },
             })
     }
 }

crates/ui/src/components/modal.rs 🔗

@@ -77,6 +77,7 @@ impl RenderOnce for Modal {
                 v_flex()
                     .id(self.container_id.clone())
                     .w_full()
+                    .flex_1()
                     .gap(Spacing::Large.rems(cx))
                     .when_some(
                         self.container_scroll_handler,
@@ -344,6 +345,7 @@ impl RenderOnce for Section {
         } else {
             v_flex()
                 .w_full()
+                .flex_1()
                 .gap_y(Spacing::Small.rems(cx))
                 .when(self.padded, |this| {
                     this.px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx))