From 8631280baad9a6355b8887ed8289416738ae4f98 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 17 May 2024 17:48:07 +0300 Subject: [PATCH] Support terminals with ssh in remote projects (#11913) Release Notes: - Added a way to create terminal tabs in remote projects, if an ssh connection string is specified --- Cargo.lock | 1 + .../20221109000000_test_schema.sql | 1 + ...0240514164510_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 +- .../src/dev_server_projects.rs | 10 +- crates/project/Cargo.toml | 1 + crates/project/src/terminals.rs | 109 +++++++++++++- crates/recent_projects/src/dev_servers.rs | 133 ++++++++++++------ crates/rpc/proto/zed.proto | 2 + crates/terminal_view/src/terminal_panel.rs | 8 -- crates/zed/src/zed.rs | 20 ++- 14 files changed, 239 insertions(+), 68 deletions(-) create mode 100644 crates/collab/migrations/20240514164510_store_ssh_connect_string.sql diff --git a/Cargo.lock b/Cargo.lock index a0e0ffefd8..1344e04e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7651,6 +7651,7 @@ dependencies = [ "client", "clock", "collections", + "dev_server_projects", "env_logger", "fs", "futures 0.3.28", diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 3ce29b039c..45c424ea22 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -407,6 +407,7 @@ CREATE TABLE dev_servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), name TEXT NOT NULL, + ssh_connection_string TEXT, hashed_token TEXT NOT NULL ); diff --git a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql b/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql new file mode 100644 index 0000000000..5085ca271b --- /dev/null +++ b/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql @@ -0,0 +1 @@ +ALTER TABLE dev_servers ADD COLUMN ssh_connection_string TEXT; diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index 38e3c0ab99..8eb3d43b9c 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/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?; diff --git a/crates/collab/src/db/tables/dev_server.rs b/crates/collab/src/db/tables/dev_server.rs index 33f99631a3..a9615ca14b 100644 --- a/crates/collab/src/db/tables/dev_server.rs +++ b/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, } 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(), } } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index fdb14e03f5..18d6b00965 100644 --- a/crates/collab/src/rpc.rs +++ b/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(()) } diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index d208e31363..c0b8f55852 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/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(); diff --git a/crates/dev_server_projects/src/dev_server_projects.rs b/crates/dev_server_projects/src/dev_server_projects.rs index ce3a8e6c05..31a7d4ea2f 100644 --- a/crates/dev_server_projects/src/dev_server_projects.rs +++ b/crates/dev_server_projects/src/dev_server_projects.rs @@ -39,6 +39,7 @@ impl From for DevServerProject { pub struct DevServer { pub id: DevServerId, pub name: SharedString, + pub ssh_connection_string: Option, pub status: DevServerStatus, } @@ -48,6 +49,7 @@ impl From 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, cx: &mut ModelContext, ) -> Task> { 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) }) } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 93e7cb9242..f523e537e8 100644 --- a/crates/project/Cargo.toml +++ b/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 diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index fd18130031..ae260d1d2a 100644 --- a/crates/project/src/terminals.rs +++ b/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>, } +#[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 { + 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, @@ -26,10 +59,15 @@ impl Project { window: AnyWindowHandle, cx: &mut ModelContext, ) -> anyhow::Result> { - 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 diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 4d5f1336d5..757f6516af 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/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, project_path_input: View, dev_server_name_input: View, + use_server_name_in_ssh: Selection, rename_dev_server_input: View, - _subscription: gpui::Subscription, + _dev_server_subscription: Subscription, } #[derive(Default, Clone)] struct CreateDevServer { creating: bool, dev_server: Option, + // ssh_connection_string: Option, } #[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) { - 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) { - 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; @@ -682,6 +677,18 @@ impl DevServerProjects { 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() @@ -690,33 +697,59 @@ impl DevServerProjects { .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( 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, cx: &mut WindowContext) -> String { + element + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string() +} + impl ModalView for DevServerProjects {} impl FocusableView for DevServerProjects { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index d6c0758cf2..c2b7685eab 100644 --- a/crates/rpc/proto/zed.proto +++ b/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 { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index abb85618f0..9d39d4e061 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/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 { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4bf4679297..21d375b171 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -214,8 +214,24 @@ pub fn initialize_workspace(app_state: Arc, 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);