Rework file picker for SSH modal (#19020)

Piotr Osiewicz , Danilo , and Danilo Leal created

This PR changes the SSH modal design so its more keyboard
navigation-friendly and adds the server nickname feature.

Release Notes:

- N/A

---------

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

Change summary

Cargo.lock                                    |   2 
assets/keymaps/default-macos.json             |   1 
crates/file_finder/src/file_finder.rs         |   2 
crates/file_finder/src/open_path_prompt.rs    |  24 
crates/gpui/src/shared_string.rs              |  11 
crates/gpui/src/window.rs                     |   6 
crates/picker/src/picker.rs                   |   2 
crates/recent_projects/Cargo.toml             |   2 
crates/recent_projects/src/dev_servers.rs     | 806 ++++++++++++--------
crates/recent_projects/src/ssh_connections.rs | 102 +
crates/title_bar/src/title_bar.rs             |  13 
crates/ui/src/components/list/list_item.rs    |   3 
12 files changed, 576 insertions(+), 398 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -8968,6 +8968,7 @@ dependencies = [
  "client",
  "dev_server_projects",
  "editor",
+ "file_finder",
  "futures 0.3.30",
  "fuzzy",
  "gpui",
@@ -8988,7 +8989,6 @@ dependencies = [
  "task",
  "terminal_view",
  "ui",
- "ui_input",
  "util",
  "workspace",
 ]

assets/keymaps/default-macos.json πŸ”—

@@ -395,6 +395,7 @@
       // Change the default action on `menu::Confirm` by setting the parameter
       // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
       "alt-cmd-o": "projects::OpenRecent",
+      "ctrl-cmd-o": "projects::OpenRemote",
       "alt-cmd-b": "branches::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
       "cmd-s": "workspace::Save",

crates/file_finder/src/file_finder.rs πŸ”—

@@ -5,6 +5,8 @@ mod file_finder_settings;
 mod new_path_prompt;
 mod open_path_prompt;
 
+pub use open_path_prompt::OpenPathDelegate;
+
 use collections::HashMap;
 use editor::{scroll::Autoscroll, Bias, Editor};
 use file_finder_settings::FileFinderSettings;

crates/file_finder/src/open_path_prompt.rs πŸ”—

@@ -26,6 +26,20 @@ pub struct OpenPathDelegate {
     should_dismiss: bool,
 }
 
+impl OpenPathDelegate {
+    pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
+        Self {
+            tx: Some(tx),
+            lister,
+            selected_index: 0,
+            directory_state: None,
+            matches: Vec::new(),
+            cancel_flag: Arc::new(AtomicBool::new(false)),
+            should_dismiss: true,
+        }
+    }
+}
+
 struct DirectoryState {
     path: String,
     match_candidates: Vec<StringMatchCandidate>,
@@ -48,15 +62,7 @@ impl OpenPathPrompt {
         cx: &mut ViewContext<Workspace>,
     ) {
         workspace.toggle_modal(cx, |cx| {
-            let delegate = OpenPathDelegate {
-                tx: Some(tx),
-                lister: lister.clone(),
-                selected_index: 0,
-                directory_state: None,
-                matches: Vec::new(),
-                cancel_flag: Arc::new(AtomicBool::new(false)),
-                should_dismiss: true,
-            };
+            let delegate = OpenPathDelegate::new(tx, lister.clone());
 
             let picker = Picker::uniform_list(delegate, cx).width(rems(34.));
             let query = lister.default_query(cx);

crates/gpui/src/shared_string.rs πŸ”—

@@ -1,5 +1,6 @@
 use derive_more::{Deref, DerefMut};
 
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{borrow::Borrow, sync::Arc};
 use util::arc_cow::ArcCow;
@@ -16,6 +17,16 @@ impl SharedString {
     }
 }
 
+impl JsonSchema for SharedString {
+    fn schema_name() -> String {
+        String::schema_name()
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        String::json_schema(gen)
+    }
+}
+
 impl Default for SharedString {
     fn default() -> Self {
         Self(ArcCow::Owned(Arc::default()))

crates/gpui/src/window.rs πŸ”—

@@ -4905,6 +4905,12 @@ impl From<(&'static str, usize)> for ElementId {
     }
 }
 
+impl From<(SharedString, usize)> for ElementId {
+    fn from((name, id): (SharedString, usize)) -> Self {
+        ElementId::NamedInteger(name, id)
+    }
+}
+
 impl From<(&'static str, u64)> for ElementId {
     fn from((name, id): (&'static str, u64)) -> Self {
         ElementId::NamedInteger(name.into(), id as usize)

crates/picker/src/picker.rs πŸ”—

@@ -476,7 +476,7 @@ impl<D: PickerDelegate> Picker<D> {
         }
     }
 
-    pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut ViewContext<Self>) {
+    pub fn set_query(&self, query: impl Into<Arc<str>>, cx: &mut WindowContext<'_>) {
         if let Head::Editor(ref editor) = &self.head {
             editor.update(cx, |editor, cx| {
                 editor.set_text(query, cx);

crates/recent_projects/Cargo.toml πŸ”—

@@ -18,6 +18,7 @@ auto_update.workspace = true
 release_channel.workspace = true
 client.workspace = true
 editor.workspace = true
+file_finder.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
@@ -36,7 +37,6 @@ smol.workspace = true
 task.workspace = true
 terminal_view.workspace = true
 ui.workspace = true
-ui_input.workspace = true
 util.workspace = true
 workspace.workspace = true
 

crates/recent_projects/src/dev_servers.rs πŸ”—

@@ -1,5 +1,6 @@
 use std::collections::HashMap;
 use std::path::PathBuf;
+use std::sync::Arc;
 use std::time::Duration;
 
 use anyhow::anyhow;
@@ -7,39 +8,46 @@ use anyhow::Context;
 use anyhow::Result;
 use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
 use editor::Editor;
+use file_finder::OpenPathDelegate;
+use futures::channel::oneshot;
+use futures::future::Shared;
+use futures::FutureExt;
+use gpui::canvas;
 use gpui::pulsating_between;
 use gpui::AsyncWindowContext;
 use gpui::ClipboardItem;
-use gpui::PathPromptOptions;
 use gpui::Subscription;
 use gpui::Task;
 use gpui::WeakView;
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
-    FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext,
+    Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
+    FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext,
 };
+use picker::Picker;
 use project::terminals::wrap_for_ssh;
 use project::terminals::SshCommand;
-use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
+use project::Project;
+use rpc::proto::DevServerStatus;
 use settings::update_settings_file;
 use settings::Settings;
 use task::HideStrategy;
 use task::RevealStrategy;
 use task::SpawnInTerminal;
 use terminal_view::terminal_panel::TerminalPanel;
-use ui::ElevationIndex;
 use ui::Section;
-use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip};
-use ui_input::{FieldLabelLayout, TextField};
+use ui::{prelude::*, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
 use util::ResultExt;
+use workspace::notifications::NotificationId;
 use workspace::OpenOptions;
-use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
+use workspace::Toast;
+use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
 
 use crate::open_dev_server_project;
 use crate::ssh_connections::connect_over_ssh;
 use crate::ssh_connections::open_ssh_project;
 use crate::ssh_connections::RemoteSettingsContent;
 use crate::ssh_connections::SshConnection;
+use crate::ssh_connections::SshConnectionHeader;
 use crate::ssh_connections::SshConnectionModal;
 use crate::ssh_connections::SshProject;
 use crate::ssh_connections::SshPrompt;
@@ -52,24 +60,251 @@ pub struct DevServerProjects {
     scroll_handle: ScrollHandle,
     dev_server_store: Model<dev_server_projects::Store>,
     workspace: WeakView<Workspace>,
-    project_path_input: View<Editor>,
-    dev_server_name_input: View<TextField>,
     _dev_server_subscription: Subscription,
+    selectable_items: SelectableItemList,
 }
 
-#[derive(Default)]
 struct CreateDevServer {
+    address_editor: View<Editor>,
     creating: Option<Task<Option<()>>>,
     ssh_prompt: Option<View<SshPrompt>>,
 }
 
-struct CreateDevServerProject {
-    dev_server_id: DevServerId,
-    _opening: Option<Subscription>,
+impl CreateDevServer {
+    fn new(cx: &mut WindowContext<'_>) -> Self {
+        let address_editor = cx.new_view(Editor::single_line);
+        address_editor.update(cx, |this, cx| {
+            this.focus_handle(cx).focus(cx);
+        });
+        Self {
+            address_editor,
+            creating: None,
+            ssh_prompt: None,
+        }
+    }
+}
+
+struct ProjectPicker {
+    connection_string: SharedString,
+    picker: View<Picker<OpenPathDelegate>>,
+    _path_task: Shared<Task<Option<()>>>,
+}
+
+type SelectedItemCallback =
+    Box<dyn Fn(&mut DevServerProjects, &mut ViewContext<DevServerProjects>) + 'static>;
+
+/// Used to implement keyboard navigation for SSH modal.
+#[derive(Default)]
+struct SelectableItemList {
+    items: Vec<SelectedItemCallback>,
+    active_item: Option<usize>,
+}
+
+struct EditNicknameState {
+    index: usize,
+    editor: View<Editor>,
+}
+
+impl EditNicknameState {
+    fn new(index: usize, cx: &mut WindowContext<'_>) -> Self {
+        let this = Self {
+            index,
+            editor: cx.new_view(Editor::single_line),
+        };
+        let starting_text = SshSettings::get_global(cx)
+            .ssh_connections()
+            .nth(index)
+            .and_then(|state| state.nickname.clone())
+            .filter(|text| !text.is_empty());
+        this.editor.update(cx, |this, cx| {
+            this.set_placeholder_text("Add a nickname for this server", cx);
+            if let Some(starting_text) = starting_text {
+                this.set_text(starting_text, cx);
+            }
+        });
+        this.editor.focus_handle(cx).focus(cx);
+        this
+    }
 }
 
+impl SelectableItemList {
+    fn reset(&mut self) {
+        self.items.clear();
+    }
+
+    fn reset_selection(&mut self) {
+        self.active_item.take();
+    }
+
+    fn prev(&mut self, _: &mut WindowContext<'_>) {
+        match self.active_item.as_mut() {
+            Some(active_index) => {
+                *active_index = active_index.checked_sub(1).unwrap_or(self.items.len() - 1)
+            }
+            None => {
+                self.active_item = Some(self.items.len() - 1);
+            }
+        }
+    }
+
+    fn next(&mut self, _: &mut WindowContext<'_>) {
+        match self.active_item.as_mut() {
+            Some(active_index) => {
+                if *active_index + 1 < self.items.len() {
+                    *active_index += 1;
+                } else {
+                    *active_index = 0;
+                }
+            }
+            None => {
+                self.active_item = Some(0);
+            }
+        }
+    }
+
+    fn add_item(&mut self, callback: SelectedItemCallback) {
+        self.items.push(callback)
+    }
+
+    fn is_selected(&self) -> bool {
+        self.active_item == self.items.len().checked_sub(1)
+    }
+
+    fn confirm(&self, dev_modal: &mut DevServerProjects, cx: &mut ViewContext<DevServerProjects>) {
+        if let Some(active_item) = self.active_item.and_then(|ix| self.items.get(ix)) {
+            active_item(dev_modal, cx);
+        }
+    }
+}
+
+impl ProjectPicker {
+    fn new(
+        ix: usize,
+        connection_string: SharedString,
+        project: Model<Project>,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<DevServerProjects>,
+    ) -> View<Self> {
+        let (tx, rx) = oneshot::channel();
+        let lister = project::DirectoryLister::Project(project.clone());
+        let query = lister.default_query(cx);
+        let delegate = file_finder::OpenPathDelegate::new(tx, lister);
+
+        let picker = cx.new_view(|cx| {
+            let picker = Picker::uniform_list(delegate, cx)
+                .width(rems(34.))
+                .modal(false);
+            picker.set_query(query, cx);
+            picker
+        });
+        cx.new_view(|cx| {
+            let _path_task = cx
+                .spawn({
+                    let workspace = workspace.clone();
+                    move |_, mut cx| async move {
+                        let Ok(Some(paths)) = rx.await else {
+                            workspace
+                                .update(&mut cx, |workspace, cx| {
+                                    let weak = cx.view().downgrade();
+                                    workspace
+                                        .toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
+                                })
+                                .log_err()?;
+                            return None;
+                        };
+
+                        let app_state = workspace
+                            .update(&mut cx, |workspace, _| workspace.app_state().clone())
+                            .ok()?;
+                        let options = cx
+                            .update(|cx| (app_state.build_window_options)(None, cx))
+                            .log_err()?;
+
+                        cx.open_window(options, |cx| {
+                            cx.activate_window();
+
+                            let fs = app_state.fs.clone();
+                            update_settings_file::<SshSettings>(fs, cx, {
+                                let paths = paths
+                                    .iter()
+                                    .map(|path| path.to_string_lossy().to_string())
+                                    .collect();
+                                move |setting, _| {
+                                    if let Some(server) = setting
+                                        .ssh_connections
+                                        .as_mut()
+                                        .and_then(|connections| connections.get_mut(ix))
+                                    {
+                                        server.projects.push(SshProject { paths })
+                                    }
+                                }
+                            });
+
+                            let tasks = paths
+                                .into_iter()
+                                .map(|path| {
+                                    project.update(cx, |project, cx| {
+                                        project.find_or_create_worktree(&path, true, cx)
+                                    })
+                                })
+                                .collect::<Vec<_>>();
+                            cx.spawn(|_| async move {
+                                for task in tasks {
+                                    task.await?;
+                                }
+                                Ok(())
+                            })
+                            .detach_and_prompt_err(
+                                "Failed to open path",
+                                cx,
+                                |_, _| None,
+                            );
+
+                            cx.new_view(|cx| {
+                                let workspace =
+                                    Workspace::new(None, project.clone(), app_state.clone(), cx);
+
+                                workspace
+                                    .client()
+                                    .telemetry()
+                                    .report_app_event("create ssh project".to_string());
+
+                                workspace
+                            })
+                        })
+                        .log_err();
+                        Some(())
+                    }
+                })
+                .shared();
+
+            Self {
+                _path_task,
+                picker,
+                connection_string,
+            }
+        })
+    }
+}
+
+impl gpui::Render for ProjectPicker {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .child(
+                SshConnectionHeader {
+                    connection_string: self.connection_string.clone(),
+                    nickname: None,
+                }
+                .render(cx),
+            )
+            .child(self.picker.clone())
+    }
+}
 enum Mode {
-    Default(Option<CreateDevServerProject>),
+    Default,
+    ViewServerOptions(usize, SshConnection),
+    EditNickname(EditNicknameState),
+    ProjectPicker(View<ProjectPicker>),
     CreateDevServer(CreateDevServer),
 }
 
@@ -89,15 +324,6 @@ impl DevServerProjects {
     }
 
     pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
-        let project_path_input = cx.new_view(|cx| {
-            let mut editor = Editor::single_line(cx);
-            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
-            editor
-        });
-        let dev_server_name_input = cx.new_view(|cx| {
-            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
-        });
-
         let focus_handle = cx.focus_handle();
         let dev_server_store = dev_server_projects::Store::global(cx);
 
@@ -112,131 +338,49 @@ impl DevServerProjects {
         });
 
         Self {
-            mode: Mode::Default(None),
+            mode: Mode::Default,
             focus_handle,
             scroll_handle: ScrollHandle::new(),
             dev_server_store,
-            project_path_input,
-            dev_server_name_input,
             workspace,
             _dev_server_subscription: subscription,
+            selectable_items: Default::default(),
         }
     }
 
-    pub fn create_dev_server_project(
-        &mut self,
-        dev_server_id: DevServerId,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
-
-        if path.is_empty() {
+    fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
+        if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
             return;
         }
-
-        if !path.starts_with('/') && !path.starts_with('~') {
-            path = format!("~/{}", path);
-        }
-
-        if self
-            .dev_server_store
-            .read(cx)
-            .projects_for_server(dev_server_id)
-            .iter()
-            .any(|p| p.paths.iter().any(|p| p == &path))
-        {
-            cx.spawn(|_, mut cx| async move {
-                cx.prompt(
-                    gpui::PromptLevel::Critical,
-                    "Failed to create project",
-                    Some(&format!("{} is already open on this dev server.", path)),
-                    &["Ok"],
-                )
-                .await
-            })
-            .detach_and_log_err(cx);
+        self.selectable_items.next(cx);
+    }
+    fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
+        if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
             return;
         }
+        self.selectable_items.prev(cx);
+    }
+    pub fn project_picker(
+        ix: usize,
+        connection_options: remote::SshConnectionOptions,
+        project: Model<Project>,
+        cx: &mut ViewContext<Self>,
+        workspace: WeakView<Workspace>,
+    ) -> Self {
+        let mut this = Self::new(cx, workspace.clone());
+        this.mode = Mode::ProjectPicker(ProjectPicker::new(
+            ix,
+            connection_options.connection_string().into(),
+            project,
+            workspace,
+            cx,
+        ));
 
-        let create = {
-            let path = path.clone();
-            self.dev_server_store.update(cx, |store, cx| {
-                store.create_dev_server_project(dev_server_id, path, cx)
-            })
-        };
-
-        cx.spawn(|this, mut cx| async move {
-            let result = create.await;
-            this.update(&mut cx, |this, cx| {
-                if let Ok(result) = &result {
-                    if let Some(dev_server_project_id) =
-                        result.dev_server_project.as_ref().map(|p| p.id)
-                    {
-                        let subscription =
-                            cx.observe(&this.dev_server_store, move |this, store, cx| {
-                                if let Some(project_id) = store
-                                    .read(cx)
-                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
-                                    .and_then(|p| p.project_id)
-                                {
-                                    this.project_path_input.update(cx, |editor, cx| {
-                                        editor.set_text("", cx);
-                                    });
-                                    this.mode = Mode::Default(None);
-                                    if let Some(app_state) = AppState::global(cx).upgrade() {
-                                        workspace::join_dev_server_project(
-                                            DevServerProjectId(dev_server_project_id),
-                                            project_id,
-                                            app_state,
-                                            None,
-                                            cx,
-                                        )
-                                        .detach_and_prompt_err(
-                                            "Could not join project",
-                                            cx,
-                                            |_, _| None,
-                                        )
-                                    }
-                                }
-                            });
-
-                        this.mode = Mode::Default(Some(CreateDevServerProject {
-                            dev_server_id,
-                            _opening: Some(subscription),
-                        }));
-                    }
-                } else {
-                    this.mode = Mode::Default(Some(CreateDevServerProject {
-                        dev_server_id,
-                        _opening: None,
-                    }));
-                }
-            })
-            .log_err();
-            result
-        })
-        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
-            match e.error_code() {
-                ErrorCode::DevServerOffline => Some(
-                    "The dev server is offline. Please log in and check it is connected."
-                        .to_string(),
-                ),
-                ErrorCode::DevServerProjectPathDoesNotExist => {
-                    Some(format!("The path `{}` does not exist on the server.", path))
-                }
-                _ => None,
-            }
-        });
-
-        self.mode = Mode::Default(Some(CreateDevServerProject {
-            dev_server_id,
-
-            _opening: None,
-        }));
+        this
     }
 
-    fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
-        let host = get_text(&self.dev_server_name_input, cx);
+    fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
+        let host = get_text(&editor, cx);
         if host.is_empty() {
             return;
         }
@@ -287,23 +431,35 @@ impl DevServerProjects {
                         });
 
                         this.add_ssh_server(connection_options, cx);
-                        this.mode = Mode::Default(None);
+                        this.mode = Mode::Default;
+                        this.selectable_items.reset_selection();
                         cx.notify()
                     })
                     .log_err(),
                 None => this
                     .update(&mut cx, |this, cx| {
-                        this.mode = Mode::CreateDevServer(CreateDevServer::default());
+                        this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
                         cx.notify()
                     })
                     .log_err(),
             };
             None
         });
