ssh: Overhaul remoting UI (#18727)

Piotr Osiewicz and Danilo Leal created

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>

Change summary

Cargo.lock                                    |   1 
assets/icons/trash_alt.svg                    |   1 
crates/recent_projects/Cargo.toml             |   1 
crates/recent_projects/src/dev_servers.rs     | 591 ++++++++------------
crates/recent_projects/src/ssh_connections.rs | 127 ++-
crates/ui/src/components/icon.rs              |   1 
crates/ui/src/components/modal.rs             |  36 
crates/ui/src/traits/styled_ext.rs            |  20 
8 files changed, 356 insertions(+), 422 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9002,7 +9002,6 @@ dependencies = [
  "gpui",
  "language",
  "log",
- "markdown",
  "menu",
  "ordered-float 2.10.1",
  "picker",

assets/icons/trash_alt.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>

crates/recent_projects/Cargo.toml 🔗

@@ -22,7 +22,6 @@ futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 log.workspace = true
-markdown.workspace = true
 menu.workspace = true
 ordered-float.workspace = true
 picker.workspace = true

crates/recent_projects/src/dev_servers.rs 🔗

@@ -8,17 +8,18 @@ use anyhow::Result;
 use client::Client;
 use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
 use editor::Editor;
+use gpui::pulsating_between;
 use gpui::AsyncWindowContext;
+use gpui::ClipboardItem;
 use gpui::PathPromptOptions;
 use gpui::Subscription;
 use gpui::Task;
 use gpui::WeakView;
 use gpui::{
-    percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
-    FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
+    percentage, Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent,
+    EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View,
+    ViewContext,
 };
-use markdown::Markdown;
-use markdown::MarkdownStyle;
 use project::terminals::wrap_for_ssh;
 use project::terminals::SshCommand;
 use rpc::proto::RegenerateDevServerTokenResponse;
@@ -35,8 +36,8 @@ use terminal_view::terminal_panel::TerminalPanel;
 use ui::ElevationIndex;
 use ui::Section;
 use ui::{
-    prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
-    RadioWithLabel, Tooltip,
+    prelude::*, IconButtonShape, Indicator, List, ListItem, Modal, ModalFooter, ModalHeader,
+    Tooltip,
 };
 use ui_input::{FieldLabelLayout, TextField};
 use util::ResultExt;
@@ -62,7 +63,6 @@ pub struct DevServerProjects {
     workspace: WeakView<Workspace>,
     project_path_input: View<Editor>,
     dev_server_name_input: View<TextField>,
-    markdown: View<Markdown>,
     _dev_server_subscription: Subscription,
 }
 
@@ -132,26 +132,6 @@ impl DevServerProjects {
             ..Default::default()
         });
 
-        let markdown_style = MarkdownStyle {
-            base_text_style: base_style,
-            code_block: gpui::StyleRefinement {
-                text: Some(gpui::TextStyleRefinement {
-                    font_family: Some("Zed Plex Mono".into()),
-                    ..Default::default()
-                }),
-                ..Default::default()
-            },
-            link: gpui::TextStyleRefinement {
-                color: Some(Color::Accent.color(cx)),
-                ..Default::default()
-            },
-            syntax: cx.theme().syntax().clone(),
-            selection_background_color: cx.theme().players().local().selection,
-            ..Default::default()
-        };
-        let markdown =
-            cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
-
         Self {
             mode: Mode::Default(None),
             focus_handle,
@@ -159,7 +139,6 @@ impl DevServerProjects {
             dev_server_store,
             project_path_input,
             dev_server_name_input,
-            markdown,
             workspace,
             _dev_server_subscription: subscription,
         }
@@ -845,7 +824,7 @@ impl DevServerProjects {
                                 })
                                 .child({
                                     let dev_server_id = dev_server.id;
-                                    IconButton::new("remove-dev-server", IconName::Trash)
+                                    IconButton::new("remove-dev-server", IconName::TrashAlt)
                                         .on_click(cx.listener(move |this, _, cx| {
                                             this.delete_dev_server(dev_server_id, cx)
                                         }))
@@ -913,40 +892,73 @@ impl DevServerProjects {
     ) -> impl IntoElement {
         v_flex()
             .w_full()
+            .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
             .child(
-                h_flex().group("ssh-server").justify_between().child(
-                    h_flex()
-                        .gap_2()
-                        .child(
-                            div()
-                                .id(("status", ix))
-                                .relative()
-                                .child(Icon::new(IconName::Server).size(IconSize::Small)),
-                        )
-                        .child(
-                            div()
-                                .max_w(rems(26.))
-                                .overflow_hidden()
-                                .whitespace_nowrap()
-                                .child(Label::new(ssh_connection.host.clone())),
-                        )
-                        .child(h_flex().visible_on_hover("ssh-server").gap_1().child({
-                            IconButton::new("remove-dev-server", IconName::Trash)
-                                .on_click(
-                                    cx.listener(move |this, _, cx| this.delete_ssh_server(ix, cx)),
-                                )
-                                .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
-                        })),
-                ),
+                h_flex()
+                    .w_full()
+                    .group("ssh-server")
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .w_full()
+                            .child(
+                                div()
+                                    .id(("status", ix))
+                                    .relative()
+                                    .child(Icon::new(IconName::Server).size(IconSize::Small)),
+                            )
+                            .child(
+                                h_flex()
+                                    .max_w(rems(26.))
+                                    .overflow_hidden()
+                                    .whitespace_nowrap()
+                                    .child(Label::new(ssh_connection.host.clone())),
+                            ),
+                    )
+                    .child(
+                        h_flex()
+                            .visible_on_hover("ssh-server")
+                            .gap_1()
+                            .child({
+                                IconButton::new("copy-dev-server-address", IconName::Copy)
+                                    .icon_size(IconSize::Small)
+                                    .on_click(cx.listener(move |this, _, cx| {
+                                        this.update_settings_file(cx, move |servers, cx| {
+                                            if let Some(content) = servers
+                                                .ssh_connections
+                                                .as_ref()
+                                                .and_then(|connections| {
+                                                    connections
+                                                        .get(ix)
+                                                        .map(|connection| connection.host.clone())
+                                                })
+                                            {
+                                                cx.write_to_clipboard(ClipboardItem::new_string(
+                                                    content,
+                                                ));
+                                            }
+                                        });
+                                    }))
+                                    .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
+                            })
+                            .child({
+                                IconButton::new("remove-dev-server", IconName::TrashAlt)
+                                    .icon_size(IconSize::Small)
+                                    .on_click(cx.listener(move |this, _, cx| {
+                                        this.delete_ssh_server(ix, cx)
+                                    }))
+                                    .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
+                            }),
+                    ),
             )
             .child(
                 v_flex()
                     .w_full()
-                    .bg(cx.theme().colors().background)
-                    .border_1()
+                    .border_l_1()
                     .border_color(cx.theme().colors().border_variant)
-                    .rounded_md()
                     .my_1()
+                    .mx_1p5()
                     .py_0p5()
                     .px_3()
                     .child(
@@ -956,12 +968,17 @@ impl DevServerProjects {
                                 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
                             }))
                             .child(
-                                ListItem::new("new-remote_project")
-                                    .start_slot(Icon::new(IconName::Plus))
-                                    .child(Label::new("Open folder…"))
-                                    .on_click(cx.listener(move |this, _, cx| {
-                                        this.create_ssh_project(ix, ssh_connection.clone(), cx);
-                                    })),
+                                h_flex().child(
+                                    Button::new("new-remote_project", "Open Folder…")
+                                        .icon(IconName::Plus)
+                                        .size(ButtonSize::Default)
+                                        .style(ButtonStyle::Filled)
+                                        .layer(ElevationIndex::ModalSurface)
+                                        .icon_position(IconPosition::Start)
+                                        .on_click(cx.listener(move |this, _, cx| {
+                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
+                                        })),
+                                ),
                             ),
                     ),
             )
@@ -978,7 +995,8 @@ impl DevServerProjects {
         let project = project.clone();
         let server = server.clone();
         ListItem::new(("remote-project", ix))
-            .start_slot(Icon::new(IconName::FileTree))
+            .spacing(ui::ListItemSpacing::Sparse)
+            .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
             .child(Label::new(project.paths.join(", ")))
             .on_click(cx.listener(move |this, _, cx| {
                 let Some(app_state) = this
@@ -1014,7 +1032,7 @@ impl DevServerProjects {
                 .detach();
             }))
             .end_hover_slot::<AnyElement>(Some(
-                IconButton::new("remove-remote-project", IconName::Trash)
+                IconButton::new("remove-remote-project", IconName::TrashAlt)
                     .on_click(
                         cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
                     )
@@ -1026,7 +1044,7 @@ impl DevServerProjects {
     fn update_settings_file(
         &mut self,
         cx: &mut ViewContext<Self>,
-        f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
+        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
     ) {
         let Some(fs) = self
             .workspace
@@ -1035,11 +1053,11 @@ impl DevServerProjects {
         else {
             return;
         };
-        update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
+        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
     }
 
     fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
-        self.update_settings_file(cx, move |setting| {
+        self.update_settings_file(cx, move |setting, _| {
             if let Some(connections) = setting.ssh_connections.as_mut() {
                 connections.remove(server);
             }
@@ -1047,7 +1065,7 @@ impl DevServerProjects {
     }
 
     fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
-        self.update_settings_file(cx, move |setting| {
+        self.update_settings_file(cx, move |setting, _| {
             if let Some(server) = setting
                 .ssh_connections
                 .as_mut()
@@ -1063,7 +1081,7 @@ impl DevServerProjects {
         connection_options: remote::SshConnectionOptions,
         cx: &mut ViewContext<Self>,
     ) {
-        self.update_settings_file(cx, move |setting| {
+        self.update_settings_file(cx, move |setting, _| {
             setting
                 .ssh_connections
                 .get_or_insert(Default::default())
@@ -1124,7 +1142,7 @@ impl DevServerProjects {
                     }).detach();
                 }
             }))
-            .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
+            .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::TrashAlt)
                 .on_click(cx.listener(move |this, _, cx| {
                     this.delete_dev_server_project(dev_server_project_id, cx)
                 }))
@@ -1148,250 +1166,109 @@ impl DevServerProjects {
             kind = NewServerKind::DirectSSH;
         }
 
-        let status = dev_server_id
-            .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
-            .unwrap_or_default();
-
-        let name = self.dev_server_name_input.update(cx, |input, cx| {
+        self.dev_server_name_input.update(cx, |input, cx| {
             input.editor().update(cx, |editor, cx| {
                 if editor.text(cx).is_empty() {
-                    match kind {
-                        NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
-                        NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
-                        NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
-                    }
+                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
                 }
-                editor.text(cx)
             })
         });
-
-        const MANUAL_SETUP_MESSAGE: &str =
-            "Generate a token for this server and follow the steps to set Zed up on that machine.";
-        const SSH_SETUP_MESSAGE: &str =
-            "Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
-
-        Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
-            .header(
-                ModalHeader::new()
-                    .headline("Create Dev Server")
-                    .show_back_button(true),
-            )
-            .section(
-                Section::new()
-                    .header(if kind == NewServerKind::Manual {
-                        "Server Name".into()
-                    } else {
-                        "SSH arguments".into()
-                    })
+        let theme = cx.theme();
+        v_flex()
+            .id("create-dev-server")
+            .overflow_hidden()
+            .size_full()
+            .flex_1()
+            .child(
+                h_flex()
+                    .p_2()
+                    .gap_2()
+                    .items_center()
+                    .border_b_1()
+                    .border_color(theme.colors().border_variant)
                     .child(
-                        div()
-                            .max_w(rems(16.))
-                            .child(self.dev_server_name_input.clone()),
-                    ),
+                        IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
+                            .shape(IconButtonShape::Square)
+                            .on_click(|_, cx| {
+                                cx.dispatch_action(menu::Cancel.boxed_clone());
+                            }),
+                    )
+                    .child(Label::new("Connect New Dev Server")),
             )
-            .section(
-                Section::new_contained()
-                    .header("Connection Method".into())
+            .child(
+                v_flex()
+                    .p_3()
+                    .border_b_1()
+                    .border_color(theme.colors().border_variant)
+                    .child(Label::new("SSH Arguments"))
+                    .child(
+                        Label::new("Enter the command you use to SSH into this server.")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
                     .child(
-                        v_flex()
+                        h_flex()
+                            .mt_2()
                             .w_full()
-                            .px_2()
-                            .gap_y(Spacing::Large.rems(cx))
-                            .when(ssh_prompt.is_none(), |el| {
-                                el.child(
-                                    v_flex()
-                                        .when(use_direct_ssh, |el| {
-                                            el.child(RadioWithLabel::new(
-                                                "use-server-name-in-ssh",
-                                                Label::new("Connect via SSH (default)"),
-                                                NewServerKind::DirectSSH == kind,
-                                                cx.listener({
-                                                    move |this, _, cx| {
-                                                        if let Mode::CreateDevServer(
-                                                            CreateDevServer { kind, .. },
-                                                        ) = &mut this.mode
-                                                        {
-                                                            *kind = NewServerKind::DirectSSH;
-                                                        }
-                                                        cx.notify()
-                                                    }
-                                                }),
-                                            ))
-                                        })
-                                        .when(!use_direct_ssh, |el| {
-                                            el.child(RadioWithLabel::new(
-                                                "use-server-name-in-ssh",
-                                                Label::new("Configure over SSH (default)"),
-                                                kind == NewServerKind::LegacySSH,
-                                                cx.listener({
-                                                    move |this, _, cx| {
-                                                        if let Mode::CreateDevServer(
-                                                            CreateDevServer { kind, .. },
-                                                        ) = &mut this.mode
-                                                        {
-                                                            *kind = NewServerKind::LegacySSH;
-                                                        }
-                                                        cx.notify()
-                                                    }
-                                                }),
-                                            ))
-                                        })
-                                        .child(RadioWithLabel::new(
-                                            "use-server-name-in-ssh",
-                                            Label::new("Configure manually"),
-                                            kind == NewServerKind::Manual,
-                                            cx.listener({
-                                                move |this, _, cx| {
-                                                    if let Mode::CreateDevServer(
-                                                        CreateDevServer { kind, .. },
-                                                    ) = &mut this.mode
-                                                    {
-                                                        *kind = NewServerKind::Manual;
-                                                    }
-                                                    cx.notify()
-                                                }
-                                            }),
-                                        )),
-                                )
-                            })
-                            .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
-                                el.child(
-                                    if kind == NewServerKind::Manual {
-                                        Label::new(MANUAL_SETUP_MESSAGE)
-                                    } else {
-                                        Label::new(SSH_SETUP_MESSAGE)
-                                    }
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                )
-                            })
-                            .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
-                            .when(dev_server_id.is_some() && access_token.is_none(), |el| {
-                                el.child(
-                                    if kind == NewServerKind::Manual {
-                                        Label::new(
-                                            "Note: updating the dev server generate a new token",
-                                        )
-                                    } else {
-                                        Label::new(SSH_SETUP_MESSAGE)
-                                    }
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                )
-                            })
-                            .when_some(access_token.clone(), {
-                                |el, access_token| {
-                                    el.child(self.render_dev_server_token_creating(
-                                        access_token,
-                                        name,
-                                        kind,
-                                        status,
-                                        creating,
-                                        cx,
-                                    ))
-                                }
-                            }),
+                            .gap_2()
+                            .child(self.dev_server_name_input.clone())
+                            .child(
+                                Button::new("create-dev-server", "Connect Server")
+                                    .style(ButtonStyle::Filled)
+                                    .layer(ElevationIndex::ModalSurface)
+                                    .disabled(creating && dev_server_id.is_none())
+                                    .on_click(cx.listener({
+                                        let access_token = access_token.clone();
+                                        move |this, _, cx| {
+                                            if kind == NewServerKind::DirectSSH {
+                                                this.create_ssh_server(cx);
+                                                return;
+                                            }
+                                            this.create_or_update_dev_server(
+                                                kind,
+                                                dev_server_id,
+                                                access_token.clone(),
+                                                cx,
+                                            );
+                                        }
+                                    })),
+                            ),
                     ),
             )
-            .footer(
-                ModalFooter::new().end_slot(if status == DevServerStatus::Online {
-                    Button::new("create-dev-server", "Done")
-                        .style(ButtonStyle::Filled)
-                        .layer(ElevationIndex::ModalSurface)
-                        .on_click(cx.listener(move |this, _, cx| {
-                            cx.focus(&this.focus_handle);
-                            this.mode = Mode::Default(None);
-                            cx.notify();
-                        }))
-                } else {
-                    Button::new(
-                        "create-dev-server",
-                        if kind == NewServerKind::Manual {
-                            if dev_server_id.is_some() {
-                                "Update"
-                            } else {
-                                "Create"
-                            }
-                        } else if dev_server_id.is_some() {
-                            "Reconnect"
+            .child(
+                h_flex()
+                    .bg(theme.colors().editor_background)
+                    .w_full()
+                    .map(|this| {
+                        if let Some(ssh_prompt) = ssh_prompt {
+                            this.child(h_flex().w_full().child(ssh_prompt))
                         } else {
-                            "Connect"
-                        },
-                    )
-                    .style(ButtonStyle::Filled)
-                    .layer(ElevationIndex::ModalSurface)
-                    .disabled(creating && dev_server_id.is_none())
-                    .on_click(cx.listener({
-                        let access_token = access_token.clone();
-                        move |this, _, cx| {
-                            if kind == NewServerKind::DirectSSH {
-                                this.create_ssh_server(cx);
-                                return;
-                            }
-                            this.create_or_update_dev_server(
-                                kind,
-                                dev_server_id,
-                                access_token.clone(),
-                                cx,
-                            );
+                            let color = Color::Muted.color(cx);
+                            this.child(
+                                h_flex()
+                                    .p_2()
+                                    .w_full()
+                                    .content_center()
+                                    .gap_2()
+                                    .child(h_flex().w_full())
+                                    .child(
+                                        div().p_1().rounded_lg().bg(color).with_animation(
+                                            "pulse-ssh-waiting-for-connection",
+                                            Animation::new(Duration::from_secs(2))
+                                                .repeat()
+                                                .with_easing(pulsating_between(0.2, 0.5)),
+                                            move |this, progress| this.bg(color.opacity(progress)),
+                                        ),
+                                    )
+                                    .child(
+                                        Label::new("Waiting for connection…")
+                                            .size(LabelSize::Small),
+                                    )
+                                    .child(h_flex().w_full()),
+                            )
                         }
-                    }))
-                }),
-            )
-    }
-
-    fn render_dev_server_token_creating(
-        &self,
-        access_token: String,
-        dev_server_name: String,
-        kind: NewServerKind,
-        status: DevServerStatus,
-        creating: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> Div {
-        self.markdown.update(cx, |markdown, cx| {
-            if kind == NewServerKind::Manual {
-                markdown.reset(format!("Please log into '{}'. If you don't yet have Zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen, to start Zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
-            } else {
-                markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using the manual setup.".to_string(), cx);
-            }
-        });
-
-        v_flex()
-            .pl_2()
-            .pt_2()
-            .gap_2()
-            .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
-            .map(|el| {
-                if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
-                {
-                    el.child(
-                        h_flex()
-                            .gap_2()
-                            .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
-                            .child(Label::new("Not connected")),
-                    )
-                } else if status == DevServerStatus::Offline {
-                    el.child(Self::render_loading_spinner("Waiting for connection…"))
-                } else {
-                    el.child(Label::new("🎊 Connection established!"))
-                }
-            })
-    }
-
-    fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
-        h_flex()
-            .gap_2()
-            .child(
-                Icon::new(IconName::ArrowCircle)
-                    .size(IconSize::Medium)
-                    .with_animation(
-                        "arrow-circle",
-                        Animation::new(Duration::from_secs(2)).repeat(),
-                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                    ),
+                    }),
             )
-            .child(Label::new(label))
     }
 
     fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -1416,64 +1293,73 @@ impl DevServerProjects {
             creating_dev_server = Some(*dev_server_id);
         };
 
+        let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
         Modal::new("remote-projects", Some(self.scroll_handle.clone()))
             .header(
-                ModalHeader::new()
-                    .show_dismiss_button(true)
-                    .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
+                ModalHeader::new().child(
+                    h_flex()
+                        .justify_between()
+                        .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
+                        .child(
+                            Button::new("register-dev-server-button", "Connect New Server")
+                                .style(ButtonStyle::Filled)
+                                .layer(ElevationIndex::ModalSurface)
+                                .icon(IconName::Plus)
+                                .icon_position(IconPosition::Start)
+                                .icon_color(Color::Muted)
+                                .on_click(cx.listener(|this, _, cx| {
+                                    this.mode = Mode::CreateDevServer(CreateDevServer {
+                                        kind: if SshSettings::get_global(cx).use_direct_ssh() {
+                                            NewServerKind::DirectSSH
+                                        } else {
+                                            NewServerKind::LegacySSH
+                                        },
+                                        ..Default::default()
+                                    });
+                                    this.dev_server_name_input.update(cx, |text_field, cx| {
+                                        text_field.editor().update(cx, |editor, cx| {
+                                            editor.set_text("", cx);
+                                        });
+                                    });
+                                    cx.notify();
+                                })),
+                        ),
+                ),
             )
             .section(
-                Section::new().child(
-                    div().child(
-                        List::new()
-                            .empty_message("No dev servers registered yet.")
-                            .header(Some(
-                                ListHeader::new("Connections").end_slot(
-                                    Button::new("register-dev-server-button", "Connect New Server")
-                                        .icon(IconName::Plus)
-                                        .icon_position(IconPosition::Start)
-                                        .icon_color(Color::Muted)
-                                        .on_click(cx.listener(|this, _, cx| {
-                                            this.mode = Mode::CreateDevServer(CreateDevServer {
-                                                kind: if SshSettings::get_global(cx)
-                                                    .use_direct_ssh()
-                                                {
-                                                    NewServerKind::DirectSSH
-                                                } else {
-                                                    NewServerKind::LegacySSH
-                                                },
-                                                ..Default::default()
-                                            });
-                                            this.dev_server_name_input.update(
-                                                cx,
-                                                |text_field, cx| {
-                                                    text_field.editor().update(cx, |editor, cx| {
-                                                        editor.set_text("", cx);
-                                                    });
-                                                },
-                                            );
-                                            cx.notify();
-                                        })),
-                                ),
-                            ))
-                            .children(ssh_connections.iter().cloned().enumerate().map(
-                                |(ix, connection)| {
-                                    self.render_ssh_connection(ix, connection, cx)
-                                        .into_any_element()
-                                },
-                            ))
-                            .children(dev_servers.iter().map(|dev_server| {
-                                let creating = if creating_dev_server == Some(dev_server.id) {
-                                    is_creating
-                                } else {
-                                    None
-                                };
-                                self.render_dev_server(dev_server, creating, cx)
-                                    .into_any_element()
-                            })),
-                    ),
+                Section::new().padded(false).child(
+                    div()
+                        .border_y_1()
+                        .border_color(cx.theme().colors().border_variant)
+                        .w_full()
+                        .child(
+                            div().p_2().child(
+                                List::new()
+                                    .empty_message("No dev servers registered yet.")
+                                    .children(ssh_connections.iter().cloned().enumerate().map(
+                                        |(ix, connection)| {
+                                            self.render_ssh_connection(ix, connection, cx)
+                                                .into_any_element()
+                                        },
+                                    ))
+                                    .children(dev_servers.iter().map(|dev_server| {
+                                        let creating = if creating_dev_server == Some(dev_server.id)
+                                        {
+                                            is_creating
+                                        } else {
+                                            None
+                                        };
+                                        self.render_dev_server(dev_server, creating, cx)
+                                            .into_any_element()
+                                    })),
+                            ),
+                        ),
                 ),
             )
+            .footer(
+                ModalFooter::new()
+                    .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
+            )
     }
 }
 
@@ -1501,7 +1387,6 @@ impl Render for DevServerProjects {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         div()
             .track_focus(&self.focus_handle)
-            .p_2()
             .elevation_3(cx)
             .key_context("DevServerModal")
             .on_action(cx.listener(Self::cancel))

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -5,9 +5,9 @@ use auto_update::AutoUpdater;
 use editor::Editor;
 use futures::channel::oneshot;
 use gpui::{
-    percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
-    EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task,
-    Transformation, View,
+    percentage, px, Action, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext,
+    DismissEvent, EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion,
+    SharedString, Task, Transformation, View,
 };
 use gpui::{AppContext, Model};
 use release_channel::{AppVersion, ReleaseChannel};
@@ -16,9 +16,9 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
 use ui::{
-    h_flex, v_flex, Color, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement,
-    IntoElement, Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext,
-    WindowContext,
+    div, h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, FluentBuilder as _, Icon,
+    IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled,
+    StyledExt as _, Tooltip, ViewContext, VisualContext, WindowContext,
 };
 use workspace::{AppState, ModalView, Workspace};
 
@@ -140,47 +140,57 @@ impl SshPrompt {
 }
 
 impl Render for SshPrompt {
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+    fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
         v_flex()
+            .w_full()
             .key_context("PasswordPrompt")
-            .p_4()
-            .size_full()
+            .justify_start()
             .child(
-                h_flex()
-                    .gap_2()
-                    .child(if self.error_message.is_some() {
-                        Icon::new(IconName::XCircle)
-                            .size(IconSize::Medium)
-                            .color(Color::Error)
-                            .into_any_element()
-                    } else {
-                        Icon::new(IconName::ArrowCircle)
-                            .size(IconSize::Medium)
-                            .with_animation(
-                                "arrow-circle",
-                                Animation::new(Duration::from_secs(2)).repeat(),
-                                |icon, delta| {
-                                    icon.transform(Transformation::rotate(percentage(delta)))
-                                },
-                            )
-                            .into_any_element()
-                    })
+                v_flex()
+                    .p_4()
+                    .size_full()
                     .child(
-                        Label::new(format!("ssh {}…", self.connection_string))
-                            .size(ui::LabelSize::Large),
-                    ),
-            )
-            .when_some(self.error_message.as_ref(), |el, error| {
-                el.child(Label::new(error.clone()))
-            })
-            .when(
-                self.error_message.is_none() && self.status_message.is_some(),
-                |el| el.child(Label::new(self.status_message.clone().unwrap())),
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(h_flex().w_full())
+                            .child(if self.error_message.is_some() {
+                                Icon::new(IconName::XCircle)
+                                    .size(IconSize::Medium)
+                                    .color(Color::Error)
+                                    .into_any_element()
+                            } else {
+                                Icon::new(IconName::ArrowCircle)
+                                    .size(IconSize::Medium)
+                                    .with_animation(
+                                        "arrow-circle",
+                                        Animation::new(Duration::from_secs(2)).repeat(),
+                                        |icon, delta| {
+                                            icon.transform(Transformation::rotate(percentage(
+                                                delta,
+                                            )))
+                                        },
+                                    )
+                                    .into_any_element()
+                            })
+                            .child(Label::new(format!(
+                                "Connecting to {}…",
+                                self.connection_string
+                            )))
+                            .child(h_flex().w_full()),
+                    )
+                    .when_some(self.error_message.as_ref(), |el, error| {
+                        el.child(Label::new(error.clone()))
+                    })
+                    .when(
+                        self.error_message.is_none() && self.status_message.is_some(),
+                        |el| el.child(Label::new(self.status_message.clone().unwrap())),
+                    )
+                    .when_some(self.prompt.as_ref(), |el, prompt| {
+                        el.child(Label::new(prompt.0.clone()))
+                            .child(self.editor.clone())
+                    }),
             )
-            .when_some(self.prompt.as_ref(), |el, prompt| {
-                el.child(Label::new(prompt.0.clone()))
-                    .child(self.editor.clone())
-            })
     }
 }
 
@@ -202,14 +212,41 @@ impl SshConnectionModal {
 
 impl Render for SshConnectionModal {
     fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
+        let connection_string = self.prompt.read(cx).connection_string.clone();
+        let theme = cx.theme();
+        let header_color = theme.colors().element_background;
+        let body_color = theme.colors().background;
         v_flex()
             .elevation_3(cx)
-            .p_4()
-            .gap_2()
             .on_action(cx.listener(Self::dismiss))
             .on_action(cx.listener(Self::confirm))
             .w(px(400.))
-            .child(self.prompt.clone())
+            .child(
+                h_flex()
+                    .p_1()
+                    .border_b_1()
+                    .border_color(theme.colors().border)
+                    .bg(header_color)
+                    .justify_between()
+                    .child(
+                        IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
+                            .icon_size(IconSize::XSmall)
+                            .on_click(|_, cx| cx.dispatch_action(menu::Cancel.boxed_clone()))
+                            .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
+                            .child(
+                                Label::new(connection_string)
+                                    .size(ui::LabelSize::Small)
+                                    .single_line(),
+                            ),
+                    )
+                    .child(div()),
+            )
+            .child(h_flex().bg(body_color).w_full().child(self.prompt.clone()))
     }
 }
 

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

@@ -262,6 +262,7 @@ impl RenderOnce for ModalFooter {
 #[derive(IntoElement)]
 pub struct Section {
     contained: bool,
+    padded: bool,
     header: Option<SectionHeader>,
     meta: Option<SharedString>,
     children: SmallVec<[AnyElement; 2]>,
@@ -277,6 +278,7 @@ impl Section {
     pub fn new() -> Self {
         Self {
             contained: false,
+            padded: true,
             header: None,
             meta: None,
             children: SmallVec::new(),
@@ -286,6 +288,7 @@ impl Section {
     pub fn new_contained() -> Self {
         Self {
             contained: true,
+            padded: true,
             header: None,
             meta: None,
             children: SmallVec::new(),
@@ -306,6 +309,10 @@ impl Section {
         self.meta = Some(meta.into());
         self
     }
+    pub fn padded(mut self, padded: bool) -> Self {
+        self.padded = padded;
+        self
+    }
 }
 
 impl ParentElement for Section {
@@ -320,22 +327,27 @@ impl RenderOnce for Section {
         section_bg.fade_out(0.96);
 
         let children = if self.contained {
-            v_flex().flex_1().px(Spacing::XLarge.rems(cx)).child(
-                v_flex()
-                    .w_full()
-                    .rounded_md()
-                    .border_1()
-                    .border_color(cx.theme().colors().border)
-                    .bg(section_bg)
-                    .py(Spacing::Medium.rems(cx))
-                    .gap_y(Spacing::Small.rems(cx))
-                    .child(div().flex().flex_1().size_full().children(self.children)),
-            )
+            v_flex()
+                .flex_1()
+                .when(self.padded, |this| this.px(Spacing::XLarge.rems(cx)))
+                .child(
+                    v_flex()
+                        .w_full()
+                        .rounded_md()
+                        .border_1()
+                        .border_color(cx.theme().colors().border)
+                        .bg(section_bg)
+                        .py(Spacing::Medium.rems(cx))
+                        .gap_y(Spacing::Small.rems(cx))
+                        .child(div().flex().flex_1().size_full().children(self.children)),
+                )
         } else {
             v_flex()
                 .w_full()
                 .gap_y(Spacing::Small.rems(cx))
-                .px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx))
+                .when(self.padded, |this| {
+                    this.px(Spacing::Medium.rems(cx) + Spacing::Medium.rems(cx))
+                })
                 .children(self.children)
         };
 

crates/ui/src/traits/styled_ext.rs 🔗

@@ -3,7 +3,7 @@ use gpui::{hsla, Styled, WindowContext};
 use crate::prelude::*;
 use crate::ElevationIndex;
 
-fn elevated<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E {
+fn elevated<E: Styled>(this: E, cx: &WindowContext, index: ElevationIndex) -> E {
     this.bg(cx.theme().colors().elevated_surface_background)
         .rounded_lg()
         .border_1()
@@ -11,7 +11,7 @@ fn elevated<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -
         .shadow(index.shadow())
 }
 
-fn elevated_borderless<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E {
+fn elevated_borderless<E: Styled>(this: E, cx: &WindowContext, index: ElevationIndex) -> E {
     this.bg(cx.theme().colors().elevated_surface_background)
         .rounded_lg()
         .shadow(index.shadow())
@@ -38,14 +38,14 @@ pub trait StyledExt: Styled + Sized {
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
     ///
     /// Example Elements: Title Bar, Panel, Tab Bar, Editor
-    fn elevation_1(self, cx: &mut WindowContext) -> Self {
+    fn elevation_1(self, cx: &WindowContext) -> Self {
         elevated(self, cx, ElevationIndex::Surface)
     }
 
     /// See [`elevation_1`].
     ///
     /// Renders a borderless version [`elevation_1`].
-    fn elevation_1_borderless(self, cx: &mut WindowContext) -> Self {
+    fn elevation_1_borderless(self, cx: &WindowContext) -> Self {
         elevated_borderless(self, cx, ElevationIndex::Surface)
     }
 
@@ -54,14 +54,14 @@ pub trait StyledExt: Styled + Sized {
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
     ///
     /// Examples: Notifications, Palettes, Detached/Floating Windows, Detached/Floating Panels
-    fn elevation_2(self, cx: &mut WindowContext) -> Self {
+    fn elevation_2(self, cx: &WindowContext) -> Self {
         elevated(self, cx, ElevationIndex::ElevatedSurface)
     }
 
     /// See [`elevation_2`].
     ///
     /// Renders a borderless version [`elevation_2`].
-    fn elevation_2_borderless(self, cx: &mut WindowContext) -> Self {
+    fn elevation_2_borderless(self, cx: &WindowContext) -> Self {
         elevated_borderless(self, cx, ElevationIndex::ElevatedSurface)
     }
 
@@ -74,24 +74,24 @@ pub trait StyledExt: Styled + Sized {
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
     ///
     /// Examples: Settings Modal, Channel Management, Wizards/Setup UI, Dialogs
-    fn elevation_3(self, cx: &mut WindowContext) -> Self {
+    fn elevation_3(self, cx: &WindowContext) -> Self {
         elevated(self, cx, ElevationIndex::ModalSurface)
     }
 
     /// See [`elevation_3`].
     ///
     /// Renders a borderless version [`elevation_3`].
-    fn elevation_3_borderless(self, cx: &mut WindowContext) -> Self {
+    fn elevation_3_borderless(self, cx: &WindowContext) -> Self {
         elevated_borderless(self, cx, ElevationIndex::ModalSurface)
     }
 
     /// The theme's primary border color.
-    fn border_primary(self, cx: &mut WindowContext) -> Self {
+    fn border_primary(self, cx: &WindowContext) -> Self {
         self.border_color(cx.theme().colors().border)
     }
 
     /// The theme's secondary or muted border color.
-    fn border_muted(self, cx: &mut WindowContext) -> Self {
+    fn border_muted(self, cx: &WindowContext) -> Self {
         self.border_color(cx.theme().colors().border_variant)
     }