repl: Treat WSL as a separate kernel type from SSH remote (#50721)

MostlyK created

Split WslRemote out of the remote_kernels bucket in the kernel picker,
giving it its own "WSL Kernels" section. Use the distro name and
kernelspec display name for WSL entries instead of the generic "WSL"
string.

In python_env_kernel_specifications, detect WSL projects via
RemoteConnectionOptions and return WslRemote instead of SshRemote. Stop
marking WSL worktrees as remote so global kernel specs load.

Fix ark kernel stdout pollution by building the wsl.exe bash command
with a quoted cd and inline env assignment, so exec replaces the shell
and doesn't echo input back.

Closes #50459 

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

Change summary

crates/repl/src/components/kernel_options.rs | 21 +++++++++--
crates/repl/src/kernels/mod.rs               | 37 +++++++++++++++++----
crates/repl/src/kernels/wsl_kernel.rs        | 30 +++++++++++++++++
crates/repl/src/repl_store.rs                | 11 +++++
4 files changed, 85 insertions(+), 14 deletions(-)

Detailed changes

crates/repl/src/components/kernel_options.rs 🔗

@@ -27,6 +27,7 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
 
     let mut python_envs = Vec::new();
     let mut jupyter_kernels = Vec::new();
+    let mut wsl_kernels = Vec::new();
     let mut remote_kernels = Vec::new();
 
     for spec in store.kernel_specifications_for_worktree(worktree_id) {
@@ -59,14 +60,18 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
                     is_recommended,
                 });
             }
-            KernelSpecification::JupyterServer(_)
-            | KernelSpecification::SshRemote(_)
-            | KernelSpecification::WslRemote(_) => {
+            KernelSpecification::JupyterServer(_) | KernelSpecification::SshRemote(_) => {
                 remote_kernels.push(KernelPickerEntry::Kernel {
                     spec: spec.clone(),
                     is_recommended,
                 });
             }
+            KernelSpecification::WslRemote(_) => {
+                wsl_kernels.push(KernelPickerEntry::Kernel {
+                    spec: spec.clone(),
+                    is_recommended,
+                });
+            }
         }
     }
 
@@ -105,6 +110,12 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
         entries.extend(jupyter_kernels);
     }
 