-        self.mode = Mode::CreateDevServer(CreateDevServer {
-            ssh_prompt: Some(ssh_prompt.clone()),
-            creating: Some(creating),
-        });
+        let mut state = CreateDevServer::new(cx);
+        state.address_editor = editor;
+        state.ssh_prompt = Some(ssh_prompt.clone());
+        state.creating = Some(creating);
+        self.mode = Mode::CreateDevServer(state);
+    }
+
+    fn view_server_options(
+        &mut self,
+        (index, connection): (usize, SshConnection),
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.selectable_items.reset_selection();
+        self.mode = Mode::ViewServerOptions(index, connection);
+        cx.notify();
     }
 
     fn create_ssh_project(
@@ -331,12 +487,12 @@ impl DevServerProjects {
 
                 let connect = connect_over_ssh(
                     connection_options.dev_server_identifier(),
-                    connection_options,
+                    connection_options.clone(),
                     prompt,
                     cx,
                 )
                 .prompt_err("Failed to connect", cx, |_, _| None);
-                cx.spawn(|workspace, mut cx| async move {
+                cx.spawn(move |workspace, mut cx| async move {
                     let Some(session) = connect.await else {
                         workspace
                             .update(&mut cx, |workspace, cx| {
@@ -346,9 +502,11 @@ impl DevServerProjects {
                             .log_err();
                         return;
                     };
-                    let Ok((app_state, project, paths)) =
-                        workspace.update(&mut cx, |workspace, cx| {
+
+                    workspace
+                        .update(&mut cx, |workspace, cx| {
                             let app_state = workspace.app_state().clone();
+                            let weak = cx.view().downgrade();
                             let project = project::Project::ssh(
                                 session,
                                 app_state.client.clone(),
@@ -358,91 +516,17 @@ impl DevServerProjects {
                                 app_state.fs.clone(),
                                 cx,
                             );
-                            let paths = workspace.prompt_for_open_path(
-                                PathPromptOptions {
-                                    files: true,
-                                    directories: true,
-                                    multiple: true,
-                                },
-                                project::DirectoryLister::Project(project.clone()),
-                                cx,
-                            );
-                            (app_state, project, paths)
-                        })
-                    else {
-                        return;
-                    };
-
-                    let Ok(Some(paths)) = paths.await else {
-                        workspace
-                            .update(&mut cx, |workspace, cx| {
-                                let weak = cx.view().downgrade();
-                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
-                            })
-                            .log_err();
-                        return;
-                    };
-
-                    let Some(options) = cx
-                        .update(|cx| (app_state.build_window_options)(None, cx))
-                        .log_err()
-                    else {
-                        return;
-                    };
-
-                    cx.open_window(options, |cx| {
-                        cx.activate_window();
-
-                        let fs = app_state.fs.clone();
-                        update_settings_file::<SshSettings>(fs, cx, {
-                            let paths = paths
-                                .iter()
-                                .map(|path| path.to_string_lossy().to_string())
-                                .collect();
-                            move |setting, _| {
-                                if let Some(server) = setting
-                                    .ssh_connections
-                                    .as_mut()
-                                    .and_then(|connections| connections.get_mut(ix))
-                                {
-                                    server.projects.push(SshProject { paths })
-                                }
-                            }
-                        });
-
-                        let tasks = paths
-                            .into_iter()
-                            .map(|path| {
-                                project.update(cx, |project, cx| {
-                                    project.find_or_create_worktree(&path, true, cx)
-                                })
-                            })
-                            .collect::<Vec<_>>();
-                        cx.spawn(|_| async move {
-                            for task in tasks {
-                                task.await?;
-                            }
-                            Ok(())
-                        })
-                        .detach_and_prompt_err(
-                            "Failed to open path",
-                            cx,
-                            |_, _| None,
-                        );
-
-                        cx.new_view(|cx| {
-                            let workspace =
-                                Workspace::new(None, project.clone(), app_state.clone(), cx);
-
-                            workspace
-                                .client()
-                                .telemetry()
-                                .report_app_event("create ssh project".to_string());
-
-                            workspace
+                            workspace.toggle_modal(cx, |cx| {
+                                DevServerProjects::project_picker(
+                                    ix,
+                                    connection_options,
+                                    project,
+                                    cx,
+                                    weak,
+                                )
+                            });
                         })
-                    })
-                    .log_err();
+                        .ok();
                 })
                 .detach()
             })
@@ -451,10 +535,12 @@ impl DevServerProjects {
 
     fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
         match &self.mode {
-            Mode::Default(None) => {}
-            Mode::Default(Some(create_project)) => {
-                self.create_dev_server_project(create_project.dev_server_id, cx);
+            Mode::Default | Mode::ViewServerOptions(_, _) => {
+                let items = std::mem::take(&mut self.selectable_items);
+                items.confirm(self, cx);
+                self.selectable_items = items;
             }
+            Mode::ProjectPicker(_) => {}
             Mode::CreateDevServer(state) => {
                 if let Some(prompt) = state.ssh_prompt.as_ref() {
                     prompt.update(cx, |prompt, cx| {
@@ -463,22 +549,41 @@ impl DevServerProjects {
                     return;
                 }
 
-                self.create_ssh_server(cx);
+                state.address_editor.update(cx, |this, _| {
+                    this.set_read_only(true);
+                });
+                self.create_ssh_server(state.address_editor.clone(), cx);
+            }
+            Mode::EditNickname(state) => {
+                let text = Some(state.editor.read(cx).text(cx))
+                    .filter(|text| !text.is_empty())
+                    .map(SharedString::from);
+                let index = state.index;
+                self.update_settings_file(cx, move |setting, _| {
+                    if let Some(connections) = setting.ssh_connections.as_mut() {
+                        if let Some(connection) = connections.get_mut(index) {
+                            connection.nickname = text;
+                        }
+                    }
+                });
+                self.mode = Mode::Default;
+                self.selectable_items.reset_selection();
+                self.focus_handle.focus(cx);
             }
         }
     }
 
     fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
         match &self.mode {
-            Mode::Default(None) => cx.emit(DismissEvent),
+            Mode::Default => cx.emit(DismissEvent),
             Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
-                self.mode = Mode::CreateDevServer(CreateDevServer {
-                    ..Default::default()
-                });
+                self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
+                self.selectable_items.reset_selection();
                 cx.notify();
             }
             _ => {
-                self.mode = Mode::Default(None);
+                self.mode = Mode::Default;
+                self.selectable_items.reset_selection();
                 self.focus_handle(cx).focus(cx);
                 cx.notify();
             }
@@ -491,126 +596,119 @@ impl DevServerProjects {
         ssh_connection: SshConnection,
         cx: &mut ViewContext<Self>,
     ) -> impl IntoElement {
+        let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
+            let aux_label = SharedString::from(format!("({})", ssh_connection.host));
+            (nickname, Some(aux_label))
+        } else {
+            (ssh_connection.host.clone(), None)
+        };
         v_flex()
             .w_full()
-            .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .mb_1()
             .child(
                 h_flex()
-                    .w_full()
                     .group("ssh-server")
-                    .justify_between()
+                    .w_full()
+                    .pt_0p5()
+                    .px_2p5()
+                    .gap_1()
+                    .overflow_hidden()
+                    .whitespace_nowrap()
+                    .w_full()
                     .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())),
-                            ),
+                        Label::new(main_label)
+                            .size(LabelSize::Small)
+                            .weight(FontWeight::SEMIBOLD)
+                            .color(Color::Muted),
                     )
-                    .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))
-                            }),
+                    .children(
+                        aux_label.map(|label| {
+                            Label::new(label).size(LabelSize::Small).color(Color::Muted)
+                        }),
                     ),
             )
             .child(
-                v_flex()
-                    .w_full()
-                    .border_l_1()
-                    .border_color(cx.theme().colors().border_variant)
-                    .mb_1()
-                    .mx_1p5()
-                    .pl_2()
-                    .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().mt_1().pl_1().child(
-                                    Button::new(("new-remote_project", ix), "Open Folder…")
-                                        .size(ButtonSize::Default)
-                                        .layer(ElevationIndex::ModalSurface)
-                                        .icon(IconName::Plus)
-                                        .icon_color(Color::Muted)
-                                        .icon_position(IconPosition::Start)
-                                        .on_click(cx.listener(move |this, _, cx| {
+                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,
+                                            );
+                                        }
+                                    })),
+                            )
+                        })),
+                ),
             )
     }
 
     fn render_ssh_project(
-        &self,
+        &mut self,
         server_ix: usize,
         server: &SshConnection,
         ix: usize,
         project: &SshProject,
         cx: &ViewContext<Self>,
     ) -> impl IntoElement {
-        let project = project.clone();
         let server = server.clone();
 
-        ListItem::new(("remote-project", ix))
-            .inset(true)
-            .spacing(ui::ListItemSpacing::Sparse)
-            .start_slot(
-                Icon::new(IconName::Folder)
-                    .color(Color::Muted)
-                    .size(IconSize::Small),
-            )
-            .child(Label::new(project.paths.join(", ")))
-            .on_click(cx.listener(move |this, _, cx| {
+        let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
+        let callback = Arc::new({
+            let project = project.clone();
+            move |this: &mut Self, cx: &mut ViewContext<Self>| {
                 let Some(app_state) = this
                     .workspace
                     .update(cx, |workspace, _| workspace.app_state().clone())
@@ -642,12 +740,32 @@ impl DevServerProjects {
                     }
                 })
                 .detach();
-            }))
+            }
+        });
+        self.selectable_items.add_item(Box::new({
+            let callback = callback.clone();
+            move |this, cx| callback(this, cx)
+        }));
+        let is_selected = self.selectable_items.is_selected();
+
+        ListItem::new((element_id_base, ix))
+            .inset(true)
+            .selected(is_selected)
+            .spacing(ui::ListItemSpacing::Sparse)
+            .start_slot(
+                Icon::new(IconName::Folder)
+                    .color(Color::Muted)
+                    .size(IconSize::Small),
+            )
+            .child(Label::new(project.paths.join(", ")))
+            .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
             .end_hover_slot::<AnyElement>(Some(
                 IconButton::new("remove-remote-project", IconName::TrashAlt)
+                    .icon_size(IconSize::Small)
                     .on_click(
                         cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
                     )
+                    .size(ButtonSize::Large)
                     .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
                     .into_any_element(),
             ))
