diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index 95235f99..8fa55afb 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -447,10 +447,93 @@ pub fn git_bash_path() -> Option { find_git_bash() } +#[cfg(all(windows, test))] +mod wsl_tests { + #[test] + fn the_default_distro_is_one_of_the_installed_ones() { + let installed = super::wsl_distros(); + if installed.is_empty() { + eprintln!("skipping: no WSL distributions installed"); + return; + } + let default = super::default_wsl_distro() + .expect("a machine with installed distros names a default in Lxss"); + assert!( + installed.contains(&default), + "registry default {default:?} not in {installed:?}" + ); + } +} + pub fn wsl_distros() -> Vec { wsl_distros_probed().unwrap_or_default() } +/// The distro `wsl.exe` launches when no `--distribution` is given, read from +/// the registry (`Lxss\DefaultDistribution` names the per-distro key that +/// carries `DistributionName`). The registry rather than `wsl -l`: this runs +/// on the pane-spawn path, where a microsecond read beats a subprocess. +#[cfg(windows)] +pub fn default_wsl_distro() -> Option { + const LXSS: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss"; + let guid = registry_user_string(LXSS, "DefaultDistribution")?; + let name = registry_user_string(&format!(r"{LXSS}\{guid}"), "DistributionName")?; + (!name.is_empty()).then_some(name) +} + +#[cfg(not(windows))] +pub fn default_wsl_distro() -> Option { + None +} + +#[cfg(windows)] +fn registry_user_string(subkey: &str, value: &str) -> Option { + use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_SZ, RegGetValueW}; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + let subkey = wide(subkey); + let value = wide(value); + let mut bytes: u32 = 0; + // SAFETY: a null data pointer makes this a pure sizing call; the key and + // value names are NUL-terminated UTF-16 owned right above. + let rc = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut bytes, + ) + }; + if rc != 0 || bytes == 0 { + return None; + } + let mut buf = vec![0u16; (bytes as usize).div_ceil(2)]; + let mut size = bytes; + // SAFETY: `buf` is `size` bytes as the sizing call reported; + // RegGetValueW writes at most that many and NUL-terminates REG_SZ data. + let rc = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ, + std::ptr::null_mut(), + buf.as_mut_ptr().cast(), + &mut size, + ) + }; + if rc != 0 { + return None; + } + let len = buf.iter().position(|&u| u == 0).unwrap_or(buf.len()); + Some(String::from_utf16_lossy(&buf[..len])) +} + pub fn wsl_distros_probed() -> Option> { #[cfg(windows)] { diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index d8c81184..c14292e2 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -146,7 +146,13 @@ fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option { Some(RemoteContext { kind: RemoteKind::Wsl, argv: Vec::new(), - target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(), + // No `--distribution` means wsl.exe launches the default distro — + // name it here, so every consumer of the context (the `\\wsl$` + // completion route, paste-path rewriting, host labels) gets a real + // distro instead of an empty placeholder. + target: shell_integration::wsl_distro(&chosen.args) + .or_else(crate::core::shells::default_wsl_distro) + .unwrap_or_default(), }) } @@ -2477,7 +2483,8 @@ mod tests { wsl_remote_context(Some(&spec("wsl.exe", vec![]))) .expect("default distro is still WSL") .target, - "" + crate::core::shells::default_wsl_distro().unwrap_or_default(), + "no --distribution resolves to the machine's default distro" ); assert_eq!( wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"]))) diff --git a/src/terminal/completion.rs b/src/terminal/completion.rs index d09a29f9..52bebf44 100644 --- a/src/terminal/completion.rs +++ b/src/terminal/completion.rs @@ -82,6 +82,26 @@ fn current_command(chars: &[char], word_start: usize) -> Option { } pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option { + complete_inner(line, cursor, cwd, cwd.is_some()) +} + +/// Completion for a pane whose filesystem this process can only reach through +/// a translated Windows path — a WSL pane's `\\wsl$` share. Paths complete +/// against `cwd` exactly like a local pane's, but everything that would +/// consult *this machine* instead of the share stays off: the command +/// position offers no PATH binaries (this process's PATH names the wrong +/// machine's commands) and generator scripts do not run (they would launch +/// Windows tools against a Linux checkout). +pub fn complete_foreign(line: &str, cursor: usize, cwd: &Path) -> Option { + complete_inner(line, cursor, Some(cwd), false) +} + +fn complete_inner( + line: &str, + cursor: usize, + cwd: Option<&Path>, + this_machine: bool, +) -> Option { let chars: Vec = line.chars().collect(); let cursor = cursor.min(chars.len()); let word_start = shell_word_start(&chars, cursor); @@ -89,12 +109,23 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option (sig.cands, sig.pending), + Some(sig) => ( + sig.cands, + if this_machine { + sig.pending + } else { + Vec::new() + }, + ), None => match cwd { None => (Vec::new(), Vec::new()), + // `~` is the *shell's* home; on a foreign pane resolve_dir + // would expand it to this machine's, so leave the word to the + // shell instead of listing the wrong home. + Some(_) if !this_machine && word.starts_with('~') => (Vec::new(), Vec::new()), Some(cwd) => { let dirs_only = current_command(&chars, word_start) .is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str())); @@ -1060,6 +1091,58 @@ mod tests { } } + /// A WSL pane's completion: paths list like a local pane's (the cwd is a + /// translated `\\wsl$` spelling std::fs can read), but the command + /// position and generator scripts stay off this machine. + #[test] + fn a_foreign_pane_lists_paths_but_never_runs_this_machine() { + let dir = temp_tree("foreign", &[("apple.txt", false), ("apricot", true)]); + let line = "cat ap"; + let c = complete_foreign(line, line.chars().count(), dir.as_path()) + .expect("the share lists like any directory"); + let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(names, vec!["apricot", "apple.txt"]); + + let commands: Vec = complete_foreign("l", 1, dir.as_path()) + .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) + .unwrap_or_default(); + assert!( + commands.iter().all(|n| BUILTINS.contains(&n.as_str())), + "this process's PATH names the wrong machine's commands: {commands:?}" + ); + + let line = "git checkout "; + if let Some(c) = complete_foreign(line, line.chars().count(), dir.as_path()) { + assert!( + c.pending.is_empty(), + "a generator would run Windows tools against a Linux checkout: {:?}", + c.pending + ); + } + + // `~` is the distro's home, not this machine's — leave it to the shell. + assert!(complete_foreign("cat ~/ap", 8, dir.as_path()).is_none()); + } + + #[cfg(windows)] + #[test] + fn an_absolute_word_stays_inside_the_wsl_share() { + // The share spelling is a UNC prefix, and Windows `join` semantics + // keep the prefix when a rooted path lands on it — which is exactly + // what makes `ls /etc/` list the *distro's* /etc instead of the + // local drive's. This pins the std behaviour the WSL completion + // route relies on. + let share = Path::new(r"\\wsl$\Ubuntu-24.04\home\me"); + assert_eq!( + resolve_dir("/etc/", share), + PathBuf::from(r"\\wsl$\Ubuntu-24.04\etc") + ); + assert_eq!( + resolve_dir("sub/", share), + PathBuf::from(r"\\wsl$\Ubuntu-24.04\home\me\sub") + ); + } + #[test] fn a_remote_pane_still_gets_a_signatures_static_candidates() { let c = complete("git ", 4, None).expect("subcommands need no filesystem"); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1106879f..280a090a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -592,6 +592,27 @@ fn wsl_path(windows: &str) -> Option { )) } +/// The Windows spelling of a WSL pane's POSIX cwd — [`wsl_path`]'s inverse, +/// for reading rather than writing: the distro's `\\wsl$` share is how a +/// local `read_dir` can list a directory this process cannot reach natively. +/// +/// Everything stays on the share, `/mnt/` included. Mapping the +/// automount back to the drive letter would list faster, but an absolute +/// word completes against its *cwd's* path prefix (`resolve_dir` keeps the +/// prefix when a rooted word lands on it) — so a drive-spelled cwd would send +/// `ls /etc` to `C:\etc` instead of the distro's `/etc`. One prefix, +/// one meaning. A distro name with a path separator cannot name a share. +fn wsl_share_path(distro: &str, posix: &str) -> Option { + if distro.is_empty() || distro.contains(['\\', '/']) { + return None; + } + let rest = posix.strip_prefix('/')?; + Some(std::path::PathBuf::from(format!( + r"\\wsl$\{distro}\{}", + rest.replace('/', "\\") + ))) +} + /// The staged image's path as the pane's own filesystem spells it. /// /// A WSL pane shares this machine's disk but not its path syntax: an agent in @@ -3522,16 +3543,25 @@ impl TerminalView { .paths_are_local() .then(|| self.local_cwd().or_else(|| std::env::current_dir().ok())) .flatten(); + let share_cwd = if cwd.is_none() { + self.wsl_share_cwd() + } else { + None + }; let line = self.cmd.text(); let cursor = self.cmd.cursor(); - let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else { + let comp = match &share_cwd { + Some(share) => super::completion::complete_foreign(&line, cursor, share), + None => super::completion::complete(&line, cursor, cwd.as_deref()), + }; + let Some(comp) = comp else { if self.spawn_remote_path_completion(&line, cursor, forward, cx) { return; } log::debug!( target: "tty7::completion", "handing the line to the shell: no candidates for {line:?} at {cursor} \ - (local cwd {cwd:?}, remote cwd {:?})", + (local cwd {cwd:?}, share cwd {share_cwd:?}, remote cwd {:?})", self.remote_ssh_cwd(), ); self.handoff_tab_to_shell(!forward, cx); @@ -3608,10 +3638,38 @@ impl TerminalView { Some(generation) } + /// The cwd to list over the distro's `\\wsl$` share, for a pane whose + /// filesystem is a WSL distro's: the local wsl.exe pane (tagged by its + /// remote context) and the WSL-workspace pane (tagged by its workspace + /// target) both report a POSIX cwd this process cannot read natively. + /// + /// Only for panes on this machine: a WSL pane owned by a remote host + /// reaches its distro through that host, not through a `\\wsl$` share + /// here — a same-named local distro would list the wrong machine. + fn wsl_share_cwd(&self) -> Option { + if !self.host_id.is_local() { + return None; + } + let distro = match self.terminal.remote_context() { + Some(remote) => (remote.kind == crate::daemon::protocol::RemoteKind::Wsl) + .then_some(remote.target)?, + None => match &self.workspace.as_ref()?.target { + crate::core::session::RemoteTarget::Wsl { distro } => distro.clone(), + _ => return None, + }, + }; + let cwd = self.cwd()?; + wsl_share_path(&distro, &cwd.to_string_lossy()) + } + fn remote_ssh_cwd(&self) -> Option { let owned = match self.terminal.remote_context() { Some(remote) => remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh, - None => self.workspace.is_some(), + // A WSL workspace carries no SSH spec: there is no connection to + // list over, and its panes complete through the `\\wsl$` share + // instead — so only a spec-carrying (SSH) workspace claims the + // remote-listing path. + None => self.workspace.as_ref().is_some_and(|w| w.spec.is_some()), }; if !owned { return None; @@ -5619,7 +5677,7 @@ mod tests { }; use super::{ remote_paste_spec, staged_path_for_pane, stages_clipboard_image, staging_cache, - staging_dir_is_safe, wsl_path, + staging_dir_is_safe, wsl_path, wsl_share_path, }; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; @@ -5971,6 +6029,34 @@ mod tests { ); } + #[test] + fn a_wsl_cwd_gets_a_windows_spelling_the_completion_engine_can_list() { + let share = |posix: &str| wsl_share_path("Ubuntu-24.04", posix); + + // A distro-native path goes through the share. + assert_eq!( + share("/home/me/repo"), + Some(PathBuf::from(r"\\wsl$\Ubuntu-24.04\home\me\repo")) + ); + assert_eq!(share("/"), Some(PathBuf::from(r"\\wsl$\Ubuntu-24.04\"))); + + // The automount stays on the share too: a drive-spelled cwd would + // send an absolute word (`ls /etc`) to `C:\etc` instead of the + // distro's /etc, because a rooted word completes against its cwd's + // path prefix. + assert_eq!( + share("/mnt/c/Users/me"), + Some(PathBuf::from(r"\\wsl$\Ubuntu-24.04\mnt\c\Users\me")) + ); + + // No absolute POSIX path, no translation — and a distro name that + // could break out of the share is refused outright. + assert_eq!(share("relative/path"), None); + assert_eq!(wsl_share_path("", "/home/me"), None); + assert_eq!(wsl_share_path(r"evil\distro", "/home/me"), None); + assert_eq!(wsl_share_path("evil/distro", "/home/me"), None); + } + #[test] fn a_failed_staging_preparation_is_retried_rather_than_latched() { assert_eq!(