From b59220c431eb3d8e898a20b8eae750d8e2444ff0 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 12 May 2026 22:05:59 -0400 Subject: [PATCH] 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` --- crates/zed/src/zed/open_listener.rs | 194 ++++++++++++++++++++++++---- docs/src/reference/cli.md | 8 +- docs/src/remote-development.md | 2 +- 3 files changed, 174 insertions(+), 30 deletions(-) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 232a7e5c41..0ac86e9d2b 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -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 { + 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); @@ -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, + 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] diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md index 2868569e9f..34194fbeb0 100644 --- a/docs/src/reference/cli.md +++ b/docs/src/reference/cli.md @@ -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 diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index 706afe67f4..c469871a74 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -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://[@][:]/` 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/[@][:]/`. +For simple cases where you don't need any SSH arguments, you can run `zed ssh://[@][:]/` to open a remote folder/file directly. The CLI also accepts the scp style `zed ssh://[@]:~/project` or `zed ssh://[@]:/absolute/path`. If you'd like to hotlink into an SSH project, use a link of the format: `zed://ssh/[@][:]/`. ## Supported platforms