@@ -698,10 +816,11 @@ impl DevServerProjects {
                 .ssh_connections
                 .get_or_insert(Default::default())
                 .push(SshConnection {
-                    host: connection_options.host,
+                    host: SharedString::from(connection_options.host),
                     username: connection_options.username,
                     port: connection_options.port,
                     projects: vec![],
+                    nickname: None,
                 })
         });
     }
@@ -711,16 +830,17 @@ impl DevServerProjects {
         state: &CreateDevServer,
         cx: &mut ViewContext<Self>,
     ) -> impl IntoElement {
-        let creating = state.creating.is_some();
         let ssh_prompt = state.ssh_prompt.clone();
 
-        self.dev_server_name_input.update(cx, |input, cx| {
-            input.editor().update(cx, |editor, cx| {
-                if editor.text(cx).is_empty() {
-                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
-                }
-            })
+        state.address_editor.update(cx, |editor, cx| {
+            if editor.text(cx).is_empty() {
+                editor.set_placeholder_text(
+                    "Enter the command you use to SSH into this server: e.g., ssh me@my.server",
+                    cx,
+                );
+            }
         });
+
         let theme = cx.theme();
 
         v_flex()

crates/recent_projects/src/ssh_connections.rs πŸ”—

@@ -16,9 +16,9 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
 use ui::{
-    div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton,
-    IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip,
-    ViewContext, VisualContext, WindowContext,
+    div, h_flex, prelude::*, v_flex, ActiveTheme, Color, Icon, IconName, IconSize,
+    InteractiveElement, IntoElement, Label, LabelCommon, Styled, ViewContext, VisualContext,
+    WindowContext,
 };
 use workspace::{AppState, ModalView, Workspace};
 
@@ -35,17 +35,20 @@ impl SshSettings {
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct SshConnection {
-    pub host: String,
+    pub host: SharedString,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub username: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub port: Option<u16>,
     pub projects: Vec<SshProject>,
+    /// Name to use for this server in UI.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub nickname: Option<SharedString>,
 }
 impl From<SshConnection> for SshConnectionOptions {
     fn from(val: SshConnection) -> Self {
         SshConnectionOptions {
-            host: val.host,
+            host: val.host.into(),
             username: val.username,
             port: val.port,
             password: None,
@@ -87,7 +90,10 @@ pub struct SshConnectionModal {
 }
 
 impl SshPrompt {
-    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
+    pub(crate) fn new(
+        connection_options: &SshConnectionOptions,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
         let connection_string = connection_options.connection_string().into();
         Self {
             connection_string,
@@ -231,12 +237,57 @@ impl SshConnectionModal {
     }
 }
 
+pub(crate) struct SshConnectionHeader {
+    pub(crate) connection_string: SharedString,
+    pub(crate) nickname: Option<SharedString>,
+}
+
+impl RenderOnce for SshConnectionHeader {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let theme = cx.theme();
+
+        let mut header_color = theme.colors().text;
+        header_color.fade_out(0.96);
+
+        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
+            (nickname, Some(format!("({})", self.connection_string)))
+        } else {
+            (self.connection_string, None)
+        };
+
+        h_flex()
+            .p_1()
+            .rounded_t_md()
+            .w_full()
+            .gap_2()
+            .justify_center()
+            .border_b_1()
+            .border_color(theme.colors().border_variant)
+            .bg(header_color)
+            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
+            .child(
+                h_flex()
+                    .gap_1()
+                    .child(
+                        Label::new(main_label)
+                            .size(ui::LabelSize::Small)
+                            .single_line(),
+                    )
+                    .children(meta_label.map(|label| {
+                        Label::new(label)
+                            .size(ui::LabelSize::Small)
+                            .single_line()
+                            .color(Color::Muted)
+                    })),
+            )
+    }
+}
+
 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 mut header_color = cx.theme().colors().text;
-        header_color.fade_out(0.96);
+
         let body_color = theme.colors().editor_background;
 
         v_flex()
@@ -248,36 +299,11 @@ impl Render for SshConnectionModal {
             .border_1()
             .border_color(theme.colors().border)
             .child(
-                h_flex()
-                    .relative()
-                    .p_1()
-                    .rounded_t_md()
-                    .border_b_1()
-                    .border_color(theme.colors().border)
-                    .bg(header_color)
-                    .justify_between()
-                    .child(
-                        div().absolute().left_0p5().top_0p5().child(
-                            IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
-                                .icon_size(IconSize::XSmall)
-                                .on_click(cx.listener(move |this, _, cx| {
-                                    this.dismiss(&Default::default(), cx);
-                                }))
-                                .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
-                        ),
-                    )
-                    .child(
-                        h_flex()
-                            .w_full()
-                            .gap_2()
-                            .justify_center()
-                            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
-                            .child(
-                                Label::new(connection_string)
-                                    .size(ui::LabelSize::Small)
-                                    .single_line(),
-                            ),
-                    ),
+                SshConnectionHeader {
+                    connection_string,
+                    nickname: None,
+                }
+                .render(cx),
             )
             .child(
                 h_flex()

crates/title_bar/src/title_bar.rs πŸ”—

@@ -293,15 +293,20 @@ impl TitleBar {
 
         let meta = SharedString::from(meta);
 
-        let indicator = div()
+        let indicator = h_flex()
+            // We're using the circle inside a circle approach because, otherwise, by using borders
+            // we'd get a very thin, leaking indicator color, which is not what we want.
             .absolute()
             .size_2p5()
             .right_0()
             .bottom_0()
+            .bg(indicator_border_color)
+            .size_2p5()
             .rounded_full()
-            .border_2()
-            .border_color(indicator_border_color)
-            .bg(indicator_color.color(cx));
+            .items_center()
+            .justify_center()
+            .overflow_hidden()
+            .child(Indicator::dot().color(indicator_color));
 
         Some(
             div()

crates/ui/src/components/list/list_item.rs πŸ”—

@@ -193,6 +193,7 @@ impl RenderOnce for ListItem {
                     .id("inner_list_item")
                     .w_full()
                     .relative()
+                    .items_center()
                     .gap_1()
                     .px(Spacing::Medium.rems(cx))
                     .map(|this| match self.spacing {
@@ -247,7 +248,7 @@ impl RenderOnce for ListItem {
                             .flex_grow()
                             .flex_shrink_0()
                             .flex_basis(relative(0.25))
-                            .gap(Spacing::Small.rems(cx))
+                            .gap(Spacing::Medium.rems(cx))
                             .map(|list_content| {
                                 if self.overflow_x {
                                     list_content