Add initial support for WSL (#37035)

Closes #36188

## Todo

* [x] CLI
* [x] terminals
* [x] tasks

## For future PRs
* debugging
* UI for opening WSL projects
* fixing workspace state restoration

Release Notes:

- Windows alpha: Zed now supports editing folders in WSL.

---------

Co-authored-by: Junkui Zhang <364772080@qq.com>
This commit is contained in:
Max Brunsfeld
2025-08-29 17:18:52 -07:00
committed by GitHub
co-authored by Junkui Zhang
parent 1c2e2a00fe
commit f78f3e7729
27 changed files with 1701 additions and 786 deletions
+42 -33
View File
@@ -23,13 +23,14 @@ use http_client::{Url, read_proxy_from_env};
use language::LanguageRegistry;
use onboarding::{FIRST_OPEN, show_onboarding_view};
use prompt_store::PromptBuilder;
use remote::RemoteConnectionOptions;
use reqwest_client::ReqwestClient;
use assets::Assets;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
use parking_lot::Mutex;
use project::project_settings::ProjectSettings;
use recent_projects::{SshSettings, open_ssh_project};
use recent_projects::{SshSettings, open_remote_project};
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use session::{AppSession, Session};
use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
@@ -360,6 +361,7 @@ pub fn main() {
open_listener.open(RawOpenRequest {
urls,
diff_paths: Vec::new(),
..Default::default()
})
}
});
@@ -696,7 +698,7 @@ pub fn main() {
let urls: Vec<_> = args
.paths_or_urls
.iter()
.filter_map(|arg| parse_url_arg(arg, cx).log_err())
.map(|arg| parse_url_arg(arg, cx))
.collect();
let diff_paths: Vec<[String; 2]> = args
@@ -706,7 +708,11 @@ pub fn main() {
.collect();
if !urls.is_empty() || !diff_paths.is_empty() {
open_listener.open(RawOpenRequest { urls, diff_paths })
open_listener.open(RawOpenRequest {
urls,
diff_paths,
wsl: args.wsl,
})
}
match open_rx
@@ -792,10 +798,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
return;
}
if let Some(connection_options) = request.ssh_connection {
if let Some(connection_options) = request.remote_connection {
cx.spawn(async move |cx| {
let paths: Vec<PathBuf> = request.open_paths.into_iter().map(PathBuf::from).collect();
open_ssh_project(
open_remote_project(
connection_options,
paths,
app_state,
@@ -978,31 +984,24 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
tasks.push(task);
}
}
SerializedWorkspaceLocation::Ssh(ssh) => {
SerializedWorkspaceLocation::Remote(mut connection_options) => {
let app_state = app_state.clone();
let ssh_host = ssh.host.clone();
let task = cx.spawn(async move |cx| {
let connection_options = cx.update(|cx| {
if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
cx.update(|cx| {
SshSettings::get_global(cx)
.connection_options_for(ssh.host, ssh.port, ssh.user)
});
match connection_options {
Ok(connection_options) => recent_projects::open_ssh_project(
connection_options,
paths.paths().into_iter().map(PathBuf::from).collect(),
app_state,
workspace::OpenOptions::default(),
cx,
)
.await
.map_err(|e| anyhow::anyhow!(e)),
Err(e) => Err(anyhow::anyhow!(
"Failed to get SSH connection options for {}: {}",
ssh_host,
e
)),
}
.fill_connection_options_from_settings(options)
})?;
}
let task = cx.spawn(async move |cx| {
recent_projects::open_remote_project(
connection_options,
paths.paths().into_iter().map(PathBuf::from).collect(),
app_state,
workspace::OpenOptions::default(),
cx,
)
.await
.map_err(|e| anyhow::anyhow!(e))
});
tasks.push(task);
}
@@ -1184,6 +1183,16 @@ struct Args {
#[arg(long, value_name = "DIR")]
user_data_dir: Option<String>,
/// The username and WSL distribution to use when opening paths. ,If not specified,
/// Zed will attempt to open the paths directly.
///
/// The username is optional, and if not specified, the default user for the distribution
/// will be used.
///
/// Example: `me@Ubuntu` or `Ubuntu` for default distribution.
#[arg(long, value_name = "USER@DISTRO")]
wsl: Option<String>,
/// Instructs zed to run as a dev server on this machine. (not implemented)
#[arg(long)]
dev_server_token: Option<String>,
@@ -1242,18 +1251,18 @@ impl ToString for IdType {
}
}
fn parse_url_arg(arg: &str, cx: &App) -> Result<String> {
fn parse_url_arg(arg: &str, cx: &App) -> String {
match std::fs::canonicalize(Path::new(&arg)) {
Ok(path) => Ok(format!("file://{}", path.display())),
Err(error) => {
Ok(path) => format!("file://{}", path.display()),
Err(_) => {
if arg.starts_with("file://")
|| arg.starts_with("zed-cli://")
|| arg.starts_with("ssh://")
|| parse_zed_link(arg, cx).is_some()
{
Ok(arg.into())
arg.into()
} else {
anyhow::bail!("error parsing path argument: {error}")
format!("file://{arg}")
}
}
}
+2 -2
View File
@@ -48,7 +48,7 @@ use project::{DirectoryLister, ProjectItem};
use project_panel::ProjectPanel;
use prompt_store::PromptBuilder;
use quick_action_bar::QuickActionBar;
use recent_projects::open_ssh_project;
use recent_projects::open_remote_project;
use release_channel::{AppCommitSha, ReleaseChannel};
use rope::Rope;
use search::project_search::ProjectSearchBar;
@@ -1557,7 +1557,7 @@ pub fn open_new_ssh_project_from_project(
};
let connection_options = ssh_client.read(cx).connection_options();
cx.spawn_in(window, async move |_, cx| {
open_ssh_project(
open_remote_project(
connection_options,
paths,
app_state,
+57 -34
View File
@@ -17,8 +17,8 @@ use gpui::{App, AsyncApp, Global, WindowHandle};
use language::Point;
use onboarding::FIRST_OPEN;
use onboarding::show_onboarding_view;
use recent_projects::{SshSettings, open_ssh_project};
use remote::SshConnectionOptions;
use recent_projects::{SshSettings, open_remote_project};
use remote::{RemoteConnectionOptions, WslConnectionOptions};
use settings::Settings;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -37,7 +37,7 @@ pub struct OpenRequest {
pub diff_paths: Vec<[String; 2]>,
pub open_channel_notes: Vec<(u64, Option<String>)>,
pub join_channel: Option<u64>,
pub ssh_connection: Option<SshConnectionOptions>,
pub remote_connection: Option<RemoteConnectionOptions>,
}
#[derive(Debug)]
@@ -51,6 +51,23 @@ pub enum OpenRequestKind {
impl OpenRequest {
pub fn parse(request: RawOpenRequest, cx: &App) -> Result<Self> {
let mut this = Self::default();
this.diff_paths = request.diff_paths;
if let Some(wsl) = request.wsl {
let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
if user.is_empty() {
anyhow::bail!("user is empty in wsl argument");
}
(Some(user.to_string()), distro.to_string())
} else {
(None, wsl)
};
this.remote_connection = Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
distro_name,
user,
}));
}
for url in request.urls {
if let Some(server_name) = url.strip_prefix("zed-cli://") {
this.kind = Some(OpenRequestKind::CliConnection(connect_to_cli(server_name)?));
@@ -80,8 +97,6 @@ impl OpenRequest {
}
}
this.diff_paths = request.diff_paths;
Ok(this)
}
@@ -108,13 +123,15 @@ impl OpenRequest {
if let Some(password) = url.password() {
connection_options.password = Some(password.to_string());
}
if let Some(ssh_connection) = &self.ssh_connection {
let connection_options = RemoteConnectionOptions::Ssh(connection_options);
if let Some(ssh_connection) = &self.remote_connection {
anyhow::ensure!(
*ssh_connection == connection_options,
"cannot open multiple ssh connections"
"cannot open multiple different remote connections"
);
}
self.ssh_connection = Some(connection_options);
self.remote_connection = Some(connection_options);
self.parse_file_path(url.path());
Ok(())
}
@@ -152,6 +169,7 @@ pub struct OpenListener(UnboundedSender<RawOpenRequest>);
pub struct RawOpenRequest {
pub urls: Vec<String>,
pub diff_paths: Vec<[String; 2]>,
pub wsl: Option<String>,
}
impl Global for OpenListener {}
@@ -303,13 +321,21 @@ pub async fn handle_cli_connection(
paths,
diff_paths,
wait,
wsl,
open_new_workspace,
env,
user_data_dir: _,
} => {
if !urls.is_empty() {
cx.update(|cx| {
match OpenRequest::parse(RawOpenRequest { urls, diff_paths }, cx) {
match OpenRequest::parse(
RawOpenRequest {
urls,
diff_paths,
wsl,
},
cx,
) {
Ok(open_request) => {
handle_open_request(open_request, app_state.clone(), cx);
responses.send(CliResponse::Exit { status: 0 }).log_err();
@@ -422,30 +448,26 @@ async fn open_workspaces(
errored = true
}
}
SerializedWorkspaceLocation::Ssh(ssh) => {
SerializedWorkspaceLocation::Remote(mut connection) => {
let app_state = app_state.clone();
let connection_options = cx.update(|cx| {
SshSettings::get_global(cx)
.connection_options_for(ssh.host, ssh.port, ssh.user)
});
if let Ok(connection_options) = connection_options {
cx.spawn(async move |cx| {
open_ssh_project(
connection_options,
workspace_paths.paths().to_vec(),
app_state,
OpenOptions::default(),
cx,
)
.await
.log_err();
})
.detach();
// We don't set `errored` here if `open_ssh_project` fails, because for ssh projects, the
// error is displayed in the window.
} else {
errored = false;
if let RemoteConnectionOptions::Ssh(options) = &mut connection {
cx.update(|cx| {
SshSettings::get_global(cx)
.fill_connection_options_from_settings(options)
})?;
}
cx.spawn(async move |cx| {
open_remote_project(
connection,
workspace_paths.paths().to_vec(),
app_state,
OpenOptions::default(),
cx,
)
.await
.log_err();
})
.detach();
}
}
}
@@ -587,6 +609,7 @@ mod tests {
};
use editor::Editor;
use gpui::TestAppContext;
use remote::SshConnectionOptions;
use serde_json::json;
use std::sync::Arc;
use util::path;
@@ -609,8 +632,8 @@ mod tests {
.unwrap()
});
assert_eq!(
request.ssh_connection.unwrap(),
SshConnectionOptions {
request.remote_connection.unwrap(),
RemoteConnectionOptions::Ssh(SshConnectionOptions {
host: "localhost".into(),
username: Some("me".into()),
port: None,
@@ -619,7 +642,7 @@ mod tests {
port_forwards: None,
nickname: None,
upload_binary_over_ssh: false,
}
})
);
assert_eq!(request.open_paths, vec!["/"]);
}
@@ -153,6 +153,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
urls,
diff_paths,
wait: false,
wsl: args.wsl.clone(),
open_new_workspace: None,
env: None,
user_data_dir: args.user_data_dir.clone(),