Cargo.lock π
@@ -9126,6 +9126,7 @@ dependencies = [
"rpc",
"serde",
"serde_json",
+ "shlex",
"smol",
"tempfile",
"thiserror",
Conrad Irwin created
This is useful for passing a custom identity file, jump hosts, etc.
Unlike with the v1 feature, we won't support `gh`/`gcloud` ssh wrappers
(yet?). I think the right way of supporting those would be to let
extensions provide remote projects.
Closes #19118
Release Notes:
- SSH remoting: restored ability to set arguments for SSH
Cargo.lock | 1
crates/recent_projects/src/dev_servers.rs | 122 +++++++++++---------
crates/recent_projects/src/recent_projects.rs | 4
crates/recent_projects/src/ssh_connections.rs | 32 +++-
crates/remote/Cargo.toml | 1
crates/remote/src/ssh_session.rs | 85 ++++++++++++++
crates/workspace/src/persistence/model.rs | 11 -
crates/zed/src/main.rs | 21 ++-
crates/zed/src/zed/open_listener.rs | 28 +++-
9 files changed, 216 insertions(+), 89 deletions(-)
@@ -9126,6 +9126,7 @@ dependencies = [
"rpc",
"serde",
"serde_json",
+ "shlex",
"smol",
"tempfile",
"thiserror",
@@ -13,19 +13,19 @@ use futures::channel::oneshot;
use futures::future::Shared;
use futures::FutureExt;
use gpui::canvas;
-use gpui::pulsating_between;
use gpui::AsyncWindowContext;
use gpui::ClipboardItem;
use gpui::Task;
use gpui::WeakView;
use gpui::{
- Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
- FocusableView, FontWeight, Model, PromptLevel, ScrollHandle, View, ViewContext,
+ AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, FontWeight,
+ Model, PromptLevel, ScrollHandle, View, ViewContext,
};
use picker::Picker;
use project::terminals::wrap_for_ssh;
use project::terminals::SshCommand;
use project::Project;
+use remote::SshConnectionOptions;
use rpc::proto::DevServerStatus;
use settings::update_settings_file;
use settings::Settings;
@@ -65,8 +65,9 @@ pub struct DevServerProjects {
struct CreateDevServer {
address_editor: View<Editor>,
- creating: Option<Task<Option<()>>>,
+ address_error: Option<SharedString>,
ssh_prompt: Option<View<SshPrompt>>,
+ _creating: Option<Task<Option<()>>>,
}
impl CreateDevServer {
@@ -77,8 +78,9 @@ impl CreateDevServer {
});
Self {
address_editor,
- creating: None,
+ address_error: None,
ssh_prompt: None,
+ _creating: None,
}
}
}
@@ -378,34 +380,22 @@ impl DevServerProjects {
}
fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
- let host = get_text(&editor, cx);
- if host.is_empty() {
+ let input = get_text(&editor, cx);
+ if input.is_empty() {
return;
}
- let mut host = host.trim_start_matches("ssh ");
- let mut username: Option<String> = None;
- let mut port: Option<u16> = None;
-
- if let Some((u, rest)) = host.split_once('@') {
- host = rest;
- username = Some(u.to_string());
- }
- if let Some((rest, p)) = host.split_once(':') {
- host = rest;
- port = p.parse().ok()
- }
-
- if let Some((rest, p)) = host.split_once(" -p") {
- host = rest;
- port = p.trim().parse().ok()
- }
-
- let connection_options = remote::SshConnectionOptions {
- host: host.to_string(),
- username: username.clone(),
- port,
- password: None,
+ let connection_options = match SshConnectionOptions::parse_command_line(&input) {
+ Ok(c) => c,
+ Err(e) => {
+ self.mode = Mode::CreateDevServer(CreateDevServer {
+ address_editor: editor,
+ address_error: Some(format!("could not parse: {:?}", e).into()),
+ ssh_prompt: None,
+ _creating: None,
+ });
+ return;
+ }
};
let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
@@ -417,6 +407,7 @@ impl DevServerProjects {
)
.prompt_err("Failed to connect", cx, |_, _| None);
+ let address_editor = editor.clone();
let creating = cx.spawn(move |this, mut cx| async move {
match connection.await {
Some(_) => this
@@ -436,18 +427,31 @@ impl DevServerProjects {
.log_err(),
None => this
.update(&mut cx, |this, cx| {
- this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
+ address_editor.update(cx, |this, _| {
+ this.set_read_only(false);
+ });
+ this.mode = Mode::CreateDevServer(CreateDevServer {
+ address_editor,
+ address_error: None,
+ ssh_prompt: None,
+ _creating: None,
+ });
cx.notify()
})
.log_err(),
};
None
});
- let mut state = CreateDevServer::new(cx);
- state.address_editor = editor;
- state.ssh_prompt = Some(ssh_prompt.clone());
- state.creating = Some(creating);
- self.mode = Mode::CreateDevServer(state);
+
+ editor.update(cx, |this, _| {
+ this.set_read_only(true);
+ });
+ self.mode = Mode::CreateDevServer(CreateDevServer {
+ address_editor: editor,
+ address_error: None,
+ ssh_prompt: Some(ssh_prompt.clone()),
+ _creating: Some(creating),
+ });
}
fn view_server_options(
@@ -547,9 +551,6 @@ impl DevServerProjects {
return;
}
- state.address_editor.update(cx, |this, _| {
- this.set_read_only(true);
- });
self.create_ssh_server(state.address_editor.clone(), cx);
}
Mode::EditNickname(state) => {
@@ -812,6 +813,7 @@ impl DevServerProjects {
port: connection_options.port,
projects: vec![],
nickname: None,
+ args: connection_options.args.unwrap_or_default(),
})
});
}
@@ -825,10 +827,7 @@ impl DevServerProjects {
state.address_editor.update(cx, |editor, cx| {
if editor.text(cx).is_empty() {
- editor.set_placeholder_text(
- "Enter the command you use to SSH into this server: e.g., ssh me@my.server",
- cx,
- );
+ editor.set_placeholder_text("ssh user@example -p 2222", cx);
}
});
@@ -854,27 +853,38 @@ impl DevServerProjects {
.map(|this| {
if let Some(ssh_prompt) = ssh_prompt {
this.child(h_flex().w_full().child(ssh_prompt))
+ } else if let Some(address_error) = &state.address_error {
+ this.child(
+ h_flex().p_2().w_full().gap_2().child(
+ Label::new(address_error.clone())
+ .size(LabelSize::Small)
+ .color(Color::Error),
+ ),
+ )
} else {
- let color = Color::Muted.color(cx);
this.child(
h_flex()
.p_2()
.w_full()
- .items_center()
- .justify_center()
- .gap_2()
+ .gap_1()
.child(
- div().size_1p5().rounded_full().bg(color).with_animation(
- "pulse-ssh-waiting-for-connection",
- Animation::new(Duration::from_secs(2))
- .repeat()
- .with_easing(pulsating_between(0.2, 0.5)),
- move |this, progress| this.bg(color.opacity(progress)),
- ),
+ Label::new(
+ "Enter the command you use to SSH into this server.",
+ )
+ .color(Color::Muted)
+ .size(LabelSize::Small),
)
.child(
- Label::new("Waiting for connectionβ¦")
- .size(LabelSize::Small),
+ Button::new("learn-more", "Learn moreβ¦")
+ .label_size(LabelSize::Small)
+ .size(ButtonSize::None)
+ .color(Color::Accent)
+ .style(ButtonStyle::Transparent)
+ .on_click(|_, cx| {
+ cx.open_url(
+ "https://zed.dev/docs/remote-development",
+ );
+ }),
),
)
}
@@ -21,7 +21,7 @@ use picker::{
use rpc::proto::DevServerStatus;
use serde::Deserialize;
use settings::Settings;
-use ssh_connections::SshSettings;
+pub use ssh_connections::SshSettings;
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -384,11 +384,13 @@ impl PickerDelegate for RecentProjectsDelegate {
..Default::default()
};
+ let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
let connection_options = SshConnectionOptions {
host: ssh_project.host.clone(),
username: ssh_project.user.clone(),
port: ssh_project.port,
password: None,
+ args,
};
let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
@@ -32,6 +32,23 @@ impl SshSettings {
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
self.ssh_connections.clone().into_iter().flatten()
}
+
+ pub fn args_for(
+ &self,
+ host: &str,
+ port: Option<u16>,
+ user: &Option<String>,
+ ) -> Option<Vec<String>> {
+ self.ssh_connections()
+ .filter_map(|conn| {
+ if conn.host == host && &conn.username == user && conn.port == port {
+ Some(conn.args)
+ } else {
+ None
+ }
+ })
+ .next()
+ }
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -45,6 +62,9 @@ pub struct SshConnection {
/// Name to use for this server in UI.
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<SharedString>,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[serde(default)]
+ pub args: Vec<String>,
}
impl From<SshConnection> for SshConnectionOptions {
fn from(val: SshConnection) -> Self {
@@ -53,6 +73,7 @@ impl From<SshConnection> for SshConnectionOptions {
username: val.username,
port: val.port,
password: None,
+ args: Some(val.args),
}
}
}
@@ -151,11 +172,9 @@ impl Render for SshPrompt {
v_flex()
.key_context("PasswordPrompt")
.size_full()
- .justify_center()
.child(
h_flex()
.p_2()
- .justify_center()
.flex_wrap()
.child(if self.error_message.is_some() {
Icon::new(IconName::XCircle)
@@ -174,24 +193,19 @@ impl Render for SshPrompt {
)
.into_any_element()
})
- .child(
- div()
- .ml_1()
- .child(Label::new("SSH Connection").size(LabelSize::Small)),
- )
.child(
div()
.text_ellipsis()
.overflow_x_hidden()
.when_some(self.error_message.as_ref(), |el, error| {
- el.child(Label::new(format!("οΌ{}", error)).size(LabelSize::Small))
+ el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
})
.when(
self.error_message.is_none() && self.status_message.is_some(),
|el| {
el.child(
Label::new(format!(
- "οΌ{}",
+ "οΌ{}β¦",
self.status_message.clone().unwrap()
))
.size(LabelSize::Small),
@@ -29,6 +29,7 @@ prost.workspace = true
rpc = { workspace = true, features = ["gpui"] }
serde.workspace = true
serde_json.workspace = true
+shlex.workspace = true
smol.workspace = true
tempfile.workspace = true
thiserror.workspace = true
@@ -61,9 +61,89 @@ pub struct SshConnectionOptions {
pub username: Option<String>,
pub port: Option<u16>,
pub password: Option<String>,
+ pub args: Option<Vec<String>>,
}
impl SshConnectionOptions {
+ pub fn parse_command_line(input: &str) -> Result<Self> {
+ let input = input.trim_start_matches("ssh ");
+ let mut hostname: Option<String> = None;
+ let mut username: Option<String> = None;
+ let mut port: Option<u16> = None;
+ let mut args = Vec::new();
+
+ // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
+ const ALLOWED_OPTS: &[&str] = &[
+ "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
+ ];
+ const ALLOWED_ARGS: &[&str] = &[
+ "-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R",
+ "-w",
+ ];
+
+ let mut tokens = shlex::split(input)
+ .ok_or_else(|| anyhow!("invalid input"))?
+ .into_iter();
+
+ 'outer: while let Some(arg) = tokens.next() {
+ if ALLOWED_OPTS.contains(&(&arg as &str)) {
+ args.push(arg.to_string());
+ continue;
+ }
+ if arg == "-p" {
+ port = tokens.next().and_then(|arg| arg.parse().ok());
+ continue;
+ } else if let Some(p) = arg.strip_prefix("-p") {
+ port = p.parse().ok();
+ continue;
+ }
+ if arg == "-l" {
+ username = tokens.next();
+ continue;
+ } else if let Some(l) = arg.strip_prefix("-l") {
+ username = Some(l.to_string());
+ continue;
+ }
+ for a in ALLOWED_ARGS {
+ if arg == *a {
+ args.push(arg);
+ if let Some(next) = tokens.next() {
+ args.push(next);
+ }
+ continue 'outer;
+ } else if arg.starts_with(a) {
+ args.push(arg);
+ continue 'outer;
+ }
+ }
+ if arg.starts_with("-") || hostname.is_some() {
+ anyhow::bail!("unsupported argument: {:?}", arg);
+ }
+ let mut input = &arg as &str;
+ if let Some((u, rest)) = input.split_once('@') {
+ input = rest;
+ username = Some(u.to_string());
+ }
+ if let Some((rest, p)) = input.split_once(':') {
+ input = rest;
+ port = p.parse().ok()
+ }
+ hostname = Some(input.to_string())
+ }
+
+ let Some(hostname) = hostname else {
+ anyhow::bail!("missing hostname");
+ };
+
+ Ok(Self {
+ host: hostname.to_string(),
+ username: username.clone(),
+ port,
+ password: None,
+ args: Some(args),
+ })
+ }
+
pub fn ssh_url(&self) -> String {
let mut result = String::from("ssh://");
if let Some(username) = &self.username {
@@ -78,6 +158,10 @@ impl SshConnectionOptions {
result
}
+ pub fn additional_args(&self) -> Option<&Vec<String>> {
+ self.args.as_ref()
+ }
+
fn scp_url(&self) -> String {
if let Some(username) = &self.username {
format!("{}@{}", username, self.host)
@@ -1179,6 +1263,7 @@ impl SshRemoteConnection {
.stderr(Stdio::piped())
.env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", &askpass_script_path)
+ .args(connection_options.additional_args().unwrap_or(&Vec::new()))
.args([
"-N",
"-o",
@@ -11,7 +11,7 @@ use db::sqlez::{
};
use gpui::{AsyncWindowContext, Model, View, WeakView};
use project::Project;
-use remote::{ssh_session::SshProjectId, SshConnectionOptions};
+use remote::ssh_session::SshProjectId;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
@@ -50,15 +50,6 @@ impl SerializedSshProject {
})
.collect()
}
-
- pub fn connection_options(&self) -> SshConnectionOptions {
- SshConnectionOptions {
- host: self.host.clone(),
- username: self.user.clone(),
- port: self.port,
- password: None,
- }
- }
}
impl StaticColumnCount for SerializedSshProject {
@@ -33,7 +33,7 @@ use assets::Assets;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use parking_lot::Mutex;
use project::project_settings::ProjectSettings;
-use recent_projects::open_ssh_project;
+use recent_projects::{open_ssh_project, SshSettings};
use release_channel::{AppCommitSha, AppVersion};
use session::{AppSession, Session};
use settings::{
@@ -214,6 +214,7 @@ fn init_common(app_state: Arc<AppState>, cx: &mut AppContext) -> Arc<PromptBuild
ThemeRegistry::global(cx),
cx,
);
+ recent_projects::init(cx);
prompt_builder
}
@@ -248,7 +249,6 @@ fn init_ui(
audio::init(Assets, cx);
workspace::init(app_state.clone(), cx);
- recent_projects::init(cx);
go_to_line::init(cx);
file_finder::init(cx);
tab_switcher::init(cx);
@@ -881,18 +881,25 @@ async fn restore_or_create_workspace(
})?;
task.await?;
}
- SerializedWorkspaceLocation::Ssh(ssh_project) => {
+ SerializedWorkspaceLocation::Ssh(ssh) => {
+ let args = cx
+ .update(|cx| {
+ SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
+ })
+ .ok()
+ .flatten();
let connection_options = SshConnectionOptions {
- host: ssh_project.host.clone(),
- username: ssh_project.user.clone(),
- port: ssh_project.port,
+ args,
+ host: ssh.host.clone(),
+ username: ssh.user.clone(),
+ port: ssh.port,
password: None,
};
let app_state = app_state.clone();
cx.spawn(move |mut cx| async move {
recent_projects::open_ssh_project(
connection_options,
- ssh_project.paths.into_iter().map(PathBuf::from).collect(),
+ ssh.paths.into_iter().map(PathBuf::from).collect(),
app_state,
workspace::OpenOptions::default(),
&mut cx,
@@ -16,8 +16,9 @@ use futures::future::join_all;
use futures::{FutureExt, SinkExt, StreamExt};
use gpui::{AppContext, AsyncAppContext, Global, WindowHandle};
use language::{Bias, Point};
-use recent_projects::open_ssh_project;
+use recent_projects::{open_ssh_project, SshSettings};
use remote::SshConnectionOptions;
+use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@@ -48,7 +49,7 @@ impl OpenRequest {
} else if let Some(file) = url.strip_prefix("zed://file") {
this.parse_file_path(file)
} else if url.starts_with("ssh://") {
- this.parse_ssh_file_path(&url)?
+ this.parse_ssh_file_path(&url, cx)?
} else if let Some(request_path) = parse_zed_link(&url, cx) {
this.parse_request_path(request_path).log_err();
} else {
@@ -65,7 +66,7 @@ impl OpenRequest {
}
}
- fn parse_ssh_file_path(&mut self, file: &str) -> Result<()> {
+ fn parse_ssh_file_path(&mut self, file: &str, cx: &AppContext) -> Result<()> {
let url = url::Url::parse(file)?;
let host = url
.host()
@@ -77,11 +78,13 @@ impl OpenRequest {
if !self.open_paths.is_empty() {
return Err(anyhow!("cannot open both local and ssh paths"));
}
+ let args = SshSettings::get_global(cx).args_for(&host, port, &username);
let connection = SshConnectionOptions {
username,
password,
host,
port,
+ args,
};
if let Some(ssh_connection) = &self.ssh_connection {
if *ssh_connection != connection {
@@ -419,12 +422,25 @@ async fn open_workspaces(
errored = true
}
}
- SerializedWorkspaceLocation::Ssh(ssh_project) => {
+ SerializedWorkspaceLocation::Ssh(ssh) => {
let app_state = app_state.clone();
+ let args = cx
+ .update(|cx| {
+ SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
+ })
+ .ok()
+ .flatten();
+ let connection_options = SshConnectionOptions {
+ args,
+ host: ssh.host.clone(),
+ username: ssh.user.clone(),
+ port: ssh.port,
+ password: None,
+ };
cx.spawn(|mut cx| async move {
open_ssh_project(
- ssh_project.connection_options(),
- ssh_project.paths.into_iter().map(PathBuf::from).collect(),
+ connection_options,
+ ssh.paths.into_iter().map(PathBuf::from).collect(),
app_state,
OpenOptions::default(),
&mut cx,