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:
Peter Tripp authored and GitHub committed 2026-05-13 02:05:59 +00:00
1 parent 023b735906
commit b59220c431
3 files changed
+174 -30

No files matched your search

+167 -27
View File
@@ -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]
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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