+    // WSL Kernels section
+    if !wsl_kernels.is_empty() {
+        entries.push(KernelPickerEntry::SectionHeader("WSL Kernels".into()));
+        entries.extend(wsl_kernels);
+    }
+
     // Remote section
     if !remote_kernels.is_empty() {
         entries.push(KernelPickerEntry::SectionHeader("Remote Servers".into()));
@@ -325,10 +336,10 @@ impl PickerDelegate for KernelPickerDelegate {
 
                 let subtitle = match spec {
                     KernelSpecification::Jupyter(_) => None,
+                    KernelSpecification::WslRemote(_) => Some(spec.path().to_string()),
                     KernelSpecification::PythonEnv(_)
                     | KernelSpecification::JupyterServer(_)
-                    | KernelSpecification::SshRemote(_)
-                    | KernelSpecification::WslRemote(_) => {
+                    | KernelSpecification::SshRemote(_) => {
                         let env_kind = spec.environment_kind_label();
                         let path = spec.path();
                         match env_kind {

crates/repl/src/kernels/mod.rs 🔗

@@ -9,6 +9,7 @@ pub use native_kernel::*;
 
 mod remote_kernels;
 use project::{Project, ProjectPath, Toolchains, WorktreeId};
+use remote::RemoteConnectionOptions;
 pub use remote_kernels::*;
 
 mod ssh_kernel;
@@ -238,7 +239,7 @@ impl KernelSpecification {
             Self::PythonEnv(spec) => spec.name.clone().into(),
             Self::JupyterServer(spec) => spec.name.clone().into(),
             Self::SshRemote(spec) => spec.name.clone().into(),
-            Self::WslRemote(spec) => spec.name.clone().into(),
+            Self::WslRemote(spec) => spec.kernelspec.display_name.clone().into(),
         }
     }
 
@@ -262,7 +263,7 @@ impl KernelSpecification {
             Self::PythonEnv(spec) => spec.path.to_string_lossy().into_owned(),
             Self::JupyterServer(spec) => spec.url.to_string(),
             Self::SshRemote(spec) => spec.path.to_string(),
-            Self::WslRemote(_) => "WSL".to_string(),
+            Self::WslRemote(spec) => spec.distro.clone(),
         })
     }
 
@@ -348,7 +349,16 @@ pub fn python_env_kernel_specifications(
 ) -> impl Future<Output = Result<Vec<KernelSpecification>>> + use<> {
     let python_language = LanguageName::new_static("Python");
     let is_remote = project.read(cx).is_remote();
-    log::info!("python_env_kernel_specifications: is_remote: {}", is_remote);
+    let wsl_distro = project
+        .read(cx)
+        .remote_connection_options(cx)
+        .and_then(|opts| {
+            if let RemoteConnectionOptions::Wsl(wsl) = opts {
+                Some(wsl.distro_name)
+            } else {
+                None
+            }
+        });
 
     let toolchains = project.read(cx).available_toolchains(
         ProjectPath {
@@ -383,6 +393,7 @@ pub fn python_env_kernel_specifications(
             .flatten()
             .chain(toolchains.toolchains)
             .map(|toolchain| {
+                let wsl_distro = wsl_distro.clone();
                 background_executor.spawn(async move {
                     // For remote projects, we assume python is available assuming toolchain is reported.
                     // We can skip the `ipykernel` check or run it remotely.
@@ -390,10 +401,6 @@ pub fn python_env_kernel_specifications(
                     // `new_smol_command` runs locally. We need to run remotely if `is_remote`.
 
                     if is_remote {
-                        log::info!(
-                            "python_env_kernel_specifications: returning SshRemote for toolchain {}",
-                            toolchain.name
-                        );
                         let default_kernelspec = JupyterKernelspec {
                             argv: vec![
                                 toolchain.path.to_string(),
@@ -409,6 +416,22 @@ pub fn python_env_kernel_specifications(
                             env: None,
                         };
 
+                        if let Some(distro) = wsl_distro {
+                            log::debug!(
+                                "python_env_kernel_specifications: returning WslRemote for toolchain {}",
+                                toolchain.name
+                            );
+                            return Some(KernelSpecification::WslRemote(WslKernelSpecification {
+                                name: toolchain.name.to_string(),
+                                kernelspec: default_kernelspec,
+                                distro,
+                            }));
+                        }
+
+                        log::debug!(
+                            "python_env_kernel_specifications: returning SshRemote for toolchain {}",
+                            toolchain.name
+                        );
                         return Some(KernelSpecification::SshRemote(
                             SshRemoteKernelSpecification {
                                 name: format!("Remote {}", toolchain.name),

crates/repl/src/kernels/wsl_kernel.rs 🔗

@@ -274,7 +274,23 @@ impl WslRunningKernel {
                     cd_command, set_env_command, arg_string, arg_string, arg_string, arg_string
                 )
             } else {
-                quote_posix_shell_arguments(&kernel_args)?
+                let args_string = quote_posix_shell_arguments(&resolved_argv)?;
+
+                let cd_command = if let Some(wd) = wsl_working_directory.as_ref() {
+                    let quoted_wd = shlex::try_quote(wd)
+                        .map(|quoted| quoted.into_owned())?;
+                    format!("cd {quoted_wd} && ")
+                } else {
+                    String::new()
+                };
+
+                let env_prefix_inline = if !env_assignments.is_empty() {
+                    format!("env {} ", env_assignments.join(" "))
+                } else {
+                    String::new()
+                };
+
+                format!("{cd_command}exec {env_prefix_inline}{args_string}")
             };
 
             cmd.arg("bash")
@@ -578,8 +594,20 @@ pub async fn wsl_kernel_specifications(
                                 })
                             })
                             .collect::<Vec<_>>();
+                    } else if let Err(e) =
+                        serde_json::from_str::<LocalKernelSpecsResponse>(&json_str)
+                    {
+                        log::error!(
+                            "wsl_kernel_specifications parse error: {} \nJSON: {}",
+                            e,
+                            json_str
+                        );
                     }
+                } else {
+                    log::error!("wsl_kernel_specifications command failed");
                 }
+            } else if let Err(e) = output {
+                log::error!("wsl_kernel_specifications command execution failed: {}", e);
             }
 
             Vec::new()

crates/repl/src/repl_store.rs 🔗

@@ -8,6 +8,7 @@ use gpui::{App, Context, Entity, EntityId, Global, SharedString, Subscription, T
 use jupyter_websocket_client::RemoteServer;
 use language::{Language, LanguageName};
 use project::{Fs, Project, ProjectPath, WorktreeId};
+use remote::RemoteConnectionOptions;
 use settings::{Settings, SettingsStore};
 use util::rel_path::RelPath;
 
@@ -144,6 +145,14 @@ impl ReplStore {
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         let is_remote = project.read(cx).is_remote();
+        // WSL does require access to global kernel specs, so we only exclude remote worktrees that aren't WSL.
+        // TODO: a better way to handle WSL vs SSH/remote projects,
+        let is_wsl_remote = project
+            .read(cx)
+            .remote_connection_options(cx)
+            .map_or(false, |opts| {
+                matches!(opts, RemoteConnectionOptions::Wsl(_))
+            });
         let kernel_specifications = python_env_kernel_specifications(project, worktree_id, cx);
         let active_toolchain = project.read(cx).active_toolchain(
             ProjectPath {
@@ -168,7 +177,7 @@ impl ReplStore {
                     this.active_python_toolchain_for_worktree
                         .insert(worktree_id, path);
                 }
-                if is_remote {
+                if is_remote && !is_wsl_remote {
                     this.remote_worktrees.insert(worktree_id);
                 } else {
                     this.remote_worktrees.remove(&worktree_id);