mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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)]
|
||||
{
|
||||
|
||||
@@ -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"])))
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user