Support SCP style SSH URLs (#56304)
Closes: - https://github.com/zed-industries/zed/discussions/56359 Self-Review Checklist: - [ YES ] I've reviewed my own diff for quality, security, and reliability - [ N/A ] Unsafe blocks (if any) have justifying comments - [ N/A ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ YES ] Tests cover the new/changed behavior - [ YES ] Performance impact has been considered and is acceptable Open to feedback or alternate approaches. Could do `--ssh user@host:~code/proj` if preferred. Or really cowboy it: regex replace `s|:~|/~|` and `s|:/|/|`; re-attempt `Url::parse` CC: @ConradIrwin Release Notes: - Add support for SCP style SSH urls: `zed ssh://user@host:~/code/proj`
This commit is contained in:
1 parent
023b735906
commit
b59220c431
3 files changed
+174
-30
No files matched your search
@@ -264,12 +264,20 @@ impl OpenRequest {
|
||||
}
|
||||
|
||||
fn parse_ssh_file_path(&mut self, file: &str, cx: &App) -> Result<()> {
|
||||
let url = url::Url::parse(file)?;
|
||||
let host = url
|
||||
let url = parse_ssh_url(file)?;
|
||||
let host = match url
|
||||
.host()
|
||||
.with_context(|| format!("missing host in ssh url: {file}"))?
|
||||
.to_string();
|
||||
let username = Some(url.username().to_string()).filter(|s| !s.is_empty());
|
||||
.with_context(|| format!("missing host in ssh url: {url}"))?
|
||||
{
|
||||
url::Host::Domain(host) => host.to_string(),
|
||||
url::Host::Ipv4(host) => host.to_string(),
|
||||
url::Host::Ipv6(host) => host.to_string(),
|
||||
};
|
||||
let username = if url.username().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(urlencoding::decode(url.username())?.into_owned())
|
||||
};
|
||||
let port = url.port();
|
||||
anyhow::ensure!(
|
||||
self.open_paths.is_empty(),
|
||||
@@ -278,7 +286,7 @@ impl OpenRequest {
|
||||
let mut connection_options =
|
||||
RemoteSettings::get_global(cx).connection_options_for(host, port, username);
|
||||
if let Some(password) = url.password() {
|
||||
connection_options.password = Some(password.to_string());
|
||||
connection_options.password = Some(urlencoding::decode(password)?.into_owned());
|
||||
}
|
||||
|
||||
let connection_options = RemoteConnectionOptions::Ssh(connection_options);
|
||||
@@ -294,6 +302,62 @@ impl OpenRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssh_url(url: &str) -> Result<url::Url> {
|
||||
if let Ok(url) = url::Url::parse(url) {
|
||||
return Ok(url);
|
||||
}
|
||||
// SCP/git style urls use ':' to separate from Authority and Path.
|
||||
// They are unsupported by Url::parse, but can be normalized into a Url.
|
||||
// SCPUrl("ssh://user@host:~/relpath") => Url("ssh://user@host/~/relpath")
|
||||
// SCPUrl("ssh://user@host:/abs/path") => Url("ssh://user@host/abs/path")
|
||||
//
|
||||
// TODO: Add IPv6 support: "ssh://[2600::]:~/foo"
|
||||
let ssh_target = url
|
||||
.strip_prefix("ssh://")
|
||||
.with_context(|| format!("invalid ssh url: {url}"))?;
|
||||
|
||||
let (authority, path) = if let Some((authority, path)) = ssh_target.rsplit_once(":~/") {
|
||||
(authority, format!("/~/{path}"))
|
||||
} else if let Some((authority, path)) = ssh_target.rsplit_once(":/") {
|
||||
(authority, format!("/{path}"))
|
||||
} else {
|
||||
anyhow::bail!("invalid ssh url: {url}");
|
||||
};
|
||||
|
||||
let (userinfo, host) = authority
|
||||
.rsplit_once('@')
|
||||
.map_or((None, authority), |(userinfo, host)| (Some(userinfo), host));
|
||||
anyhow::ensure!(
|
||||
!host.is_empty() && !host.starts_with('[') && !host.contains(':'),
|
||||
"invalid ssh url: {url}"
|
||||
);
|
||||
|
||||
let normalized_authority = if let Some(userinfo) = userinfo {
|
||||
let (username, colon_password) =
|
||||
if let Some((username, password)) = userinfo.split_once(':') {
|
||||
(
|
||||
urlencoding::encode(&urlencoding::decode(username)?).into_owned(),
|
||||
format!(
|
||||
":{}",
|
||||
urlencoding::encode(&urlencoding::decode(password)?).into_owned()
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
urlencoding::encode(&urlencoding::decode(userinfo)?).into_owned(),
|
||||
String::new(),
|
||||
)
|
||||
};
|
||||
format!("{username}{colon_password}@{host}")
|
||||
} else {
|
||||
authority.to_string()
|
||||
};
|
||||
|
||||
Ok(url::Url::parse(&format!(
|
||||
"ssh://{normalized_authority}{path}"
|
||||
))?)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenListener(UnboundedSender<RawOpenRequest>);
|
||||
|
||||
@@ -998,34 +1062,110 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_ssh_url(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
fn assert_ssh_parse(
|
||||
cx: &mut TestAppContext,
|
||||
input: &str,
|
||||
expected_url: Option<&str>,
|
||||
host: &str,
|
||||
username: Option<&str>,
|
||||
port: Option<u16>,
|
||||
path: &str,
|
||||
) {
|
||||
if let Some(expected_url) = expected_url {
|
||||
assert_eq!(parse_ssh_url(input).unwrap().as_str(), expected_url);
|
||||
}
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec!["ssh://me@localhost:/".into()],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
let rq = RawOpenRequest {
|
||||
urls: vec![input.into()],
|
||||
..Default::default()
|
||||
};
|
||||
OpenRequest::parse(rq, cx).unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
request.remote_connection.unwrap(),
|
||||
RemoteConnectionOptions::Ssh(SshConnectionOptions {
|
||||
host: "localhost".into(),
|
||||
username: Some("me".into()),
|
||||
port: None,
|
||||
password: None,
|
||||
args: None,
|
||||
port_forwards: None,
|
||||
nickname: None,
|
||||
upload_binary_over_ssh: false,
|
||||
connection_timeout: None,
|
||||
host: host.into(),
|
||||
username: username.map(str::to_string),
|
||||
port,
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
assert_eq!(request.open_paths, vec!["/"]);
|
||||
assert_eq!(request.open_paths, vec![path]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_ssh_urls(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
let cases = [
|
||||
("ssh://me@host:/", None, "host", Some("me"), None, "/"),
|
||||
(
|
||||
"ssh://me@host:~/code",
|
||||
None,
|
||||
"host",
|
||||
Some("me"),
|
||||
None,
|
||||
"/~/code",
|
||||
),
|
||||
(
|
||||
"ssh://me@host:22/tmp",
|
||||
None,
|
||||
"host",
|
||||
Some("me"),
|
||||
Some(22),
|
||||
"/tmp",
|
||||
),
|
||||
(
|
||||
"ssh://user@domain.tld@host:22/tmp",
|
||||
None,
|
||||
"host",
|
||||
Some("user@domain.tld"),
|
||||
Some(22),
|
||||
"/tmp",
|
||||
),
|
||||
(
|
||||
"ssh://domain\\user@host/dir",
|
||||
Some("ssh://domain%5Cuser@host/dir"),
|
||||
"host",
|
||||
Some("domain\\user"),
|
||||
None,
|
||||
"/dir",
|
||||
),
|
||||
(
|
||||
r"ssh://domain\\user@localhost/project",
|
||||
Some("ssh://domain%5C%5Cuser@localhost/project"),
|
||||
"localhost",
|
||||
Some(r"domain\\user"),
|
||||
None,
|
||||
"/project",
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_url, host, username, port, path) in cases {
|
||||
assert_ssh_parse(cx, input, expected_url, host, username, port, path);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_reject_ssh_urls(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
for input in [
|
||||
"ssh://me@localhost:code/vibes/mine-bot",
|
||||
"ssh://me@localhost:2222:~/project",
|
||||
"ssh://me@[2001:db8::1]:~/project",
|
||||
] {
|
||||
let result = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![input.into()],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
assert!(result.is_err(), "{input} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -156,11 +156,15 @@ This creates a temporary file with the stdin content and opens it in Zed.
|
||||
|
||||
## URL Handling
|
||||
|
||||
The CLI can open `zed://`, `http://`, and `https://` URLs:
|
||||
The CLI can open `zed://`, `file://`, and `ssh://` URLs:
|
||||
|
||||
```sh
|
||||
zed zed://settings
|
||||
zed https://github.com/zed-industries/zed
|
||||
zed file:///Users/whatever/.zshrc
|
||||
zed ssh://me@example.com/abs/path
|
||||
zed ssh://me@example.com:/abs/path
|
||||
zed ssh://me@example.com/~/project
|
||||
zed ssh://me@example.com:~/project
|
||||
```
|
||||
|
||||
## Using Zed as Your Default Editor
|
||||
|
||||
@@ -29,7 +29,7 @@ On your local machine, Zed runs its UI, talks to language models, uses Tree-sitt
|
||||
1. Once the Zed server is running, you will be prompted to choose a path to open on the remote server.
|
||||
> **Note:** Zed does not currently handle opening very large directories (for example, `/` 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.
|
||||
|
||||
For simple cases where you don't need any SSH arguments, you can run `zed ssh://[<user>@]<host>[:<port>]/<path>` to open a remote folder/file directly. If you'd like to hotlink into an SSH project, use a link of the format: `zed://ssh/[<user>@]<host>[:<port>]/<path>`.
|
||||
For simple cases where you don't need any SSH arguments, you can run `zed ssh://[<user>@]<host>[:<port>]/<path>` to open a remote folder/file directly. The CLI also accepts the scp style `zed ssh://[<user>@]<host>:~/project` or `zed ssh://[<user>@]<host>:/absolute/path`. If you'd like to hotlink into an SSH project, use a link of the format: `zed://ssh/[<user>@]<host>[:<port>]/<path>`.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
|
||||
Reference in new issue
Block a user