Allow ssh connection for setting up zed (#12063)

Conrad Irwin , Mikayla , and Nate Butler created

Co-Authored-By: Mikayla <mikayla@zed.dev>



Release Notes:

- Magic `ssh` login feature for remote development

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>

Change summary

Cargo.lock                                            |   4 
Cargo.toml                                            |   1 
crates/assistant/src/prompt_library.rs                |   4 
crates/collab/src/db/queries/dev_servers.rs           |   2 
crates/collab/src/rpc.rs                              |   7 
crates/collab/src/tests/dev_server_tests.rs           |   1 
crates/dev_server_projects/src/dev_server_projects.rs |   2 
crates/markdown/src/markdown.rs                       |  15 
crates/project/Cargo.toml                             |   2 
crates/project/src/terminals.rs                       | 298 ++-
crates/recent_projects/Cargo.toml                     |   2 
crates/recent_projects/src/dev_servers.rs             | 863 +++++-------
crates/recent_projects/src/recent_projects.rs         |   3 
crates/rpc/proto/zed.proto                            |   1 
crates/task/src/lib.rs                                |  34 
crates/task/src/task_template.rs                      |  29 
crates/terminal_view/src/terminal_panel.rs            | 118 
crates/terminal_view/src/terminal_view.rs             |  58 
crates/ui/src/components.rs                           |   2 
crates/ui/src/components/button/button.rs             |   7 
crates/ui/src/components/button/button_like.rs        |  62 
crates/ui/src/components/button/icon_button.rs        |   7 
crates/ui/src/components/button/toggle_button.rs      |   7 
crates/ui/src/components/modal.rs                     | 322 ++++
crates/ui/src/components/radio.rs                     |  61 
crates/ui/src/styles/elevation.rs                     |   6 
crates/ui/src/styles/typography.rs                    |   9 
docs/src/remote-development.md                        |  52 
script/install.sh                                     |  14 
29 files changed, 1,226 insertions(+), 767 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -7683,10 +7683,12 @@ dependencies = [
  "serde_json",
  "settings",
  "sha2 0.10.7",
+ "shlex",
  "similar",
  "smol",
  "snippet",
  "task",
+ "tempfile",
  "terminal",
  "text",
  "unindent",
@@ -8056,6 +8058,8 @@ dependencies = [
  "serde",
  "serde_json",
  "smol",
+ "task",
+ "terminal_view",
  "ui",
  "ui_text_field",
  "util",

Cargo.toml πŸ”—

@@ -330,6 +330,7 @@ serde_json_lenient = { version = "0.1", features = [
 serde_repr = "0.1"
 sha2 = "0.10"
 shellexpand = "2.1.0"
+shlex = "1.3.0"
 smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2"
 strum = { version = "0.25.0", features = ["derive"] }

crates/assistant/src/prompt_library.rs πŸ”—

@@ -251,8 +251,8 @@ impl Render for PromptManager {
             .h(rems(40.))
             .overflow_hidden()
             .child(
-                ModalHeader::new("prompt-manager-header")
-                    .child(Headline::new("Prompt Library").size(HeadlineSize::Small))
+                ModalHeader::new()
+                    .headline("Prompt Library")
                     .show_dismiss_button(true),
             )
             .child(

crates/collab/src/db/queries/dev_servers.rs πŸ”—

@@ -137,6 +137,7 @@ impl Database {
         &self,
         id: DevServerId,
         name: &str,
+        ssh_connection_string: &Option<String>,
         user_id: UserId,
     ) -> crate::Result<proto::DevServerProjectsUpdate> {
         self.transaction(|tx| async move {
@@ -149,6 +150,7 @@ impl Database {
 
             dev_server::Entity::update(dev_server::ActiveModel {
                 name: ActiveValue::Set(name.trim().to_string()),
+                ssh_connection_string: ActiveValue::Set(ssh_connection_string.clone()),
                 ..dev_server.clone().into_active_model()
             })
             .exec(&*tx)

crates/collab/src/rpc.rs πŸ”—

@@ -2439,7 +2439,12 @@ async fn rename_dev_server(
     let status = session
         .db()
         .await
-        .rename_dev_server(dev_server_id, &request.name, session.user_id())
+        .rename_dev_server(
+            dev_server_id,
+            &request.name,
+            &request.ssh_connection_string,
+            session.user_id(),
+        )
         .await?;
 
     send_dev_server_projects_update(session.user_id(), status, &session).await;

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

@@ -185,6 +185,7 @@ impl Store {
         &mut self,
         dev_server_id: DevServerId,
         name: String,
+        ssh_connection_string: Option<String>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
         let client = self.client.clone();
@@ -193,6 +194,7 @@ impl Store {
                 .request(proto::RenameDevServer {
                     dev_server_id: dev_server_id.0,
                     name,
+                    ssh_connection_string,
                 })
                 .await?;
             Ok(())

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

@@ -73,6 +73,9 @@ impl Markdown {
     }
 
     pub fn reset(&mut self, source: String, cx: &mut ViewContext<Self>) {
+        if source == self.source() {
+            return;
+        }
         self.source = source;
         self.selection = Selection::default();
         self.autoscroll_request = None;
@@ -544,8 +547,10 @@ impl Element for MarkdownElement {
                             })
                         }
                         MarkdownTag::Link { dest_url, .. } => {
-                            builder.push_link(dest_url.clone(), range.clone());
-                            builder.push_text_style(self.style.link.clone())
+                            if builder.code_block_stack.is_empty() {
+                                builder.push_link(dest_url.clone(), range.clone());
+                                builder.push_text_style(self.style.link.clone())
+                            }
                         }
                         _ => log::error!("unsupported markdown tag {:?}", tag),
                     }
@@ -577,7 +582,11 @@ impl Element for MarkdownElement {
                     MarkdownTagEnd::Emphasis => builder.pop_text_style(),
                     MarkdownTagEnd::Strong => builder.pop_text_style(),
                     MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
-                    MarkdownTagEnd::Link => builder.pop_text_style(),
+                    MarkdownTagEnd::Link => {
+                        if builder.code_block_stack.is_empty() {
+                            builder.pop_text_style()
+                        }
+                    }
                     _ => log::error!("unsupported markdown tag end: {:?}", tag),
                 },
                 MarkdownEvent::Text => {

crates/project/Cargo.toml πŸ”—

@@ -52,10 +52,12 @@ regex.workspace = true
 rpc.workspace = true
 schemars.workspace = true
 task.workspace = true
+tempfile.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 sha2.workspace = true
+shlex.workspace = true
 similar = "1.3"
 smol.workspace = true
 snippet.workspace = true

crates/project/src/terminals.rs πŸ”—

@@ -5,8 +5,13 @@ use gpui::{
 };
 use settings::{Settings, SettingsLocation};
 use smol::channel::bounded;
-use std::path::{Path, PathBuf};
-use task::SpawnInTerminal;
+use std::{
+    env,
+    fs::File,
+    io::Write,
+    path::{Path, PathBuf},
+};
+use task::{SpawnInTerminal, TerminalWorkDir};
 use terminal::{
     terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent},
     TaskState, TaskStatus, Terminal, TerminalBuilder,
@@ -27,58 +32,57 @@ pub struct ConnectRemoteTerminal {
 }
 
 impl Project {
-    pub fn remote_terminal_connection_data(
+    pub fn terminal_work_dir_for(
         &self,
+        pathbuf: Option<&PathBuf>,
         cx: &AppContext,
-    ) -> Option<ConnectRemoteTerminal> {
-        self.dev_server_project_id()
-            .and_then(|dev_server_project_id| {
-                let projects_store = dev_server_projects::Store::global(cx).read(cx);
-                let project_path = projects_store
-                    .dev_server_project(dev_server_project_id)?
-                    .path
-                    .clone();
-                let ssh_connection_string = projects_store
-                    .dev_server_for_project(dev_server_project_id)?
-                    .ssh_connection_string
-                    .clone();
-                Some(project_path).zip(ssh_connection_string)
-            })
-            .map(
-                |(project_path, ssh_connection_string)| ConnectRemoteTerminal {
-                    ssh_connection_string,
-                    project_path,
-                },
-            )
+    ) -> Option<TerminalWorkDir> {
+        if self.is_local() {
+            return Some(TerminalWorkDir::Local(pathbuf?.clone()));
+        }
+        let dev_server_project_id = self.dev_server_project_id()?;
+        let projects_store = dev_server_projects::Store::global(cx).read(cx);
+        let ssh_command = projects_store
+            .dev_server_for_project(dev_server_project_id)?
+            .ssh_connection_string
+            .clone()?
+            .to_string();
+
+        let path = if let Some(pathbuf) = pathbuf {
+            pathbuf.to_string_lossy().to_string()
+        } else {
+            projects_store
+                .dev_server_project(dev_server_project_id)?
+                .path
+                .to_string()
+        };
+
+        Some(TerminalWorkDir::Ssh {
+            ssh_command,
+            path: Some(path),
+        })
     }
 
     pub fn create_terminal(
         &mut self,
-        working_directory: Option<PathBuf>,
+        working_directory: Option<TerminalWorkDir>,
         spawn_task: Option<SpawnInTerminal>,
         window: AnyWindowHandle,
         cx: &mut ModelContext<Self>,
     ) -> anyhow::Result<Model<Terminal>> {
-        let remote_connection_data = if self.is_remote() {
-            let remote_connection_data = self.remote_terminal_connection_data(cx);
-            if remote_connection_data.is_none() {
-                anyhow::bail!("Cannot create terminal for remote project without connection data")
-            }
-            remote_connection_data
-        } else {
-            None
-        };
-
         // used only for TerminalSettings::get
         let worktree = {
-            let terminal_cwd = working_directory.as_deref();
+            let terminal_cwd = working_directory
+                .as_ref()
+                .and_then(|cwd| cwd.local_path().clone());
             let task_cwd = spawn_task
                 .as_ref()
-                .and_then(|spawn_task| spawn_task.cwd.as_deref());
+                .and_then(|spawn_task| spawn_task.cwd.as_ref())
+                .and_then(|cwd| cwd.local_path());
 
             terminal_cwd
-                .and_then(|terminal_cwd| self.find_local_worktree(terminal_cwd, cx))
-                .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(spawn_cwd, cx)))
+                .and_then(|terminal_cwd| self.find_local_worktree(&terminal_cwd, cx))
+                .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(&spawn_cwd, cx)))
         };
 
         let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
@@ -86,7 +90,8 @@ impl Project {
             path,
         });
 
-        let is_terminal = spawn_task.is_none() && remote_connection_data.is_none();
+        let is_terminal = spawn_task.is_none() && (working_directory.as_ref().is_none())
+            || (working_directory.as_ref().unwrap().is_local());
         let settings = TerminalSettings::get(settings_location, cx);
         let python_settings = settings.detect_venv.clone();
         let (completion_tx, completion_rx) = bounded(1);
@@ -95,60 +100,138 @@ impl Project {
         // Alacritty uses parent project's working directory when no working directory is provided
         // https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52
 
+        let mut retained_script = None;
+
         let venv_base_directory = working_directory
-            .as_deref()
-            .unwrap_or_else(|| Path::new(""));
-
-        let (spawn_task, shell) = if let Some(remote_connection_data) = remote_connection_data {
-            log::debug!("Connecting to a remote server: {remote_connection_data:?}");
-            // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
-            // to properly display colors.
-            // We do not have the luxury of assuming the host has it installed,
-            // so we set it to a default that does not break the highlighting via ssh.
-            env.entry("TERM".to_string())
-                .or_insert_with(|| "xterm-256color".to_string());
-
-            (
-                None,
-                Shell::WithArguments {
-                    program: "ssh".to_string(),
-                    args: vec![
-                        remote_connection_data.ssh_connection_string.to_string(),
-                        "-t".to_string(),
-                        format!(
-                            "cd {} && exec $SHELL -l",
-                            escape_path_for_shell(remote_connection_data.project_path.as_ref())
-                        ),
-                    ],
-                },
-            )
-        } else if let Some(spawn_task) = spawn_task {
-            log::debug!("Spawning task: {spawn_task:?}");
-            env.extend(spawn_task.env);
-            // Activate minimal Python virtual environment
-            if let Some(python_settings) = &python_settings.as_option() {
-                self.set_python_venv_path_for_tasks(python_settings, venv_base_directory, &mut env);
+            .as_ref()
+            .and_then(|cwd| cwd.local_path().map(|path| path.clone()))
+            .unwrap_or_else(|| PathBuf::new())
+            .clone();
+
+        let (spawn_task, shell) = match working_directory.as_ref() {
+            Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
+                log::debug!("Connecting to a remote server: {ssh_command:?}");
+                // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
+                // to properly display colors.
+                // We do not have the luxury of assuming the host has it installed,
+                // so we set it to a default that does not break the highlighting via ssh.
+                env.entry("TERM".to_string())
+                    .or_insert_with(|| "xterm-256color".to_string());
+
+                let tmp_dir = tempfile::tempdir()?;
+                let real_ssh = which::which("ssh")?;
+                let ssh_path = tmp_dir.path().join("ssh");
+                let mut ssh_file = File::create(ssh_path.clone())?;
+
+                let to_run = if let Some(spawn_task) = spawn_task.as_ref() {
+                    Some(shlex::try_quote(&spawn_task.command)?.to_string())
+                        .into_iter()
+                        .chain(spawn_task.args.iter().filter_map(|arg| {
+                            shlex::try_quote(arg).ok().map(|arg| arg.to_string())
+                        }))
+                        .collect::<Vec<String>>()
+                        .join(" ")
+                } else {
+                    "exec $SHELL -l".to_string()
+                };
+
+                let (port_forward, local_dev_env) =
+                    if env::var("ZED_RPC_URL") == Ok("http://localhost:8080/rpc".to_string()) {
+                        (
+                            "-R 8080:localhost:8080",
+                            "export ZED_RPC_URL=http://localhost:8080/rpc;",
+                        )
+                    } else {
+                        ("", "")
+                    };
+
+                let commands = if let Some(path) = path {
+                    // I've found that `ssh -t dev sh -c 'cd; cd /tmp; pwd'` gives /tmp
+                    // but `ssh -t dev sh -c 'cd /tmp; pwd'` gives /root
+                    format!("cd {}; {} {}", path, local_dev_env, to_run)
+                } else {
+                    format!("cd; {} {}", local_dev_env, to_run)
+                };
+
+                let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
+
+                // To support things like `gh cs ssh`/`coder ssh`, we run whatever command
+                // you have configured, but place our custom script on the path so that it will
+                // be run instead.
+                write!(
+                    &mut ssh_file,
+                    "#!/bin/sh\nexec {} \"$@\" {} {} {}",
+                    real_ssh.to_string_lossy(),
+                    if spawn_task.is_none() { "-t" } else { "" },
+                    port_forward,
+                    shlex::try_quote(shell_invocation)?,
+                )?;
+                // todo(windows)
+                #[cfg(not(target_os = "windows"))]
+                std::fs::set_permissions(
+                    ssh_path,
+                    smol::fs::unix::PermissionsExt::from_mode(0o755),
+                )?;
+                let path = format!(
+                    "{}:{}",
+                    tmp_dir.path().to_string_lossy(),
+                    env.get("PATH")
+                        .cloned()
+                        .or(env::var("PATH").ok())
+                        .unwrap_or_default()
+                );
+                env.insert("PATH".to_string(), path);
+
+                let mut args = shlex::split(&ssh_command).unwrap_or_default();
+                let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
+
+                retained_script = Some(tmp_dir);
+                (
+                    spawn_task.map(|spawn_task| TaskState {
+                        id: spawn_task.id,
+                        full_label: spawn_task.full_label,
+                        label: spawn_task.label,
+                        command_label: spawn_task.command_label,
+                        status: TaskStatus::Running,
+                        completion_rx,
+                    }),
+                    Shell::WithArguments { program, args },
+                )
+            }
+            _ => {
+                if let Some(spawn_task) = spawn_task {
+                    log::debug!("Spawning task: {spawn_task:?}");
+                    env.extend(spawn_task.env);
+                    // Activate minimal Python virtual environment
+                    if let Some(python_settings) = &python_settings.as_option() {
+                        self.set_python_venv_path_for_tasks(
+                            python_settings,
+                            &venv_base_directory,
+                            &mut env,
+                        );
+                    }
+                    (
+                        Some(TaskState {
+                            id: spawn_task.id,
+                            full_label: spawn_task.full_label,
+                            label: spawn_task.label,
+                            command_label: spawn_task.command_label,
+                            status: TaskStatus::Running,
+                            completion_rx,
+                        }),
+                        Shell::WithArguments {
+                            program: spawn_task.command,
+                            args: spawn_task.args,
+                        },
+                    )
+                } else {
+                    (None, settings.shell.clone())
+                }
             }
-            (
-                Some(TaskState {
-                    id: spawn_task.id,
-                    full_label: spawn_task.full_label,
-                    label: spawn_task.label,
-                    command_label: spawn_task.command_label,
-                    status: TaskStatus::Running,
-                    completion_rx,
-                }),
-                Shell::WithArguments {
-                    program: spawn_task.command,
-                    args: spawn_task.args,
-                },
-            )
-        } else {
-            (None, settings.shell.clone())
         };
 
         let terminal = TerminalBuilder::new(
-            working_directory.clone(),
+            working_directory.and_then(|cwd| cwd.local_path()).clone(),
             spawn_task,
             shell,
             env,
@@ -167,6 +250,7 @@ impl Project {
 
             let id = terminal_handle.entity_id();
             cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
+                drop(retained_script);
                 let handles = &mut project.terminals.local_handles;
 
                 if let Some(index) = handles
@@ -183,7 +267,7 @@ impl Project {
             if is_terminal {
                 if let Some(python_settings) = &python_settings.as_option() {
                     if let Some(activate_script_path) =
-                        self.find_activate_script_path(python_settings, venv_base_directory)
+                        self.find_activate_script_path(python_settings, &venv_base_directory)
                     {
                         self.activate_python_virtual_environment(
                             Project::get_activate_command(python_settings),
@@ -291,39 +375,3 @@ impl Project {
         &self.terminals.local_handles
     }
 }
-
-#[cfg(unix)]
-fn escape_path_for_shell(input: &str) -> String {
-    input
-        .chars()
-        .fold(String::with_capacity(input.len()), |mut s, c| {
-            match c {
-                ' ' | '"' | '\'' | '\\' | '(' | ')' | '{' | '}' | '[' | ']' | '|' | ';' | '&'
-                | '<' | '>' | '*' | '?' | '$' | '#' | '!' | '=' | '^' | '%' | ':' => {
-                    s.push('\\');
-                    s.push('\\');
-                    s.push(c);
-                }
-                _ => s.push(c),
-            }
-            s
-        })
-}
-
-#[cfg(windows)]
-fn escape_path_for_shell(input: &str) -> String {
-    input
-        .chars()
-        .fold(String::with_capacity(input.len()), |mut s, c| {
-            match c {
-                '^' | '&' | '|' | '<' | '>' | ' ' | '(' | ')' | '@' | '`' | '=' | ';' | '%' => {
-                    s.push('^');
-                    s.push(c);
-                }
-                _ => s.push(c),
-            }
-            s
-        })
-}
-
-// TODO: Add a few tests for adding and removing terminal tabs

crates/recent_projects/Cargo.toml πŸ”—

@@ -26,6 +26,8 @@ dev_server_projects.workspace = true
 rpc.workspace = true
 serde.workspace = true
 smol.workspace = true
+task.workspace = true
+terminal_view.workspace = true
 ui.workspace = true
 ui_text_field.workspace = true
 util.workspace = true

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

@@ -1,23 +1,34 @@
 use std::time::Duration;
 
+use anyhow::Context;
 use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
 use editor::Editor;
 use feature_flags::FeatureFlagAppExt;
 use feature_flags::FeatureFlagViewExt;
 use gpui::Subscription;
+use gpui::Task;
+use gpui::WeakView;
 use gpui::{
-    percentage, Action, Animation, AnimationExt, AnyElement, AppContext, ClipboardItem,
-    DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation,
-    View, ViewContext,
+    percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
+    FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
 };
 use markdown::Markdown;
 use markdown::MarkdownStyle;
+use rpc::proto::RegenerateDevServerTokenResponse;
 use rpc::{
-    proto::{CreateDevServerResponse, DevServerStatus, RegenerateDevServerTokenResponse},
+    proto::{CreateDevServerResponse, DevServerStatus},
     ErrorCode, ErrorExt,
 };
-use ui::CheckboxWithLabel;
-use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
+use task::RevealStrategy;
+use task::SpawnInTerminal;
+use task::TerminalWorkDir;
+use terminal_view::terminal_panel::TerminalPanel;
+use ui::ElevationIndex;
+use ui::Section;
+use ui::{
+    prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
+    RadioWithLabel, Tooltip,
+};
 use ui_text_field::{FieldLabelLayout, TextField};
 use util::ResultExt;
 use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
@@ -29,10 +40,9 @@ pub struct DevServerProjects {
     focus_handle: FocusHandle,
     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>,
-    use_server_name_in_ssh: Selection,
-    rename_dev_server_input: View<TextField>,
     markdown: View<Markdown>,
     _dev_server_subscription: Subscription,
 }
@@ -40,22 +50,9 @@ pub struct DevServerProjects {
 #[derive(Default, Clone)]
 struct CreateDevServer {
     creating: bool,
-    dev_server: Option<CreateDevServerResponse>,
-    // ssh_connection_string: Option<String>,
-}
-
-#[derive(Clone)]
-struct EditDevServer {
-    dev_server_id: DevServerId,
-    state: EditDevServerState,
-}
-
-#[derive(Clone, PartialEq)]
-enum EditDevServerState {
-    Default,
-    RenamingDevServer,
-    RegeneratingToken,
-    RegeneratedToken(RegenerateDevServerTokenResponse),
+    dev_server_id: Option<DevServerId>,
+    access_token: Option<String>,
+    manual_setup: bool,
 }
 
 struct CreateDevServerProject {
@@ -67,7 +64,6 @@ struct CreateDevServerProject {
 enum Mode {
     Default(Option<CreateDevServerProject>),
     CreateDevServer(CreateDevServer),
-    EditDevServer(EditDevServer),
 }
 
 impl DevServerProjects {
@@ -86,26 +82,27 @@ impl DevServerProjects {
 
     fn register_open_remote_action(workspace: &mut Workspace) {
         workspace.register_action(|workspace, _: &OpenRemote, cx| {
-            workspace.toggle_modal(cx, |cx| Self::new(cx))
+            let handle = cx.view().downgrade();
+            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
         });
     }
 
     pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
         workspace.update(cx, |workspace, cx| {
-            workspace.toggle_modal(cx, |cx| Self::new(cx))
+            let handle = cx.view().downgrade();
+            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
         })
     }
 
-    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+    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", "").with_label(FieldLabelLayout::Stacked));
-        let rename_dev_server_input =
-            cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
+        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);
@@ -123,7 +120,10 @@ impl DevServerProjects {
             },
             inline_code: Default::default(),
             block_quote: Default::default(),
-            link: Default::default(),
+            link: gpui::TextStyleRefinement {
+                color: Some(Color::Accent.color(cx)),
+                ..Default::default()
+            },
             rule_color: Default::default(),
             block_quote_border_color: Default::default(),
             syntax: cx.theme().syntax().clone(),
@@ -132,15 +132,19 @@ impl DevServerProjects {
         let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx));
 
         Self {
-            mode: Mode::Default(None),
+            mode: Mode::CreateDevServer(CreateDevServer {
+                creating: false,
+                dev_server_id: None,
+                access_token: None,
+                manual_setup: false,
+            }),
             focus_handle,
             scroll_handle: ScrollHandle::new(),
             dev_server_store,
             project_path_input,
             dev_server_name_input,
-            rename_dev_server_input,
             markdown,
-            use_server_name_in_ssh: Selection::Unselected,
+            workspace,
             _dev_server_subscription: subscription,
         }
     }
@@ -254,137 +258,179 @@ impl DevServerProjects {
         }));
     }
 
-    pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
+    pub fn create_or_update_dev_server(
+        &mut self,
+        manual_setup: bool,
+        existing_id: Option<DevServerId>,
+        access_token: Option<String>,
+        cx: &mut ViewContext<Self>,
+    ) {
         let name = get_text(&self.dev_server_name_input, cx);
         if name.is_empty() {
             return;
         }
 
-        let ssh_connection_string = if self.use_server_name_in_ssh == Selection::Selected {
+        let ssh_connection_string = if manual_setup {
+            None
+        } else if name.contains(' ') {
             Some(name.clone())
         } else {
-            None
+            Some(format!("ssh {}", name))
         };
 
-        let dev_server = self.dev_server_store.update(cx, |store, cx| {
-            store.create_dev_server(name, ssh_connection_string, cx)
+        let dev_server = self.dev_server_store.update(cx, {
+            let access_token = access_token.clone();
+            |store, cx| {
+                let ssh_connection_string = ssh_connection_string.clone();
+                if let Some(dev_server_id) = existing_id {
+                    let rename = store.rename_dev_server(
+                        dev_server_id,
+                        name.clone(),
+                        ssh_connection_string,
+                        cx,
+                    );
+                    let token = if let Some(access_token) = access_token {
+                        Task::ready(Ok(RegenerateDevServerTokenResponse {
+                            dev_server_id: dev_server_id.0,
+                            access_token,
+                        }))
+                    } else {
+                        store.regenerate_dev_server_token(dev_server_id, cx)
+                    };
+                    cx.spawn(|_, _| async move {
+                        rename.await?;
+                        let response = token.await?;
+                        Ok(CreateDevServerResponse {
+                            dev_server_id: dev_server_id.0,
+                            name,
+                            access_token: response.access_token,
+                        })
+                    })
+                } else {
+                    store.create_dev_server(name, ssh_connection_string.clone(), cx)
+                }
+            }
         });
 
-        cx.spawn(|this, mut cx| async move {
+        let workspace = self.workspace.clone();
+
+        cx.spawn({
+            let access_token = access_token.clone();
+            |this, mut cx| async move {
             let result = dev_server.await;
 
-            this.update(&mut cx, |this, cx| match &result {
+            match result {
                 Ok(dev_server) => {
-                    this.focus_handle.focus(cx);
-                    this.mode = Mode::CreateDevServer(CreateDevServer {
-                        creating: false,
-                        dev_server: Some(dev_server.clone()),
-                    });
-                }
-                Err(_) => {
-                    this.mode = Mode::CreateDevServer(Default::default());
-                }
-            })
-            .log_err();
-            result
-        })
+                    if let Some(ssh_connection_string) =  ssh_connection_string {
+
+                        let access_token = access_token.clone();
+                        this.update(&mut cx, |this, cx| {
+                                this.focus_handle.focus(cx);
+                                this.mode = Mode::CreateDevServer(CreateDevServer {
+                                    creating: true,
+                                    dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
+                                    access_token: Some(access_token.unwrap_or(dev_server.access_token.clone())),
+                                    manual_setup: false,
+                            });
+                                cx.notify();
+                        })?;
+                    let terminal_panel = workspace
+                        .update(&mut cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
+                        .ok()
+                        .flatten()
+                        .with_context(|| anyhow::anyhow!("No terminal panel"))?;
+
+                        let command = "sh".to_string();
+                        let args = vec!["-x".to_string(),"-c".to_string(),
+                            format!(r#"~/.local/bin/zed -v >/dev/stderr || (curl -sSL https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | bash && ~/.local/bin/zed --dev-server-token {}"#, dev_server.access_token)];
+
+                        let terminal = terminal_panel.update(&mut cx, |terminal_panel, cx| {
+                            terminal_panel.spawn_in_new_terminal(
+                                SpawnInTerminal {
+                                    id: task::TaskId("ssh-remote".into()),
+                                    full_label: "Install zed over ssh".into(),
+                                    label: "Install zed over ssh".into(),
+                                    command,
+                                    args,
+                                    command_label: ssh_connection_string.clone(),
+                                    cwd: Some(TerminalWorkDir::Ssh { ssh_command: ssh_connection_string, path: None }),
+                                    env: Default::default(),
+                                    use_new_terminal: true,
+                                    allow_concurrent_runs: false,
+                                    reveal: RevealStrategy::Always,
+                                },
+                                cx,
+                            )
+                        })?.await?;
+
+                        terminal.update(&mut cx, |terminal, cx| {
+                            terminal.wait_for_completed_task(cx)
+                        })?.await;
+
+                        // There's a race-condition between the task completing successfully, and the server sending us the online status. Make it less likely we'll show the error state.
+                        if this.update(&mut cx, |this, cx| {
+                            this.dev_server_store.read(cx).dev_server_status(DevServerId(dev_server.dev_server_id))
+                        })? == DevServerStatus::Offline {
+                            cx.background_executor().timer(Duration::from_millis(200)).await
+                        }
+                    }
+
+                    this.update(&mut cx, |this, cx| {
+                            this.focus_handle.focus(cx);
+                            this.mode = Mode::CreateDevServer(CreateDevServer {
+                                creating: false,
+                                dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
+                                access_token: Some(dev_server.access_token),
+                                manual_setup: false,
+                        });
+                            cx.notify();
+                    })?;
+                Ok(())
+            }
+            Err(e) => {
+                this.update(&mut cx, |this, cx| {
+                    this.mode = Mode::CreateDevServer(CreateDevServer { creating:false, dev_server_id: existing_id, access_token: None, manual_setup });
+                    cx.notify()
+                })
+                .log_err();
+
+                return Err(e)
+            }
+            }
+        }})
         .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
 
         self.mode = Mode::CreateDevServer(CreateDevServer {
             creating: true,
-            dev_server: None,
+            dev_server_id: existing_id,
+            access_token,
+            manual_setup,
         });
         cx.notify()
     }
 
-    fn rename_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
-        let name = get_text(&self.rename_dev_server_input, cx);
-
-        let Some(dev_server) = self.dev_server_store.read(cx).dev_server(id) else {
-            return;
-        };
-
-        if name.is_empty() || dev_server.name == name {
-            return;
-        }
-
-        let request = self
-            .dev_server_store
-            .update(cx, |store, cx| store.rename_dev_server(id, name, cx));
-
-        self.mode = Mode::EditDevServer(EditDevServer {
-            dev_server_id: id,
-            state: EditDevServerState::RenamingDevServer,
-        });
-
-        cx.spawn(|this, mut cx| async move {
-            request.await?;
-            this.update(&mut cx, move |this, cx| {
-                this.mode = Mode::EditDevServer(EditDevServer {
-                    dev_server_id: id,
-                    state: EditDevServerState::Default,
-                });
-                cx.notify();
-            })
-        })
-        .detach_and_prompt_err("Failed to rename dev server", cx, |_, _| None);
-    }
-
-    fn refresh_dev_server_token(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
-        let answer = cx.prompt(
-            gpui::PromptLevel::Warning,
-            "Are you sure?",
-            Some("This will invalidate the existing dev server token."),
-            &["Generate", "Cancel"],
-        );
-        cx.spawn(|this, mut cx| async move {
-            let answer = answer.await?;
-
-            if answer != 0 {
-                return Ok(());
-            }
-
-            let response = this
-                .update(&mut cx, move |this, cx| {
-                    let request = this
-                        .dev_server_store
-                        .update(cx, |store, cx| store.regenerate_dev_server_token(id, cx));
-                    this.mode = Mode::EditDevServer(EditDevServer {
-                        dev_server_id: id,
-                        state: EditDevServerState::RegeneratingToken,
-                    });
-                    cx.notify();
-                    request
-                })?
-                .await?;
-
-            this.update(&mut cx, move |this, cx| {
-                this.mode = Mode::EditDevServer(EditDevServer {
-                    dev_server_id: id,
-                    state: EditDevServerState::RegeneratedToken(response),
-                });
-                cx.notify();
-            })
-            .log_err();
-
-            Ok(())
-        })
-        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
-    }
-
     fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
-        let answer = cx.prompt(
-            gpui::PromptLevel::Warning,
-            "Are you sure?",
-            Some("This will delete the dev server and all of its remote projects."),
-            &["Delete", "Cancel"],
-        );
+        let store = self.dev_server_store.read(cx);
+        let prompt = if store.projects_for_server(id).is_empty()
+            && store
+                .dev_server(id)
+                .is_some_and(|server| server.status == DevServerStatus::Offline)
+        {
+            None
+        } else {
+            Some(cx.prompt(
+                gpui::PromptLevel::Warning,
+                "Are you sure?",
+                Some("This will delete the dev server and all of its remote projects."),
+                &["Delete", "Cancel"],
+            ))
+        };
 
         cx.spawn(|this, mut cx| async move {
-            let answer = answer.await?;
-
-            if answer != 0 {
-                return Ok(());
+            if let Some(prompt) = prompt {
+                if prompt.await? != 0 {
+                    return Ok(());
+                }
             }
 
             let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
@@ -457,19 +503,13 @@ impl DevServerProjects {
                 self.create_dev_server_project(create_project.dev_server_id, cx);
             }
             Mode::CreateDevServer(state) => {
-                if !state.creating && state.dev_server.is_none() {
-                    self.create_dev_server(cx);
-                }
-            }
-            Mode::EditDevServer(edit_dev_server) => {
-                if self
-                    .rename_dev_server_input
-                    .read(cx)
-                    .editor()
-                    .read(cx)
-                    .is_focused(cx)
-                {
-                    self.rename_dev_server(edit_dev_server.dev_server_id, cx);
+                if !state.creating {
+                    self.create_or_update_dev_server(
+                        state.manual_setup,
+                        state.dev_server_id,
+                        state.access_token.clone(),
+                        cx,
+                    );
                 }
             }
         }
@@ -495,6 +535,7 @@ impl DevServerProjects {
         let dev_server_id = dev_server.id;
         let status = dev_server.status;
         let dev_server_name = dev_server.name.clone();
+        let manual_setup = dev_server.ssh_connection_string.is_none();
 
         v_flex()
             .w_full()
@@ -523,7 +564,13 @@ impl DevServerProjects {
                                     )
                                 }),
                         )
-                        .child(dev_server_name.clone())
+                        .child(
+                            div()
+                                .max_w(rems(26.))
+                                .overflow_hidden()
+                                .whitespace_nowrap()
+                                .child(Label::new(dev_server_name.clone())),
+                        )
                         .child(
                             h_flex()
                                 .visible_on_hover("dev-server")
@@ -531,12 +578,14 @@ impl DevServerProjects {
                                 .child(
                                     IconButton::new("edit-dev-server", IconName::Pencil)
                                         .on_click(cx.listener(move |this, _, cx| {
-                                            this.mode = Mode::EditDevServer(EditDevServer {
-                                                dev_server_id,
-                                                state: EditDevServerState::Default,
+                                            this.mode = Mode::CreateDevServer(CreateDevServer {
+                                                dev_server_id: Some(dev_server_id),
+                                                creating: false,
+                                                access_token: None,
+                                                manual_setup,
                                             });
                                             let dev_server_name = dev_server_name.clone();
-                                            this.rename_dev_server_input.update(
+                                            this.dev_server_name_input.update(
                                                 cx,
                                                 move |input, cx| {
                                                     input.editor().update(cx, move |editor, cx| {
@@ -561,7 +610,7 @@ impl DevServerProjects {
             .child(
                 v_flex()
                     .w_full()
-                    .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
+                    .bg(cx.theme().colors().background)
                     .border_1()
                     .border_color(cx.theme().colors().border_variant)
                     .rounded_md()
@@ -672,129 +721,151 @@ impl DevServerProjects {
     ) -> impl IntoElement {
         let CreateDevServer {
             creating,
-            dev_server,
-        } = state;
+            dev_server_id,
+            access_token,
+            manual_setup,
+        } = state.clone();
+
+        let status = dev_server_id
+            .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
+            .unwrap_or_default();
 
-        self.dev_server_name_input.update(cx, |input, cx| {
-            input.set_disabled(creating || dev_server.is_some(), cx);
+        let name = self.dev_server_name_input.update(cx, |input, cx| {
+            input.editor().update(cx, |editor, cx| {
+                if editor.text(cx).is_empty() {
+                    if manual_setup {
+                        editor.set_placeholder_text("example-server", cx)
+                    } else {
+                        editor.set_placeholder_text("ssh host", cx)
+                    }
+                }
+                editor.text(cx)
+            })
         });
 
-        v_flex()
-            .id("scroll-container")
-            .h_full()
-            .overflow_y_scroll()
-            .track_scroll(&self.scroll_handle)
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                ModalHeader::new("create-dev-server")
-                    .show_back_button(true)
-                    .child(Headline::new("New dev server").size(HeadlineSize::Small)),
+        Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
+            .header(
+                ModalHeader::new()
+                    .headline("Create Dev Server")
+                    .show_back_button(true),
             )
-            .child(
-                ModalContent::new().child(
-                    v_flex()
-                        .w_full()
-                        .child(
-                            v_flex()
-                                .pb_2()
-                                .w_full()
-                                .px_2()
-                                .child(
-                                    div()
-                                        .pl_2()
-                                        .max_w(rems(16.))
-                                        .child(self.dev_server_name_input.clone()),
-                                )
-                        )
-                        .child(
-                            h_flex()
-                                .pb_2()
-                                .items_end()
-                                .w_full()
-                                .px_2()
-                                .border_b_1()
-                                .border_color(cx.theme().colors().border)
-                                .child(
-                                    div()
-                                        .pl_1()
-                                        .pb(px(3.))
-                                        .when(!creating && dev_server.is_none(), |div| {
-                                            div
-                                                .child(
-                                                    CheckboxWithLabel::new(
-                                                        "use-server-name-in-ssh",
-                                                        Label::new("Use name as ssh connection string"),
-                                                        self.use_server_name_in_ssh,
-                                                        cx.listener(move |this, &new_selection, _| {
-                                                            this.use_server_name_in_ssh = new_selection;
-                                                        })
-                                                    )
-                                                )
-                                                .child(
-                                                    Button::new("create-dev-server", "Create").on_click(
-                                                        cx.listener(move |this, _, cx| {
-                                                            this.create_dev_server(cx);
-                                                        })
-                                                    )
-                                                )
-                                        })
-                                        .when(creating && dev_server.is_none(), |div| {
-                                            div
-                                                .child(
-                                                    CheckboxWithLabel::new(
-                                                        "use-server-name-in-ssh",
-                                                        Label::new("Use SSH for terminals"),
-                                                        self.use_server_name_in_ssh,
-                                                        |&_, _| {}
-                                                    )
-                                                )
-                                                .child(
-                                                    Button::new("create-dev-server", "Creating...")
-                                                        .disabled(true),
+            .section(
+                Section::new()
+                    .header(if manual_setup { "Server Name".into()} else { "SSH arguments".into()})
+                    .child(
+                        div()
+                            .max_w(rems(16.))
+                            .child(self.dev_server_name_input.clone())
+                    ),
+            )
+            .section(
+                Section::new_contained()
+                    .header("Connection Method".into())
+                    .child(
+                        v_flex()
+                            .w_full()
+                            .gap_y(Spacing::Large.rems(cx))
+                            .child(v_flex().child(RadioWithLabel::new(
+                                "use-server-name-in-ssh",
+                                Label::new("Connect via SSH (default)"),
+                                !manual_setup,
+                                cx.listener({
+                                    let state = state.clone();
+                                    move |this, _, cx| {
+                                    this.mode = Mode::CreateDevServer(CreateDevServer {
+                                        manual_setup: false,
+                                        ..state.clone()
+                                    });
+                                    cx.notify()
+                                    }
+                                }),
+                            ))
+                            .child(RadioWithLabel::new(
+                                "use-server-name-in-ssh",
+                                Label::new("Manual Setup"),
+                                manual_setup,
+                                cx.listener({
+                                    let state = state.clone();
+                                    move |this, _, cx| {
+                                    this.mode = Mode::CreateDevServer(CreateDevServer {
+                                        manual_setup: true,
+                                        ..state.clone()
+                                    });
+                                    cx.notify()
+                                }}),
+                            )))
+                            .when(dev_server_id.is_none(), |el| {
+                                el.child(
+                                    if manual_setup {
+                                        Label::new(
+                                            "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine."
                                                 )
-                                        }),
+                                    } else {
+                                        Label::new(
+                                            "Enter the command you use to ssh into this server.\n\
+                                            For example: `ssh me@my.server` or `gh cs ssh -c example`."
+                                            )
+                                }.size(LabelSize::Small).color(Color::Muted))
+                            })
+                            .when(dev_server_id.is_some() && access_token.is_none(),|el|{
+                                el.child(
+                                if manual_setup {
+                                    Label::new(
+                                        "Note: updating the dev server generate a new token"
+                                            )
+                                } else {
+                                    Label::new(
+                                        "Enter the command you use to ssh into this server.\n\
+                                        For example: `ssh me@my.server` or `gh cs ssh -c example`."
+                                        )
+                                }.size(LabelSize::Small).color(Color::Muted)
                                 )
-                        )
-                        .when(dev_server.is_none(), |div| {
-                            let server_name = get_text(&self.dev_server_name_input, cx);
-                            let server_name_trimmed = server_name.trim();
-                            let ssh_host_name = if server_name_trimmed.is_empty() {
-                                "user@host"
-                            } else {
-                                server_name_trimmed
-                            };
-                            div.px_2().child(Label::new(format!(
-                                "Once you have created a dev server, you will be given a command to run on the server to register it.\n\n\
-                                If you enable SSH, then the terminal will automatically `ssh {ssh_host_name}` on open."
-                            )))
-                        })
-                        .when_some(dev_server.clone(), |div, dev_server| {
-                            let status = self
-                                .dev_server_store
-                                .read(cx)
-                                .dev_server_status(DevServerId(dev_server.dev_server_id));
-
-                            div.child(
-                                 self.render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx)
-                            )
-                        }),
-                )
+                            })
+                            .when_some(access_token.clone(), {
+                                |el, access_token| {
+                                el.child(
+                                    self.render_dev_server_token_creating(access_token, name, manual_setup, status, creating,  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 manual_setup { "Create"} else { "Connect"})
+                        .style(ButtonStyle::Filled)
+                        .layer(ElevationIndex::ModalSurface)
+                        .disabled(creating)
+                        .on_click(cx.listener({
+                            let access_token = access_token.clone();
+                            move |this, _, cx| {
+                            this.create_or_update_dev_server(manual_setup, dev_server_id, access_token.clone(), cx);
+                        }}))
+                }
+            ))
     }
 
-    fn render_dev_server_token_instructions(
+    fn render_dev_server_token_creating(
         &self,
-        access_token: &str,
-        dev_server_name: &str,
+        access_token: String,
+        dev_server_name: String,
+        manual_setup: bool,
         status: DevServerStatus,
+        creating: bool,
         cx: &mut ViewContext<Self>,
     ) -> Div {
-        let instructions = SharedString::from(format!("zed --dev-server-token {}", access_token));
         self.markdown.update(cx, |markdown, cx| {
-            if !markdown.source().contains(access_token) {
-                markdown.reset(format!("```\n{}\n```", instructions), cx);
+            if manual_setup {
+                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 manual setup.".to_string(), cx);
             }
         });
 
@@ -802,40 +873,20 @@ impl DevServerProjects {
             .pl_2()
             .pt_2()
             .gap_2()
-            .child(
-                h_flex()
-                    .justify_between()
-                    .w_full()
-                    .child(Label::new(format!(
-                        "Please log into `{}` and run:",
-                        dev_server_name
-                    )))
-                    .child(
-                        Button::new("copy-access-token", "Copy Instructions")
-                            .icon(Some(IconName::Copy))
-                            .icon_size(IconSize::Small)
-                            .on_click({
-                                let instructions = instructions.clone();
-                                cx.listener(move |_, _, cx| {
-                                    cx.write_to_clipboard(ClipboardItem::new(
-                                        instructions.to_string(),
-                                    ))
-                                })
-                            }),
-                    ),
-            )
-            .child(v_flex().w_full().child(self.markdown.clone()))
-            .when(status == DevServerStatus::Offline, |this| {
-                this.child(Self::render_loading_spinner("Waiting for connection…"))
-            })
-            .when(status == DevServerStatus::Online, |this| {
-                this.child(Label::new("🎊 Connection established!")).child(
-                    h_flex()
-                        .justify_end()
-                        .child(Button::new("done", "Done").on_click(
-                            cx.listener(|_, _, cx| cx.dispatch_action(menu::Cancel.boxed_clone())),
-                        )),
-                )
+            .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
+            .map(|el| {
+                if status == DevServerStatus::Offline && !manual_setup && !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!"))
+                }
             })
     }
 
@@ -854,127 +905,6 @@ impl DevServerProjects {
             .child(Label::new(label))
     }
 
-    fn render_edit_dev_server(
-        &mut self,
-        edit_dev_server: EditDevServer,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let dev_server_id = edit_dev_server.dev_server_id;
-        let dev_server = self
-            .dev_server_store
-            .read(cx)
-            .dev_server(dev_server_id)
-            .cloned();
-
-        let dev_server_name = dev_server
-            .as_ref()
-            .map(|dev_server| dev_server.name.clone())
-            .unwrap_or_default();
-
-        let dev_server_status = dev_server
-            .map(|dev_server| dev_server.status)
-            .unwrap_or(DevServerStatus::Offline);
-
-        let disabled = matches!(
-            edit_dev_server.state,
-            EditDevServerState::RenamingDevServer | EditDevServerState::RegeneratingToken
-        );
-        self.rename_dev_server_input.update(cx, |input, cx| {
-            input.set_disabled(disabled, cx);
-        });
-
-        let rename_dev_server_input_text = self
-            .rename_dev_server_input
-            .read(cx)
-            .editor()
-            .read(cx)
-            .text(cx);
-
-        let content = v_flex().w_full().gap_2().child(
-            h_flex()
-                .pb_2()
-                .border_b_1()
-                .border_color(cx.theme().colors().border)
-                .items_end()
-                .w_full()
-                .px_2()
-                .child(
-                    div()
-                        .pl_2()
-                        .max_w(rems(16.))
-                        .child(self.rename_dev_server_input.clone()),
-                )
-                .child(
-                    div()
-                        .pl_1()
-                        .pb(px(3.))
-                        .when(
-                            edit_dev_server.state != EditDevServerState::RenamingDevServer,
-                            |div| {
-                                div.child(
-                                    Button::new("rename-dev-server", "Rename")
-                                        .disabled(
-                                            rename_dev_server_input_text.trim().is_empty()
-                                                || rename_dev_server_input_text == dev_server_name,
-                                        )
-                                        .on_click(cx.listener(move |this, _, cx| {
-                                            this.rename_dev_server(dev_server_id, cx);
-                                            cx.notify();
-                                        })),
-                                )
-                            },
-                        )
-                        .when(
-                            edit_dev_server.state == EditDevServerState::RenamingDevServer,
-                            |div| {
-                                div.child(
-                                    Button::new("rename-dev-server", "Renaming...").disabled(true),
-                                )
-                            },
-                        ),
-                ),
-        );
-
-        let content = content.child(match edit_dev_server.state {
-            EditDevServerState::RegeneratingToken => {
-                Self::render_loading_spinner("Generating token...")
-            }
-            EditDevServerState::RegeneratedToken(response) => self
-                .render_dev_server_token_instructions(
-                    &response.access_token,
-                    &dev_server_name,
-                    dev_server_status,
-                    cx,
-                ),
-            _ => h_flex().items_end().w_full().child(
-                Button::new("regenerate-dev-server-token", "Generate new access token")
-                    .icon(IconName::Update)
-                    .on_click(cx.listener(move |this, _, cx| {
-                        this.refresh_dev_server_token(dev_server_id, cx);
-                        cx.notify();
-                    })),
-            ),
-        });
-
-        v_flex()
-            .id("scroll-container")
-            .h_full()
-            .overflow_y_scroll()
-            .track_scroll(&self.scroll_handle)
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                ModalHeader::new("edit-dev-server")
-                    .show_back_button(true)
-                    .child(
-                        Headline::new(format!("Edit {}", &dev_server_name))
-                            .size(HeadlineSize::Small),
-                    ),
-            )
-            .child(ModalContent::new().child(v_flex().w_full().child(content)))
-    }
-
     fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let dev_servers = self.dev_server_store.read(cx).dev_servers();
 
@@ -994,51 +924,50 @@ impl DevServerProjects {
             creating_dev_server = Some(*dev_server_id);
         };
 
-        v_flex()
-            .id("scroll-container")
-            .h_full()
-            .overflow_y_scroll()
-            .track_scroll(&self.scroll_handle)
-            .px_1()
-            .pt_0p5()
-            .gap_px()
-            .child(
-                ModalHeader::new("remote-projects")
+        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
+            .header(
+                ModalHeader::new()
                     .show_dismiss_button(true)
                     .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
             )
-            .child(
-                ModalContent::new().child(
-                    List::new()
-                        .empty_message("No dev servers registered.")
-                        .header(Some(
-                            ListHeader::new("Dev Servers").end_slot(
-                                Button::new("register-dev-server-button", "New Server")
-                                    .icon(IconName::Plus)
-                                    .icon_position(IconPosition::Start)
-                                    .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
-                                    .on_click(cx.listener(|this, _, cx| {
-                                        this.mode =
-                                            Mode::CreateDevServer(CreateDevServer::default());
-                                        this.dev_server_name_input.update(cx, |text_field, cx| {
-                                            text_field.editor().update(cx, |editor, cx| {
-                                                editor.set_text("", cx);
-                                            });
-                                        });
-                                        this.use_server_name_in_ssh = Selection::Unselected;
-                                        cx.notify();
-                                    })),
-                            ),
-                        ))
-                        .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(
+                Section::new().child(
+                    div().mb_4().child(
+                        List::new()
+                            .empty_message("No dev servers registered.")
+                            .header(Some(
+                                ListHeader::new("Dev Servers").end_slot(
+                                    Button::new("register-dev-server-button", "New Server")
+                                        .icon(IconName::Plus)
+                                        .icon_position(IconPosition::Start)
+                                        .tooltip(|cx| {
+                                            Tooltip::text("Register a new dev server", cx)
+                                        })
+                                        .on_click(cx.listener(|this, _, cx| {
+                                            this.mode =
+                                                Mode::CreateDevServer(CreateDevServer::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(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()
+                            })),
+                    ),
                 ),
             )
     }

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

@@ -328,7 +328,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                             ).await?;
                                         if response == 1 {
                                             workspace.update(&mut cx, |workspace, cx| {
-                                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx))
+                                                let handle = cx.view().downgrade();
+                                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, handle))
                                             })?;
                                         } else {
                                             workspace.update(&mut cx, |workspace, cx| {

crates/rpc/proto/zed.proto πŸ”—

@@ -515,6 +515,7 @@ message ShutdownDevServer {
 message RenameDevServer {
     uint64 dev_server_id = 1;
     string name = 2;
+    optional string ssh_connection_string = 3;
 }
 
 message DeleteDevServer {

crates/task/src/lib.rs πŸ”—

@@ -19,6 +19,38 @@ pub use vscode_format::VsCodeTaskFile;
 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct TaskId(pub String);
 
+/// TerminalWorkDir describes where a task should be run
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TerminalWorkDir {
+    /// Local is on this machine
+    Local(PathBuf),
+    /// SSH runs the terminal over ssh
+    Ssh {
+        /// The command to run to connect
+        ssh_command: String,
+        /// The path on the remote server
+        path: Option<String>,
+    },
+}
+
+impl TerminalWorkDir {
+    /// is_local
+    pub fn is_local(&self) -> bool {
+        match self {
+            TerminalWorkDir::Local(_) => true,
+            TerminalWorkDir::Ssh { .. } => false,
+        }
+    }
+
+    /// local_path
+    pub fn local_path(&self) -> Option<PathBuf> {
+        match self {
+            TerminalWorkDir::Local(path) => Some(path.clone()),
+            TerminalWorkDir::Ssh { .. } => None,
+        }
+    }
+}
+
 /// Contains all information needed by Zed to spawn a new terminal tab for the given task.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct SpawnInTerminal {
@@ -36,7 +68,7 @@ pub struct SpawnInTerminal {
     /// A human-readable label, containing command and all of its arguments, joined and substituted.
     pub command_label: String,
     /// Current working directory to spawn the command into.
-    pub cwd: Option<PathBuf>,
+    pub cwd: Option<TerminalWorkDir>,
     /// Env overrides for the command, will be appended to the terminal's environment from the settings.
     pub env: HashMap<String, String>,
     /// Whether to use a new terminal tab or reuse the existing one to spawn the process.

crates/task/src/task_template.rs πŸ”—

@@ -8,7 +8,8 @@ use sha2::{Digest, Sha256};
 use util::{truncate_and_remove_front, ResultExt};
 
 use crate::{
-    ResolvedTask, SpawnInTerminal, TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX,
+    ResolvedTask, SpawnInTerminal, TaskContext, TaskId, TerminalWorkDir, VariableName,
+    ZED_VARIABLE_NAME_PREFIX,
 };
 
 /// A template definition of a Zed task to run.
@@ -112,12 +113,14 @@ impl TaskTemplate {
                     &variable_names,
                     &mut substituted_variables,
                 )?;
-                Some(substitured_cwd)
+                Some(TerminalWorkDir::Local(PathBuf::from(substitured_cwd)))
             }
             None => None,
         }
-        .map(PathBuf::from)
-        .or(cx.cwd.clone());
+        .or(cx
+            .cwd
+            .as_ref()
+            .map(|cwd| TerminalWorkDir::Local(cwd.clone())));
         let human_readable_label = substitute_all_template_variables_in_str(
             &self.label,
             &truncated_variables,
@@ -379,8 +382,10 @@ mod tests {
             task_variables: TaskVariables::default(),
         };
         assert_eq!(
-            resolved_task(&task_without_cwd, &cx).cwd.as_deref(),
-            Some(context_cwd.as_path()),
+            resolved_task(&task_without_cwd, &cx)
+                .cwd
+                .and_then(|cwd| cwd.local_path()),
+            Some(context_cwd.clone()),
             "TaskContext's cwd should be taken on resolve if task's cwd is None"
         );
 
@@ -394,8 +399,10 @@ mod tests {
             task_variables: TaskVariables::default(),
         };
         assert_eq!(
-            resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
-            Some(task_cwd.as_path()),
+            resolved_task(&task_with_cwd, &cx)
+                .cwd
+                .and_then(|cwd| cwd.local_path()),
+            Some(task_cwd.clone()),
             "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
         );
 
@@ -404,8 +411,10 @@ mod tests {
             task_variables: TaskVariables::default(),
         };
         assert_eq!(
-            resolved_task(&task_with_cwd, &cx).cwd.as_deref(),
-            Some(task_cwd.as_path()),
+            resolved_task(&task_with_cwd, &cx)
+                .cwd
+                .and_then(|cwd| cwd.local_path()),
+            Some(task_cwd.clone()),
             "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
         );
     }

crates/terminal_view/src/terminal_panel.rs πŸ”—

@@ -6,16 +6,19 @@ use db::kvp::KEY_VALUE_STORE;
 use futures::future::join_all;
 use gpui::{
     actions, Action, AppContext, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
-    ExternalPaths, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled,
-    Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
+    ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render,
+    Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use project::{Fs, ProjectEntryId};
 use search::{buffer_search::DivRegistrar, BufferSearchBar};
 use serde::{Deserialize, Serialize};
 use settings::Settings;
-use task::{RevealStrategy, SpawnInTerminal, TaskId};
-use terminal::terminal_settings::{Shell, TerminalDockPosition, TerminalSettings};
+use task::{RevealStrategy, SpawnInTerminal, TaskId, TerminalWorkDir};
+use terminal::{
+    terminal_settings::{Shell, TerminalDockPosition, TerminalSettings},
+    Terminal,
+};
 use ui::{
     h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, Selectable,
     Tooltip,
@@ -319,14 +322,16 @@ impl TerminalPanel {
             return;
         };
 
-        terminal_panel.update(cx, |panel, cx| {
-            panel.add_terminal(
-                Some(action.working_directory.clone()),
-                None,
-                RevealStrategy::Always,
-                cx,
-            )
-        });
+        let terminal_work_dir = workspace
+            .project()
+            .read(cx)
+            .terminal_work_dir_for(Some(&action.working_directory), cx);
+
+        terminal_panel
+            .update(cx, |panel, cx| {
+                panel.add_terminal(terminal_work_dir, None, RevealStrategy::Always, cx)
+            })
+            .detach_and_log_err(cx);
     }
 
     fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
@@ -355,18 +360,19 @@ impl TerminalPanel {
         let spawn_task = spawn_task;
 
         let reveal = spawn_task.reveal;
-        let working_directory = spawn_in_terminal.cwd.clone();
         let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs;
         let use_new_terminal = spawn_in_terminal.use_new_terminal;
 
         if allow_concurrent_runs && use_new_terminal {
-            self.spawn_in_new_terminal(spawn_task, working_directory, cx);
+            self.spawn_in_new_terminal(spawn_task, cx)
+                .detach_and_log_err(cx);
             return;
         }
 
         let terminals_for_task = self.terminals_for_task(&spawn_in_terminal.full_label, cx);
         if terminals_for_task.is_empty() {
-            self.spawn_in_new_terminal(spawn_task, working_directory, cx);
+            self.spawn_in_new_terminal(spawn_task, cx)
+                .detach_and_log_err(cx);
             return;
         }
         let (existing_item_index, existing_terminal) = terminals_for_task
@@ -378,13 +384,7 @@ impl TerminalPanel {
                 !use_new_terminal,
                 "Should have handled 'allow_concurrent_runs && use_new_terminal' case above"
             );
-            self.replace_terminal(
-                working_directory,
-                spawn_task,
-                existing_item_index,
-                existing_terminal,
-                cx,
-            );
+            self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx);
         } else {
             self.deferred_tasks.insert(
                 spawn_in_terminal.id.clone(),
@@ -393,14 +393,11 @@ impl TerminalPanel {
                     terminal_panel
                         .update(&mut cx, |terminal_panel, cx| {
                             if use_new_terminal {
-                                terminal_panel.spawn_in_new_terminal(
-                                    spawn_task,
-                                    working_directory,
-                                    cx,
-                                );
+                                terminal_panel
+                                    .spawn_in_new_terminal(spawn_task, cx)
+                                    .detach_and_log_err(cx);
                             } else {
                                 terminal_panel.replace_terminal(
-                                    working_directory,
                                     spawn_task,
                                     existing_item_index,
                                     existing_terminal,
@@ -428,14 +425,13 @@ impl TerminalPanel {
         }
     }
 
-    fn spawn_in_new_terminal(
+    pub fn spawn_in_new_terminal(
         &mut self,
         spawn_task: SpawnInTerminal,
-        working_directory: Option<PathBuf>,
         cx: &mut ViewContext<Self>,
-    ) {
+    ) -> Task<Result<Model<Terminal>>> {
         let reveal = spawn_task.reveal;
-        self.add_terminal(working_directory, Some(spawn_task), reveal, cx);
+        self.add_terminal(spawn_task.cwd.clone(), Some(spawn_task), reveal, cx)
     }
 
     /// Create a new Terminal in the current working directory or the user's home directory
@@ -448,9 +444,11 @@ impl TerminalPanel {
             return;
         };
 
-        terminal_panel.update(cx, |this, cx| {
-            this.add_terminal(None, None, RevealStrategy::Always, cx)
-        });
+        terminal_panel
+            .update(cx, |this, cx| {
+                this.add_terminal(None, None, RevealStrategy::Always, cx)
+            })
+            .detach_and_log_err(cx);
     }
 
     fn terminals_for_task(
@@ -482,17 +480,17 @@ impl TerminalPanel {
 
     fn add_terminal(
         &mut self,
-        working_directory: Option<PathBuf>,
+        working_directory: Option<TerminalWorkDir>,
         spawn_task: Option<SpawnInTerminal>,
         reveal_strategy: RevealStrategy,
         cx: &mut ViewContext<Self>,
-    ) {
+    ) -> Task<Result<Model<Terminal>>> {
         let workspace = self.workspace.clone();
         self.pending_terminals_to_add += 1;
 
         cx.spawn(|terminal_panel, mut cx| async move {
             let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?;
-            workspace.update(&mut cx, |workspace, cx| {
+            let result = workspace.update(&mut cx, |workspace, cx| {
                 let working_directory = if let Some(working_directory) = working_directory {
                     Some(working_directory)
                 } else {
@@ -502,35 +500,33 @@ impl TerminalPanel {
                 };
 
                 let window = cx.window_handle();
-                if let Some(terminal) = workspace.project().update(cx, |project, cx| {
-                    project
-                        .create_terminal(working_directory, spawn_task, window, cx)
-                        .log_err()
-                }) {
-                    let terminal = Box::new(cx.new_view(|cx| {
-                        TerminalView::new(
-                            terminal,
-                            workspace.weak_handle(),
-                            workspace.database_id(),
-                            cx,
-                        )
-                    }));
-                    pane.update(cx, |pane, cx| {
-                        let focus = pane.has_focus(cx);
-                        pane.add_item(terminal, true, focus, None, cx);
-                    });
-                }
+                let terminal = workspace.project().update(cx, |project, cx| {
+                    project.create_terminal(working_directory, spawn_task, window, cx)
+                })?;
+                let terminal_view = Box::new(cx.new_view(|cx| {
+                    TerminalView::new(
+                        terminal.clone(),
+                        workspace.weak_handle(),
+                        workspace.database_id(),
+                        cx,
+                    )
+                }));
+                pane.update(cx, |pane, cx| {
+                    let focus = pane.has_focus(cx);
+                    pane.add_item(terminal_view, true, focus, None, cx);
+                });
+
                 if reveal_strategy == RevealStrategy::Always {
                     workspace.focus_panel::<Self>(cx);
                 }
+                Ok(terminal)
             })?;
             terminal_panel.update(&mut cx, |this, cx| {
                 this.pending_terminals_to_add = this.pending_terminals_to_add.saturating_sub(1);
                 this.serialize(cx)
             })?;
-            anyhow::Ok(())
+            result
         })
-        .detach_and_log_err(cx);
     }
 
     fn serialize(&mut self, cx: &mut ViewContext<Self>) {
@@ -579,7 +575,6 @@ impl TerminalPanel {
 
     fn replace_terminal(
         &self,
-        working_directory: Option<PathBuf>,
         spawn_task: SpawnInTerminal,
         terminal_item_index: usize,
         terminal_to_replace: View<TerminalView>,
@@ -594,7 +589,7 @@ impl TerminalPanel {
         let window = cx.window_handle();
         let new_terminal = project.update(cx, |project, cx| {
             project
-                .create_terminal(working_directory, Some(spawn_task), window, cx)
+                .create_terminal(spawn_task.cwd.clone(), Some(spawn_task), window, cx)
                 .log_err()
         })?;
         terminal_to_replace.update(cx, |terminal_to_replace, cx| {
@@ -738,7 +733,8 @@ impl Panel for TerminalPanel {
 
     fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
         if active && self.has_no_terminals(cx) {
-            self.add_terminal(None, None, RevealStrategy::Never, cx);
+            self.add_terminal(None, None, RevealStrategy::Never, cx)
+                .detach_and_log_err(cx)
         }
     }
 

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

@@ -14,6 +14,7 @@ use language::Bias;
 use persistence::TERMINAL_DB;
 use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project};
 use settings::SettingsStore;
+use task::TerminalWorkDir;
 use terminal::{
     alacritty_terminal::{
         index::Point,
@@ -878,21 +879,26 @@ impl Item for TerminalView {
     ) -> Task<anyhow::Result<View<Self>>> {
         let window = cx.window_handle();
         cx.spawn(|pane, mut cx| async move {
-            let cwd = TERMINAL_DB
-                .get_working_directory(item_id, workspace_id)
-                .log_err()
-                .flatten()
-                .or_else(|| {
-                    cx.update(|cx| {
+            let cwd = cx
+                .update(|cx| {
+                    let from_db = TERMINAL_DB
+                        .get_working_directory(item_id, workspace_id)
+                        .log_err()
+                        .flatten();
+                    if from_db
+                        .as_ref()
+                        .is_some_and(|from_db| !from_db.as_os_str().is_empty())
+                    {
+                        project.read(cx).terminal_work_dir_for(from_db.as_ref(), cx)
+                    } else {
                         let strategy = TerminalSettings::get_global(cx).working_directory.clone();
                         workspace.upgrade().and_then(|workspace| {
                             get_working_directory(workspace.read(cx), cx, strategy)
                         })
-                    })
-                    .ok()
-                    .flatten()
+                    }
                 })
-                .filter(|cwd| !cwd.as_os_str().is_empty());
+                .ok()
+                .flatten();
 
             let terminal = project.update(&mut cx, |project, cx| {
                 project.create_terminal(cwd, None, window, cx)
@@ -1043,20 +1049,24 @@ pub fn get_working_directory(
     workspace: &Workspace,
     cx: &AppContext,
     strategy: WorkingDirectory,
-) -> Option<PathBuf> {
-    let res = match strategy {
-        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
-            .or_else(|| first_project_directory(workspace, cx)),
-        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
-        WorkingDirectory::AlwaysHome => None,
-        WorkingDirectory::Always { directory } => {
-            shellexpand::full(&directory) //TODO handle this better
-                .ok()
-                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
-                .filter(|dir| dir.is_dir())
-        }
-    };
-    res.or_else(home_dir)
+) -> Option<TerminalWorkDir> {
+    if workspace.project().read(cx).is_local() {
+        let res = match strategy {
+            WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
+                .or_else(|| first_project_directory(workspace, cx)),
+            WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
+            WorkingDirectory::AlwaysHome => None,
+            WorkingDirectory::Always { directory } => {
+                shellexpand::full(&directory) //TODO handle this better
+                    .ok()
+                    .map(|dir| Path::new(&dir.to_string()).to_path_buf())
+                    .filter(|dir| dir.is_dir())
+            }
+        };
+        res.or_else(home_dir).map(|cwd| TerminalWorkDir::Local(cwd))
+    } else {
+        workspace.project().read(cx).terminal_work_dir_for(None, cx)
+    }
 }
 
 ///Gets the first project's home directory, or the home directory

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

@@ -13,6 +13,7 @@ mod list;
 mod modal;
 mod popover;
 mod popover_menu;
+mod radio;
 mod right_click_menu;
 mod stack;
 mod tab;
@@ -39,6 +40,7 @@ pub use list::*;
 pub use modal::*;
 pub use popover::*;
 pub use popover_menu::*;
+pub use radio::*;
 pub use right_click_menu::*;
 pub use stack::*;
 pub use tab::*;

crates/ui/src/components/button/button.rs πŸ”—

@@ -1,6 +1,6 @@
 use gpui::{AnyView, DefiniteLength};
 
-use crate::{prelude::*, IconPosition, KeyBinding, Spacing};
+use crate::{prelude::*, ElevationIndex, IconPosition, KeyBinding, Spacing};
 use crate::{
     ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle,
 };
@@ -340,6 +340,11 @@ impl ButtonCommon for Button {
         self.base = self.base.tooltip(tooltip);
         self
     }
+
+    fn layer(mut self, elevation: ElevationIndex) -> Self {
+        self.base = self.base.layer(elevation);
+        self
+    }
 }
 
 impl RenderOnce for Button {

crates/ui/src/components/button/button_like.rs πŸ”—

@@ -2,7 +2,7 @@ use gpui::{relative, DefiniteLength, MouseButton};
 use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems};
 use smallvec::SmallVec;
 
-use crate::{prelude::*, Spacing};
+use crate::{prelude::*, Elevation, ElevationIndex, Spacing};
 
 /// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected.
 pub trait SelectableButton: Selectable {
@@ -33,6 +33,8 @@ pub trait ButtonCommon: Clickable + Disableable {
     /// Nearly all interactable elements should have a tooltip. Some example
     /// exceptions might a scroll bar, or a slider.
     fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self;
+
+    fn layer(self, elevation: ElevationIndex) -> Self;
 }
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
@@ -135,11 +137,35 @@ pub(crate) struct ButtonLikeStyles {
     pub icon_color: Hsla,
 }
 
+fn element_bg_from_elevation(elevation: Option<Elevation>, cx: &mut WindowContext) -> Hsla {
+    match elevation {
+        Some(Elevation::ElevationIndex(ElevationIndex::Background)) => {
+            cx.theme().colors().element_background
+        }
+        Some(Elevation::ElevationIndex(ElevationIndex::ElevatedSurface)) => {
+            cx.theme().colors().surface_background
+        }
+        Some(Elevation::ElevationIndex(ElevationIndex::Surface)) => {
+            cx.theme().colors().elevated_surface_background
+        }
+        Some(Elevation::ElevationIndex(ElevationIndex::ModalSurface)) => {
+            cx.theme().colors().background
+        }
+        _ => cx.theme().colors().element_background,
+    }
+}
+
 impl ButtonStyle {
-    pub(crate) fn enabled(self, cx: &mut WindowContext) -> ButtonLikeStyles {
+    pub(crate) fn enabled(
+        self,
+        elevation: Option<Elevation>,
+        cx: &mut WindowContext,
+    ) -> ButtonLikeStyles {
+        let filled_background = element_bg_from_elevation(elevation, cx);
+
         match self {
             ButtonStyle::Filled => ButtonLikeStyles {
-                background: cx.theme().colors().element_background,
+                background: filled_background,
                 border_color: transparent_black(),
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
@@ -160,10 +186,17 @@ impl ButtonStyle {
         }
     }
 
-    pub(crate) fn hovered(self, cx: &mut WindowContext) -> ButtonLikeStyles {
+    pub(crate) fn hovered(
+        self,
+        elevation: Option<Elevation>,
+        cx: &mut WindowContext,
+    ) -> ButtonLikeStyles {
+        let mut filled_background = element_bg_from_elevation(elevation, cx);
+        filled_background.fade_out(0.92);
+
         match self {
             ButtonStyle::Filled => ButtonLikeStyles {
-                background: cx.theme().colors().element_hover,
+                background: filled_background,
                 border_color: transparent_black(),
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
@@ -238,7 +271,13 @@ impl ButtonStyle {
     }
 
     #[allow(unused)]
-    pub(crate) fn disabled(self, cx: &mut WindowContext) -> ButtonLikeStyles {
+    pub(crate) fn disabled(
+        self,
+        elevation: Option<Elevation>,
+        cx: &mut WindowContext,
+    ) -> ButtonLikeStyles {
+        let filled_background = element_bg_from_elevation(elevation, cx).fade_out(0.82);
+
         match self {
             ButtonStyle::Filled => ButtonLikeStyles {
                 background: cx.theme().colors().element_disabled,
@@ -301,6 +340,7 @@ pub struct ButtonLike {
     pub(super) selected_style: Option<ButtonStyle>,
     pub(super) width: Option<DefiniteLength>,
     pub(super) height: Option<DefiniteLength>,
+    pub(super) layer: Option<Elevation>,
     size: ButtonSize,
     rounding: Option<ButtonLikeRounding>,
     tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>,
@@ -324,6 +364,7 @@ impl ButtonLike {
             tooltip: None,
             children: SmallVec::new(),
             on_click: None,
+            layer: None,
         }
     }
 
@@ -397,6 +438,11 @@ impl ButtonCommon for ButtonLike {
         self.tooltip = Some(Box::new(tooltip));
         self
     }
+
+    fn layer(mut self, elevation: ElevationIndex) -> Self {
+        self.layer = Some(elevation.into());
+        self
+    }
 }
 
 impl VisibleOnHover for ButtonLike {
@@ -437,11 +483,11 @@ impl RenderOnce for ButtonLike {
                 ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)),
                 ButtonSize::None => this,
             })
-            .bg(style.enabled(cx).background)
+            .bg(style.enabled(self.layer, cx).background)
             .when(self.disabled, |this| this.cursor_not_allowed())
             .when(!self.disabled, |this| {
                 this.cursor_pointer()
-                    .hover(|hover| hover.bg(style.hovered(cx).background))
+                    .hover(|hover| hover.bg(style.hovered(self.layer, cx).background))
                     .active(|active| active.bg(style.active(cx).background))
             })
             .when_some(

crates/ui/src/components/button/icon_button.rs πŸ”—

@@ -1,6 +1,6 @@
 use gpui::{AnyView, DefiniteLength};
 
-use crate::{prelude::*, SelectableButton, Spacing};
+use crate::{prelude::*, ElevationIndex, SelectableButton, Spacing};
 use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize};
 
 use super::button_icon::ButtonIcon;
@@ -119,6 +119,11 @@ impl ButtonCommon for IconButton {
         self.base = self.base.tooltip(tooltip);
         self
     }
+
+    fn layer(mut self, elevation: ElevationIndex) -> Self {
+        self.base = self.base.layer(elevation);
+        self
+    }
 }
 
 impl VisibleOnHover for IconButton {

crates/ui/src/components/button/toggle_button.rs πŸ”—

@@ -1,6 +1,6 @@
 use gpui::{AnyView, ClickEvent};
 
-use crate::{prelude::*, ButtonLike, ButtonLikeRounding};
+use crate::{prelude::*, ButtonLike, ButtonLikeRounding, ElevationIndex};
 
 /// The position of a [`ToggleButton`] within a group of buttons.
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -103,6 +103,11 @@ impl ButtonCommon for ToggleButton {
         self.base = self.base.tooltip(tooltip);
         self
     }
+
+    fn layer(mut self, elevation: ElevationIndex) -> Self {
+        self.base = self.base.layer(elevation);
+        self
+    }
 }
 
 impl RenderOnce for ToggleButton {

crates/ui/src/components/modal.rs πŸ”—

@@ -1,29 +1,121 @@
+use crate::{
+    h_flex, rems_from_px, v_flex, Clickable, Color, Headline, HeadlineSize, IconButton,
+    IconButtonShape, IconName, Label, LabelCommon, LabelSize, Spacing,
+};
 use gpui::{prelude::FluentBuilder, *};
 use smallvec::SmallVec;
+use theme::ActiveTheme;
 
-use crate::{
-    h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize,
-    Spacing,
-};
+#[derive(IntoElement)]
+pub struct Modal {
+    id: ElementId,
+    header: ModalHeader,
+    children: SmallVec<[AnyElement; 2]>,
+    footer: Option<ModalFooter>,
+    container_id: ElementId,
+    container_scroll_handler: Option<ScrollHandle>,
+}
+
+impl Modal {
+    pub fn new(id: impl Into<SharedString>, scroll_handle: Option<ScrollHandle>) -> Self {
+        let id = id.into();
+
+        let container_id = ElementId::Name(format!("{}_container", id.clone()).into());
+        Self {
+            id: ElementId::Name(id),
+            header: ModalHeader::new(),
+            children: SmallVec::new(),
+            footer: None,
+            container_id,
+            container_scroll_handler: scroll_handle,
+        }
+    }
+
+    pub fn header(mut self, header: ModalHeader) -> Self {
+        self.header = header;
+        self
+    }
+
+    pub fn section(mut self, section: Section) -> Self {
+        self.children.push(section.into_any_element());
+        self
+    }
+
+    pub fn footer(mut self, footer: ModalFooter) -> Self {
+        self.footer = Some(footer);
+        self
+    }
+
+    pub fn show_dismiss(mut self, show: bool) -> Self {
+        self.header.show_dismiss_button = show;
+        self
+    }
+
+    pub fn show_back(mut self, show: bool) -> Self {
+        self.header.show_back_button = show;
+        self
+    }
+}
+
+impl ParentElement for Modal {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl RenderOnce for Modal {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        v_flex()
+            .id(self.id.clone())
+            .size_full()
+            .flex_1()
+            .overflow_hidden()
+            .child(self.header)
+            .child(
+                v_flex()
+                    .id(self.container_id.clone())
+                    .w_full()
+                    .gap(Spacing::Large.rems(cx))
+                    .when_some(
+                        self.container_scroll_handler,
+                        |this, container_scroll_handle| {
+                            this.overflow_y_scroll()
+                                .track_scroll(&container_scroll_handle)
+                        },
+                    )
+                    .children(self.children),
+            )
+            .children(self.footer)
+    }
+}
 
 #[derive(IntoElement)]
 pub struct ModalHeader {
-    id: ElementId,
+    headline: Option<SharedString>,
     children: SmallVec<[AnyElement; 2]>,
     show_dismiss_button: bool,
     show_back_button: bool,
 }
 
 impl ModalHeader {
-    pub fn new(id: impl Into<ElementId>) -> Self {
+    pub fn new() -> Self {
         Self {
-            id: id.into(),
+            headline: None,
             children: SmallVec::new(),
             show_dismiss_button: false,
             show_back_button: false,
         }
     }
 
+    /// Set the headline of the modal.
+    ///
+    /// This will insert the headline as the first item
+    /// of `children` if it is not already present.
+    pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
+        self.headline = Some(headline.into());
+        self
+    }
+
     pub fn show_dismiss_button(mut self, show: bool) -> Self {
         self.show_dismiss_button = show;
         self
@@ -43,24 +135,36 @@ impl ParentElement for ModalHeader {
 
 impl RenderOnce for ModalHeader {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let mut children = self.children;
+
+        if self.headline.is_some() {
+            children.insert(
+                0,
+                Headline::new(self.headline.unwrap())
+                    .size(HeadlineSize::XSmall)
+                    .color(Color::Muted)
+                    .into_any_element(),
+            );
+        }
+
         h_flex()
-            .id(self.id)
+            .flex_none()
+            .justify_between()
             .w_full()
-            .px(Spacing::Large.rems(cx))
-            .py_1p5()
+            .px(Spacing::XLarge.rems(cx))
+            .pt(Spacing::Large.rems(cx))
+            .pb(Spacing::Small.rems(cx))
+            .gap(Spacing::Large.rems(cx))
             .when(self.show_back_button, |this| {
                 this.child(
-                    div().pr_1().child(
-                        IconButton::new("back", IconName::ArrowLeft)
-                            .shape(IconButtonShape::Square)
-                            .on_click(|_, cx| {
-                                cx.dispatch_action(menu::Cancel.boxed_clone());
-                            }),
-                    ),
+                    IconButton::new("back", IconName::ArrowLeft)
+                        .shape(IconButtonShape::Square)
+                        .on_click(|_, cx| {
+                            cx.dispatch_action(menu::Cancel.boxed_clone());
+                        }),
                 )
             })
-            .child(div().flex_1().children(self.children))
-            .justify_between()
+            .child(div().flex_1().children(children))
             .when(self.show_dismiss_button, |this| {
                 this.child(
                     IconButton::new("dismiss", IconName::Close)
@@ -74,11 +178,11 @@ impl RenderOnce for ModalHeader {
 }
 
 #[derive(IntoElement)]
-pub struct ModalContent {
+pub struct ModalRow {
     children: SmallVec<[AnyElement; 2]>,
 }
 
-impl ModalContent {
+impl ModalRow {
     pub fn new() -> Self {
         Self {
             children: SmallVec::new(),
@@ -86,40 +190,145 @@ impl ModalContent {
     }
 }
 
-impl ParentElement for ModalContent {
+impl ParentElement for ModalRow {
     fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
         self.children.extend(elements)
     }
 }
 
-impl RenderOnce for ModalContent {
+impl RenderOnce for ModalRow {
     fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        h_flex().w_full().px_2().py_1p5().children(self.children)
+        h_flex().w_full().px_2().py_1().children(self.children)
     }
 }
 
 #[derive(IntoElement)]
-pub struct ModalRow {
+pub struct ModalFooter {
+    start_slot: Option<AnyElement>,
+    end_slot: Option<AnyElement>,
+}
+
+impl ModalFooter {
+    pub fn new() -> Self {
+        Self {
+            start_slot: None,
+            end_slot: None,
+        }
+    }
+
+    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
+        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
+        self
+    }
+
+    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
+        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
+        self
+    }
+}
+
+impl RenderOnce for ModalFooter {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        h_flex()
+            .flex_none()
+            .w_full()
+            .p(Spacing::Large.rems(cx))
+            .justify_between()
+            .child(div().when_some(self.start_slot, |this, start_slot| this.child(start_slot)))
+            .child(div().when_some(self.end_slot, |this, end_slot| this.child(end_slot)))
+    }
+}
+
+#[derive(IntoElement)]
+pub struct Section {
+    contained: bool,
+    header: Option<SectionHeader>,
+    meta: Option<SharedString>,
     children: SmallVec<[AnyElement; 2]>,
 }
 
-impl ModalRow {
+impl Section {
     pub fn new() -> Self {
         Self {
+            contained: false,
+            header: None,
+            meta: None,
+            children: SmallVec::new(),
+        }
+    }
+
+    pub fn new_contained() -> Self {
+        Self {
+            contained: true,
+            header: None,
+            meta: None,
             children: SmallVec::new(),
         }
     }
+
+    pub fn contained(mut self, contained: bool) -> Self {
+        self.contained = contained;
+        self
+    }
+
+    pub fn header(mut self, header: SectionHeader) -> Self {
+        self.header = Some(header);
+        self
+    }
+
+    pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
+        self.meta = Some(meta.into());
+        self
+    }
 }
 
-impl ParentElement for ModalRow {
+impl ParentElement for Section {
     fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
         self.children.extend(elements)
     }
 }
 
-impl RenderOnce for ModalRow {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        h_flex().w_full().px_2().py_1().children(self.children)
+impl RenderOnce for Section {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let mut section_bg = cx.theme().colors().text;
+        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))
+                    .px(Spacing::Large.rems(cx) - rems_from_px(1.0))
+                    .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::Large.rems(cx) + Spacing::Large.rems(cx))
+                .children(self.children)
+        };
+
+        v_flex()
+            .size_full()
+            .flex_1()
+            .child(
+                v_flex()
+                    .flex_none()
+                    .px(Spacing::XLarge.rems(cx))
+                    .children(self.header)
+                    .when_some(self.meta, |this, meta| {
+                        this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
+                    }),
+            )
+            .child(children)
+            // fill any leftover space
+            .child(div().flex().flex_1())
     }
 }
 
@@ -147,23 +356,40 @@ impl SectionHeader {
 }
 
 impl RenderOnce for SectionHeader {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        h_flex().id(self.label.clone()).w_full().child(
-            div()
-                .h_7()
-                .flex()
-                .items_center()
-                .justify_between()
-                .w_full()
-                .gap_1()
-                .child(
-                    div().flex_1().child(
-                        Label::new(self.label.clone())
-                            .size(LabelSize::Large)
-                            .into_element(),
-                    ),
-                )
-                .child(h_flex().children(self.end_slot)),
-        )
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        h_flex()
+            .id(self.label.clone())
+            .w_full()
+            .px(Spacing::Large.rems(cx))
+            .child(
+                div()
+                    .h_7()
+                    .flex()
+                    .items_center()
+                    .justify_between()
+                    .w_full()
+                    .gap(Spacing::Small.rems(cx))
+                    .child(
+                        div().flex_1().child(
+                            Label::new(self.label.clone())
+                                .size(LabelSize::Small)
+                                .into_element(),
+                        ),
+                    )
+                    .child(h_flex().children(self.end_slot)),
+            )
+    }
+}
+
+impl Into<SectionHeader> for SharedString {
+    fn into(self) -> SectionHeader {
+        SectionHeader::new(self)
+    }
+}
+
+impl Into<SectionHeader> for &'static str {
+    fn into(self) -> SectionHeader {
+        let label: SharedString = self.into();
+        SectionHeader::new(label)
     }
 }

crates/ui/src/components/radio.rs πŸ”—

@@ -0,0 +1,61 @@
+use std::sync::Arc;
+
+use crate::prelude::*;
+
+/// A [`Checkbox`] that has a [`Label`].
+#[derive(IntoElement)]
+pub struct RadioWithLabel {
+    id: ElementId,
+    label: Label,
+    selected: bool,
+    on_click: Arc<dyn Fn(&bool, &mut WindowContext) + 'static>,
+}
+
+impl RadioWithLabel {
+    pub fn new(
+        id: impl Into<ElementId>,
+        label: Label,
+        selected: bool,
+        on_click: impl Fn(&bool, &mut WindowContext) + 'static,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            label,
+            selected,
+            on_click: Arc::new(on_click),
+        }
+    }
+}
+
+impl RenderOnce for RadioWithLabel {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let inner_diameter = rems_from_px(6.);
+        let outer_diameter = rems_from_px(16.);
+        let border_width = rems_from_px(1.);
+        h_flex()
+            .id(self.id)
+            .gap(Spacing::Large.rems(cx))
+            .group("")
+            .child(
+                div()
+                    .size(outer_diameter)
+                    .rounded(outer_diameter / 2.)
+                    .border_color(cx.theme().colors().border)
+                    .border(border_width)
+                    .group_hover("", |el| el.bg(cx.theme().colors().element_hover))
+                    .when(self.selected, |el| {
+                        el.child(
+                            div()
+                                .m((outer_diameter - inner_diameter) / 2. - border_width)
+                                .size(inner_diameter)
+                                .rounded(inner_diameter / 2.)
+                                .bg(cx.theme().colors().icon_accent),
+                        )
+                    }),
+            )
+            .child(self.label)
+            .on_click(move |_event, cx| {
+                (self.on_click)(&true, cx);
+            })
+    }
+}

crates/ui/src/styles/elevation.rs πŸ”—

@@ -9,6 +9,12 @@ pub enum Elevation {
     ElementIndex(ElementIndex),
 }
 
+impl Into<Elevation> for ElevationIndex {
+    fn into(self) -> Elevation {
+        Elevation::ElevationIndex(self)
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ElevationIndex {
     Background,

crates/ui/src/styles/typography.rs πŸ”—

@@ -4,7 +4,7 @@ use gpui::{
 use settings::Settings;
 use theme::{ActiveTheme, ThemeSettings};
 
-use crate::rems_from_px;
+use crate::{rems_from_px, Color};
 
 /// Extends [`gpui::Styled`] with typography-related styling methods.
 pub trait StyledTypography: Styled + Sized {
@@ -164,6 +164,7 @@ impl HeadlineSize {
 pub struct Headline {
     size: HeadlineSize,
     text: SharedString,
+    color: Color,
 }
 
 impl RenderOnce for Headline {
@@ -184,6 +185,7 @@ impl Headline {
         Self {
             size: HeadlineSize::default(),
             text: text.into(),
+            color: Color::default(),
         }
     }
 
@@ -191,4 +193,9 @@ impl Headline {
         self.size = size;
         self
     }
+
+    pub fn color(mut self, color: Color) -> Self {
+        self.color = color;
+        self
+    }
 }

docs/src/remote-development.md πŸ”—

@@ -6,7 +6,7 @@ Remote Development allows you to code at the speed of thought, even when your co
 
 ## Overview
 
-Remote development requires running two instances of Zed. A headless instance on the remote machine, and the editor interface on your local computer. All configuration is done on your local computer, except for starting the headless instance.
+Remote development requires running two instances of Zed. A headless instance on the remote machine, and the editor interface on your local computer. All configuration is done on your local computer.
 
 Currently the two instances connect via Zed's servers, but we intend to build peer to peer communication in the future.
 
@@ -14,18 +14,48 @@ Currently the two instances connect via Zed's servers, but we intend to build pe
 
 > NOTE: You must be in the alpha program to see this UI. The instructions will likely change as the feature gets closer to launch.
 
-1. Open the projects dialog with `cmd-option-o` and then click "Connect…".
+1. Download and install the latest [Zed Preview](https://zed.dev/releases/preview).
+1. Open the remote projects dialogue with `cmd-shift-p remote`
 2. Click "Add Server"
-3. Give it a name, and copy the instructions given.
-4. On the remote machine, install Zed
-   ```
-   curl https://zed.dev/install.sh | bash
-   ```
-5. On the remote machine, paste the instructions from step 3. You should see `connected!`.
-   > NOTE: If this command runs but doesn't output anything, try running `zed --foreground --dev-server-token YY.XXX`. It is possible that the zed background process is crashing on startup.
+3. Choose whether to setup via SSH, or to follow the manual setup.
+   > NOTE: With both options your laptop and the remote machine will communicate
+     via https://collab.zed.dev/, so you will need outbound internet access on the remote machine.
 6. On your laptop you can now open folders on the remote machine.
    > NOTE: Zed does not currently handle opening very large directories (e.g. `/` or `~` that may have >100,000 files) very well. We are working on improving this, but suggest in the meantime opening only specific projects, or subfolders of very large mono-repos.
 
+## Toubleshooting
+
+### UI is not showing up
+
+This can happen either if you were just added to the alpha, in which case you need to restart zed. Or, if you lost connection to the zed server, in which case you just need to click "Sign In" in the top right.
+
+### SSH connections
+
+If you chose to connect via SSH, the command you specify will be run in a zed terminal given you an opportunity to type any passwords/keyphrases etc. that you need.
+Once a connection is established zed will be downloaded and installed to `~/.local/bin/zed` on the remote machine, and run.
+
+If you don't see any output from the zed command, it is likely that zed is crashing
+on startup. You can troubleshoot this by switching to manual mode and passing the `--foreground` flag. Please [file a bug](https://github.com/zed-industries/zed) so we can debug it together.
+
+### SSH-like connections
+
+Zed intercepts `ssh` in a way that should make it possible to intercept connections made by most "ssh wrappers". For example you
+can specify:
+
+* `user@host` will assume you meant `ssh user@host`
+* `ssh -J jump target` to connect via a jump-host
+* `gh cs ssh -c example-codespace` to connect to a github codespace
+* `doctl compute ssh example-droplet` to connect to a digital ocean droplet
+* `gcloud compute ssh` for a google cloud instance
+
+### zed --dev-server-token isn't connecting
+
+There are a few likely causes of failure:
+
+* `zed --dev-server-token` runs but outputs nothing. This is probably because the zed background process is crashing on startup. Try running `zed --dev-server-token XX --foreground` to see any output, and [file a bug](https://github.com/zed-industries/zed) so we can debug it together.
+* `zed --dev-server-token` outputs something like "Connection refused" or "Unauthorized" and immediately exits. This is likely due to issues making outbound HTTP requests to https://collab.zed.dev from your host. You can try to debug this with `curl https://collab.zed.dev`, but we have seen cases where curl is whitelisted, but other binaries are not allowed network access.
+* `zed --dev-server-token` outputs "Zed is already running". If you are editing an existing server, it is possible that clicking "Connect" a second time will work, but if not you will have to manually log into the server and kill the zed process.
+
 ## Supported platforms
 
 The remote machine must be able to run Zed. The following platforms should work, though note that we have not exhaustively tested every linux distribution:
@@ -36,11 +66,11 @@ The remote machine must be able to run Zed. The following platforms should work,
 
 ## Known Limitations
 
-- The Terminal does not work remotely.
+- The Terminal does not work remotely unless you configure the machine to use SSH.
 - You cannot spawn Tasks remotely.
 - Extensions aren't yet supported in headless Zed.
 - You can not run `zed` in headless mode and in GUI mode at the same time on the same machine.
 
 ## Feedback
 
-- Please join the #remoting-feedback in the [Zed Discord](https://discord.gg/qSDQ8VWc7k).
+- Please join the #remoting-feedback channel in the [Zed Discord](https://discord.gg/qSDQ8VWc7k).

script/install.sh πŸ”—

@@ -112,7 +112,19 @@ macos() {
     ditto "$temp/mount/$app" "/Applications/$app"
     hdiutil detach -quiet "$temp/mount"
 
-    echo "Zed has been installed. Run with 'open /Applications/$app'"
+    mkdir -p "$HOME/.local/bin"
+    # Link the binary
+    ln -sf /Applications/$app/Contents/MacOS/cli "$HOME/.local/bin/zed"
+
+    if which "zed" >/dev/null 2>&1; then
+        echo "Zed has been installed. Run with 'zed'"
+    else
+        echo "To run Zed from your terminal, you must add ~/.local/bin to your PATH"
+        echo "Run:"
+        echo "   echo 'export PATH=\$HOME/.local/bin:\$PATH' >> ~/.bashrc"
+        echo "   source ~/.bashrc"
+        echo "To run Zed now, '~/.local/bin/zed'"
+    fi
 }
 
 main "$@"