fix(terminal): complete WSL panes over the distro's \\wsl$ share (#408)

* fix(terminal): complete WSL panes over the distro's \\wsl$ share

Tab in a WSL bash pane always fell through to the shell: the pane's
filesystem is foreign, so the completion engine had no cwd to list and
handed every Tab back to bash. Now a WSL pane's POSIX cwd (OSC 7) is
translated to the distro's \\wsl$ share, which this process can read
like any directory:

- complete_foreign lists paths against the share but keeps everything
  that would consult this machine switched off: no PATH binaries in the
  command position, no generator scripts, and `~` is left to the shell
  (it names the distro's home, not this machine's).
- Absolute words stay inside the share: Windows join semantics keep the
  UNC prefix when a rooted word lands on it, so `ls /etc<Tab>` lists the
  distro's /etc, not C:\etc. The automount stays on the share for the
  same reason.
- A wsl.exe pane spawned without --distribution now resolves the default
  distro from the registry (Lxss\DefaultDistribution), so its remote
  context names a real distro instead of an empty placeholder.
- A WSL workspace no longer claims the SSH remote-listing path; only a
  spec-carrying workspace does.

Verified end to end against a live Ubuntu-24.04 through an isolated
daemon: the default distro resolves, cwd frames flow on cd, and the
translated share lists from the Windows side.

* style: cargo fmt

* fix(terminal): keep the \\wsl$ completion route on this machine's panes

A WSL pane owned by a remote host reaches its distro through that host,
not through a local \\wsl$ share -- a same-named local distro would list
the wrong machine's files. Gate wsl_share_cwd on host locality, so a
remote host's WSL pane falls back to handing Tab to the shell.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-08 15:41:16 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 692cb76635
commit 9c54ccf8e8
4 changed files with 267 additions and 8 deletions
+83
View File
@@ -447,10 +447,93 @@ pub fn git_bash_path() -> Option<PathBuf> {
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<String> {
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<String> {
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<String> {
None
}
#[cfg(windows)]
fn registry_user_string(subkey: &str, value: &str) -> Option<String> {
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_SZ, RegGetValueW};
fn wide(s: &str) -> Vec<u16> {
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<Vec<String>> {
#[cfg(windows)]
{
+9 -2
View File
@@ -146,7 +146,13 @@ fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> {
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"])))
+85 -2
View File
@@ -82,6 +82,26 @@ fn current_command(chars: &[char], word_start: usize) -> Option<String> {
}
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
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<Completion> {
complete_inner(line, cursor, Some(cwd), false)
}
fn complete_inner(
line: &str,
cursor: usize,
cwd: Option<&Path>,
this_machine: bool,
) -> Option<Completion> {
let chars: Vec<char> = 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<Complet
let is_command = chars[..word_start].iter().all(|c| c.is_whitespace());
let (word_cands, pending) = if is_command && !word.contains('/') {
(complete_command(&word, cwd.is_some()), Vec::new())
(complete_command(&word, this_machine), Vec::new())
} else {
match complete_signature(&chars, word_start, &word, cwd) {
Some(sig) => (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<String> = 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/<Tab>` 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");
+90 -4
View File
@@ -592,6 +592,27 @@ fn wsl_path(windows: &str) -> Option<String> {
))
}
/// 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/<drive>` 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<Tab>` 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<std::path::PathBuf> {
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<std::path::PathBuf> {
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<String> {
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<Tab>`) 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!(