Support terminals with ssh in remote projects (#11913)

Kirill Bulatov created

Release Notes:

- Added a way to create terminal tabs in remote projects, if an ssh
connection string is specified

Change summary

Cargo.lock                                                           |   1 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql       |   1 
crates/collab/migrations/20240514164510_store_ssh_connect_string.sql |   1 
crates/collab/src/db/queries/dev_servers.rs                          |   4 
crates/collab/src/db/tables/dev_server.rs                            |   2 
crates/collab/src/rpc.rs                                             |   9 
crates/collab/src/tests/dev_server_tests.rs                          |   6 
crates/dev_server_projects/src/dev_server_projects.rs                |  10 
crates/project/Cargo.toml                                            |   1 
crates/project/src/terminals.rs                                      | 109 
crates/recent_projects/src/dev_servers.rs                            | 129 
crates/rpc/proto/zed.proto                                           |   2 
crates/terminal_view/src/terminal_panel.rs                           |   8 
crates/zed/src/zed.rs                                                |  20 
14 files changed, 237 insertions(+), 66 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7651,6 +7651,7 @@ dependencies = [
  "client",
  "clock",
  "collections",
+ "dev_server_projects",
  "env_logger",
  "fs",
  "futures 0.3.28",

crates/collab/src/db/queries/dev_servers.rs 🔗

@@ -73,6 +73,7 @@ impl Database {
     pub async fn create_dev_server(
         &self,
         name: &str,
+        ssh_connection_string: Option<&str>,
         hashed_access_token: &str,
         user_id: UserId,
     ) -> crate::Result<(dev_server::Model, proto::DevServerProjectsUpdate)> {
@@ -86,6 +87,9 @@ impl Database {
                 hashed_token: ActiveValue::Set(hashed_access_token.to_string()),
                 name: ActiveValue::Set(name.trim().to_string()),
                 user_id: ActiveValue::Set(user_id),
+                ssh_connection_string: ActiveValue::Set(
+                    ssh_connection_string.map(ToOwned::to_owned),
+                ),
             })
             .exec_with_returning(&*tx)
             .await?;

crates/collab/src/db/tables/dev_server.rs 🔗

@@ -10,6 +10,7 @@ pub struct Model {
     pub name: String,
     pub user_id: UserId,
     pub hashed_token: String,
+    pub ssh_connection_string: Option<String>,
 }
 
 impl ActiveModelBehavior for ActiveModel {}
@@ -32,6 +33,7 @@ impl Model {
             dev_server_id: self.id.to_proto(),
             name: self.name.clone(),
             status: status as i32,
+            ssh_connection_string: self.ssh_connection_string.clone(),
         }
     }
 }

crates/collab/src/rpc.rs 🔗

@@ -2365,7 +2365,12 @@ async fn create_dev_server(
     let (dev_server, status) = session
         .db()
         .await
-        .create_dev_server(&request.name, &hashed_access_token, session.user_id())
+        .create_dev_server(
+            &request.name,
+            request.ssh_connection_string.as_deref(),
+            &hashed_access_token,
+            session.user_id(),
+        )
         .await?;
 
     send_dev_server_projects_update(session.user_id(), status, &session).await;
@@ -2373,7 +2378,7 @@ async fn create_dev_server(
     response.send(proto::CreateDevServerResponse {
         dev_server_id: dev_server.id.0 as u64,
         access_token: auth::generate_dev_server_token(dev_server.id.0 as usize, access_token),
-        name: request.name.clone(),
+        name: request.name,
     })?;
     Ok(())
 }

crates/collab/src/tests/dev_server_tests.rs 🔗

@@ -20,7 +20,7 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
 
     let resp = store
         .update(cx, |store, cx| {
-            store.create_dev_server("server-1".to_string(), cx)
+            store.create_dev_server("server-1".to_string(), None, cx)
         })
         .await
         .unwrap();
@@ -167,7 +167,7 @@ async fn create_dev_server_project(
 
     let resp = store
         .update(cx, |store, cx| {
-            store.create_dev_server("server-1".to_string(), cx)
+            store.create_dev_server("server-1".to_string(), None, cx)
         })
         .await
         .unwrap();
@@ -521,7 +521,7 @@ async fn test_create_dev_server_project_path_validation(
 
     let resp = store
         .update(cx1, |store, cx| {
-            store.create_dev_server("server-2".to_string(), cx)
+            store.create_dev_server("server-2".to_string(), None, cx)
         })
         .await
         .unwrap();

crates/dev_server_projects/src/dev_server_projects.rs 🔗

@@ -39,6 +39,7 @@ impl From<proto::DevServerProject> for DevServerProject {
 pub struct DevServer {
     pub id: DevServerId,
     pub name: SharedString,
+    pub ssh_connection_string: Option<SharedString>,
     pub status: DevServerStatus,
 }
 
@@ -48,6 +49,7 @@ impl From<proto::DevServer> for DevServer {
             id: DevServerId(dev_server.dev_server_id),
             status: dev_server.status(),
             name: dev_server.name.into(),
+            ssh_connection_string: dev_server.ssh_connection_string.map(|s| s.into()),
         }
     }
 }
@@ -164,11 +166,17 @@ impl Store {
     pub fn create_dev_server(
         &mut self,
         name: String,
+        ssh_connection_string: Option<String>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<proto::CreateDevServerResponse>> {
         let client = self.client.clone();
         cx.background_executor().spawn(async move {
-            let result = client.request(proto::CreateDevServer { name }).await?;
+            let result = client
+                .request(proto::CreateDevServer {
+                    name,
+                    ssh_connection_string,
+                })
+                .await?;
             Ok(result)
         })
     }

crates/project/Cargo.toml 🔗

@@ -30,6 +30,7 @@ async-trait.workspace = true
 client.workspace = true
 clock.workspace = true
 collections.workspace = true
+dev_server_projects.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/project/src/terminals.rs 🔗

@@ -1,6 +1,8 @@
 use crate::Project;
 use collections::HashMap;
-use gpui::{AnyWindowHandle, Context, Entity, Model, ModelContext, WeakModel};
+use gpui::{
+    AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, SharedString, WeakModel,
+};
 use settings::{Settings, SettingsLocation};
 use smol::channel::bounded;
 use std::path::{Path, PathBuf};
@@ -18,7 +20,38 @@ pub struct Terminals {
     pub(crate) local_handles: Vec<WeakModel<terminal::Terminal>>,
 }
 
+#[derive(Debug, Clone)]
+pub struct ConnectRemoteTerminal {
+    pub ssh_connection_string: SharedString,
+    pub project_path: SharedString,
+}
+
 impl Project {
+    pub fn remote_terminal_connection_data(
+        &self,
+        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,
+                },
+            )
+    }
+
     pub fn create_terminal(
         &mut self,
         working_directory: Option<PathBuf>,
@@ -26,10 +59,15 @@ impl Project {
         window: AnyWindowHandle,
         cx: &mut ModelContext<Self>,
     ) -> anyhow::Result<Model<Terminal>> {
-        anyhow::ensure!(
-            !self.is_remote(),
-            "creating terminals as a guest is not supported yet"
-        );
+        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 = {
@@ -48,7 +86,7 @@ impl Project {
             path,
         });
 
-        let is_terminal = spawn_task.is_none();
+        let is_terminal = spawn_task.is_none() && remote_connection_data.is_none();
         let settings = TerminalSettings::get(settings_location, cx);
         let python_settings = settings.detect_venv.clone();
         let (completion_tx, completion_rx) = bounded(1);
@@ -61,7 +99,30 @@ impl Project {
             .as_deref()
             .unwrap_or_else(|| Path::new(""));
 
-        let (spawn_task, shell) = if let Some(spawn_task) = spawn_task {
+        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
@@ -231,4 +292,38 @@ impl Project {
     }
 }
 
+#[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/src/dev_servers.rs 🔗

@@ -16,6 +16,7 @@ use rpc::{
 };
 use settings::Settings;
 use theme::ThemeSettings;
+use ui::CheckboxWithLabel;
 use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
 use ui_text_field::{FieldLabelLayout, TextField};
 use util::ResultExt;
@@ -30,14 +31,16 @@ pub struct DevServerProjects {
     dev_server_store: Model<dev_server_projects::Store>,
     project_path_input: View<Editor>,
     dev_server_name_input: View<TextField>,
+    use_server_name_in_ssh: Selection,
     rename_dev_server_input: View<TextField>,
-    _subscription: gpui::Subscription,
+    _dev_server_subscription: Subscription,
 }
 
 #[derive(Default, Clone)]
 struct CreateDevServer {
     creating: bool,
     dev_server: Option<CreateDevServerResponse>,
+    // ssh_connection_string: Option<String>,
 }
 
 #[derive(Clone)]
@@ -118,7 +121,8 @@ impl DevServerProjects {
             project_path_input,
             dev_server_name_input,
             rename_dev_server_input,
-            _subscription: subscription,
+            use_server_name_in_ssh: Selection::Unselected,
+            _dev_server_subscription: subscription,
         }
     }
 
@@ -232,22 +236,20 @@ impl DevServerProjects {
     }
 
     pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
-        let name = self
-            .dev_server_name_input
-            .read(cx)
-            .editor()
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-
-        if name == "" {
+        let name = get_text(&self.dev_server_name_input, cx);
+        if name.is_empty() {
             return;
         }
 
-        let dev_server = self
-            .dev_server_store
-            .update(cx, |store, cx| store.create_dev_server(name.clone(), cx));
+        let ssh_connection_string = if self.use_server_name_in_ssh == Selection::Selected {
+            Some(name.clone())
+        } else {
+            None
+        };
+
+        let dev_server = self.dev_server_store.update(cx, |store, cx| {
+            store.create_dev_server(name, ssh_connection_string, cx)
+        });
 
         cx.spawn(|this, mut cx| async move {
             let result = dev_server.await;
@@ -277,14 +279,7 @@ impl DevServerProjects {
     }
 
     fn rename_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
-        let name = self
-            .rename_dev_server_input
-            .read(cx)
-            .editor()
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
+        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;
@@ -683,40 +678,78 @@ impl DevServerProjects {
                     v_flex()
                         .w_full()
                         .child(
-                            h_flex()
+                            v_flex()
                                 .pb_2()
-                                .items_end()
                                 .w_full()
                                 .px_2()
-                                .border_b_1()
-                                .border_color(cx.theme().colors().border)
                                 .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(Button::new("create-dev-server", "Create").on_click(
-                                                cx.listener(move |this, _, cx| {
-                                                    this.create_dev_server(cx);
-                                                }),
-                                            ))
+                                            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(
-                                                Button::new("create-dev-server", "Creating...")
-                                                    .disabled(true),
-                                            )
+                                            div
+                                                .child(
+                                                    CheckboxWithLabel::new(
+                                                        "use-server-name-in-ssh",
+                                                        Label::new("Use name as ssh connection string"),
+                                                        self.use_server_name_in_ssh,
+                                                        |&_, _| {}
+                                                    )
+                                                )
+                                                .child(
+                                                    Button::new("create-dev-server", "Creating...")
+                                                        .disabled(true),
+                                                )
                                         }),
                                 )
                         )
                         .when(dev_server.is_none(), |div| {
-                            div.px_2().child(Label::new("Once you have created a dev server, you will be given a command to run on the server to register it.").color(Color::Muted))
+                            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\
+                                Ssh connection string enables remote terminals, which runs `ssh {ssh_host_name}` when creating terminal tabs."
+                            )))
                         })
                         .when_some(dev_server.clone(), |div, dev_server| {
                             let status = self
@@ -973,15 +1006,14 @@ impl DevServerProjects {
                                     .icon_position(IconPosition::Start)
                                     .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
                                     .on_click(cx.listener(|this, _, cx| {
-                                        this.mode = Mode::CreateDevServer(Default::default());
-
-                                        this.dev_server_name_input.update(cx, |input, cx| {
-                                            input.editor().update(cx, |editor, 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);
                                             });
-                                            input.focus_handle(cx).focus(cx)
                                         });
-
+                                        this.use_server_name_in_ssh = Selection::Unselected;
                                         cx.notify();
                                     })),
                             ),
@@ -999,6 +1031,17 @@ impl DevServerProjects {
             )
     }
 }
+
+fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
+    element
+        .read(cx)
+        .editor()
+        .read(cx)
+        .text(cx)
+        .trim()
+        .to_string()
+}
+
 impl ModalView for DevServerProjects {}
 
 impl FocusableView for DevServerProjects {

crates/rpc/proto/zed.proto 🔗

@@ -490,6 +490,7 @@ message ValidateDevServerProjectRequest {
 message CreateDevServer {
     reserved 1;
     string name = 2;
+    optional string ssh_connection_string = 3;
 }
 
 message RegenerateDevServerToken {
@@ -1251,6 +1252,7 @@ message DevServer {
     uint64 dev_server_id = 2;
     string name = 3;
     DevServerStatus status = 4;
+    optional string ssh_connection_string = 5;
 }
 
 enum DevServerStatus {

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -493,14 +493,6 @@ impl TerminalPanel {
         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| {
-                if workspace.project().read(cx).is_remote() {
-                    workspace.show_error(
-                        &anyhow::anyhow!("Cannot open terminals on remote projects (yet!)"),
-                        cx,
-                    );
-                    return;
-                };
-
                 let working_directory = if let Some(working_directory) = working_directory {
                     Some(working_directory)
                 } else {

crates/zed/src/zed.rs 🔗

@@ -214,8 +214,24 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
             workspace_handle.update(&mut cx, |workspace, cx| {
                 workspace.add_panel(assistant_panel, cx);
                 workspace.add_panel(project_panel, cx);
-                if !workspace.project().read(cx).is_remote() {
-                    workspace.add_panel(terminal_panel, cx);
+                {
+                    let project = workspace.project().read(cx);
+                    if project.is_local()
+                        || project
+                            .dev_server_project_id()
+                            .and_then(|dev_server_project_id| {
+                                Some(
+                                    dev_server_projects::Store::global(cx)
+                                        .read(cx)
+                                        .dev_server_for_project(dev_server_project_id)?
+                                        .ssh_connection_string
+                                        .is_some(),
+                                )
+                            })
+                            .unwrap_or(false)
+                    {
+                        workspace.add_panel(terminal_panel, cx);
+                    }
                 }
                 workspace.add_panel(channels_panel, cx);
                 workspace.add_panel(chat_panel, cx);