mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #134 from l0ng-ai/feat/wsl-shell-integration
feat(shell-integration): support WSL
This commit is contained in:
@@ -308,6 +308,14 @@ pub fn git_bash_path() -> Option<PathBuf> {
|
||||
find_git_bash()
|
||||
}
|
||||
|
||||
/// Installed WSL distributions. Exposed only to tests, for the same reason as
|
||||
/// [`git_bash_path`]: the live-PTY check needs a real distro to launch into,
|
||||
/// and skips itself when there is none.
|
||||
#[cfg(all(windows, test))]
|
||||
pub fn wsl_distros() -> Vec<String> {
|
||||
list_wsl_distros()
|
||||
}
|
||||
|
||||
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
|
||||
/// x86, and the per-user installer's home).
|
||||
#[cfg(windows)]
|
||||
|
||||
+146
-18
@@ -163,7 +163,7 @@ fn apply_shell_integration(
|
||||
// sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`,
|
||||
// PowerShell flags) must use an explicit command builder first. Env-only zsh
|
||||
// integration keeps the default login-shell path.
|
||||
if integration.force_non_login || (cmd.is_default_prog() && !integration.args.is_empty()) {
|
||||
if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) {
|
||||
*cmd = CommandBuilder::new(resolved_program);
|
||||
}
|
||||
cmd.args(&integration.args);
|
||||
@@ -184,22 +184,66 @@ fn build_spawn_config(
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<SpawnConfig> {
|
||||
let initial_cwd = initial_working_directory(cwd);
|
||||
let (cmd, integration_dir) = build_shell_command(shell, &initial_cwd)?;
|
||||
// Resolved here rather than inside `build_shell_command` because the WSL tag
|
||||
// must be read off the shell we *actually* launch. `shell` is only the
|
||||
// per-spawn override; `config.json` supplies the program when it is `None`,
|
||||
// and a `wsl.exe` configured there is just as much a WSL pane as one picked
|
||||
// from the dropdown.
|
||||
let configured = choose_shell(shell, crate::core::config::shell_command());
|
||||
let remote = wsl_remote_context(configured.as_ref());
|
||||
let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?;
|
||||
Ok(SpawnConfig {
|
||||
cmd,
|
||||
initial_cwd,
|
||||
integration_dir,
|
||||
remote: None,
|
||||
remote,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the
|
||||
/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7
|
||||
/// launched, so there is nothing to detect.
|
||||
///
|
||||
/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and
|
||||
/// so what keeps the local git probe, path completion, link resolution and cwd
|
||||
/// inheritance away from a path that means nothing on this side (and that
|
||||
/// Windows would read as drive-relative). It is set whether or not shell
|
||||
/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if
|
||||
/// it ever does the gate must already be in place.
|
||||
///
|
||||
/// Takes the post-[`choose_shell`] program, not the per-spawn override: a
|
||||
/// `wsl.exe` written into `config.json` reaches the same integration and so must
|
||||
/// reach the same tag.
|
||||
fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let chosen = shell?;
|
||||
let base = std::path::Path::new(&chosen.program)
|
||||
.file_name()?
|
||||
.to_str()?
|
||||
.to_ascii_lowercase();
|
||||
if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" {
|
||||
return None;
|
||||
}
|
||||
Some(RemoteContext {
|
||||
kind: RemoteKind::Wsl,
|
||||
argv: Vec::new(),
|
||||
// The distro, when the args name one; otherwise `wsl.exe` picks the
|
||||
// default and we have no name for it without another probe. Shared with
|
||||
// the integration so the two can't disagree about which distro an argv
|
||||
// names — they are handed the very same args.
|
||||
target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the argv for a spawn from an already-resolved shell (see
|
||||
/// [`choose_shell`]); `None` means the platform default (the login shell on
|
||||
/// Unix, PowerShell on Windows).
|
||||
fn build_shell_command(
|
||||
shell: Option<ShellSpec>,
|
||||
configured: Option<ChosenShell>,
|
||||
initial_cwd: &Option<PathBuf>,
|
||||
) -> anyhow::Result<(CommandBuilder, Option<PathBuf>)> {
|
||||
// Build the shell command; `None` means the platform default (the login
|
||||
// shell on Unix, PowerShell on Windows).
|
||||
let configured = choose_shell(shell, crate::core::config::shell_command());
|
||||
let mut cmd = match &configured {
|
||||
Some(chosen) => {
|
||||
let mut c = CommandBuilder::new(&chosen.program);
|
||||
@@ -218,12 +262,14 @@ fn build_shell_command(
|
||||
None => default_shell_name(&cmd),
|
||||
};
|
||||
|
||||
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell
|
||||
// — see `daemon::shell_integration`). Best effort — `None` (an unsupported
|
||||
// shell, or a bash/PowerShell with unpreservable custom args) means we launch
|
||||
// bare.
|
||||
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell,
|
||||
// and through `wsl.exe` into a distro — see `daemon::shell_integration`).
|
||||
// Best effort — `None` (an unsupported shell, or one with unpreservable
|
||||
// custom args) means we launch bare. The args go in because the WSL path
|
||||
// reads the distro out of them.
|
||||
let integration = shell_integration::setup(
|
||||
Some(&resolved_program),
|
||||
configured.as_ref().map_or(&[][..], |c| c.args.as_slice()),
|
||||
has_custom_args(configured.as_ref()),
|
||||
);
|
||||
if let Some(integration) = &integration {
|
||||
@@ -938,15 +984,23 @@ impl DaemonPane {
|
||||
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
|
||||
}
|
||||
let remote = if poll_now {
|
||||
// A native-SSH pane already carries its own remote
|
||||
// context; process-table detection must not clobber
|
||||
// it (this pane *is* SSH). Only a plain PTY pane gets
|
||||
// foreground `ssh` detection.
|
||||
// A pane tty7 itself spawned as remote (native SSH,
|
||||
// or WSL) already carries its own context from the
|
||||
// spawn spec; process-table detection must not
|
||||
// clobber it. Only `Ssh` — the kind this very probe
|
||||
// produces — may be replaced, so a pane that has
|
||||
// since left a foreground `ssh` clears correctly.
|
||||
//
|
||||
// Testing `!= Ssh` rather than `== NativeSsh` is
|
||||
// load-bearing for WSL: `wsl.exe` is not `ssh`, so
|
||||
// the probe returns `None` and would blank the
|
||||
// context on the very next poll — twice a second,
|
||||
// each time also clearing the pane's cwd.
|
||||
let managed = {
|
||||
let st = state.lock().unwrap();
|
||||
st.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.kind == RemoteKind::NativeSsh)
|
||||
.is_some_and(|remote| remote.kind != RemoteKind::Ssh)
|
||||
};
|
||||
(!managed).then(&foreground_remote)
|
||||
} else {
|
||||
@@ -2316,13 +2370,87 @@ mod tests {
|
||||
assert!(!has_custom_args(None));
|
||||
}
|
||||
|
||||
/// A WSL pane must be tagged as living in another filesystem namespace, so
|
||||
/// `TerminalView::local_cwd` declines the distro's cwd and the local git
|
||||
/// probe / completion / link resolution / cwd inheritance never see a path
|
||||
/// that means nothing here — and that Windows would read as drive-relative
|
||||
/// (`/home/me` -> `C:\home\me`) rather than reject.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_panes_are_tagged_as_a_foreign_filesystem() {
|
||||
let spec = |program: &str, args: Vec<&str>| ChosenShell {
|
||||
program: program.to_string(),
|
||||
args: args.into_iter().map(str::to_string).collect(),
|
||||
args_are_tty7_defaults: true,
|
||||
};
|
||||
|
||||
// The dropdown's WSL row.
|
||||
let ctx = wsl_remote_context(Some(&spec(
|
||||
"wsl.exe",
|
||||
vec!["--distribution", "Ubuntu-24.04", "--cd", "~"],
|
||||
)))
|
||||
.expect("wsl.exe must be tagged");
|
||||
assert_eq!(ctx.kind, RemoteKind::Wsl);
|
||||
// The distro rides along as the target so the UI has a name for it.
|
||||
assert_eq!(ctx.target, "Ubuntu-24.04");
|
||||
// Nothing reads `argv` for this kind; it is not an ssh invocation.
|
||||
assert!(ctx.argv.is_empty());
|
||||
|
||||
// Short flag, and no flag at all (wsl.exe then picks the default
|
||||
// distro — still a WSL pane, just one we have no name for).
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"])))
|
||||
.expect("short flag")
|
||||
.target,
|
||||
"Debian"
|
||||
);
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec![])))
|
||||
.expect("default distro is still WSL")
|
||||
.target,
|
||||
""
|
||||
);
|
||||
// The `--distribution=NAME` spelling too — the tag reads the distro with
|
||||
// the integration's own parser, so the two cannot disagree about an argv
|
||||
// they are both handed.
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"])))
|
||||
.expect("joined flag")
|
||||
.target,
|
||||
"Arch"
|
||||
);
|
||||
// Case- and suffix-insensitive, like every other Windows program name.
|
||||
assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some());
|
||||
|
||||
// Regression: the tag is read off the *resolved* shell, not the
|
||||
// per-spawn override. A `wsl.exe` written into `config.json` reaches
|
||||
// `setup_wsl` with no override in play (empty args, so nothing custom to
|
||||
// preserve), so the distro starts reporting its own cwd — and an
|
||||
// untagged pane would hand `/home/me/proj` straight to the local git
|
||||
// probe, which Windows resolves drive-relative to `C:\home\me\proj`.
|
||||
let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new())));
|
||||
assert_eq!(
|
||||
wsl_remote_context(from_config.as_ref()).map(|c| c.kind),
|
||||
Some(RemoteKind::Wsl),
|
||||
"a configured wsl.exe is as much a WSL pane as a dropdown one"
|
||||
);
|
||||
|
||||
// Everything else is a local pane and must not be tagged — tagging it
|
||||
// would silently disable its git status, completion and cwd inheritance.
|
||||
assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none());
|
||||
assert!(
|
||||
wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none()
|
||||
);
|
||||
assert!(wsl_remote_context(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arg_based_integration_rebuilds_default_shell_builder() {
|
||||
let mut cmd = CommandBuilder::new_default_prog();
|
||||
let injection = shell_integration::Injection {
|
||||
env: std::collections::HashMap::new(),
|
||||
args: vec!["-C".to_string(), "echo ready".to_string()],
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
};
|
||||
|
||||
@@ -2345,7 +2473,7 @@ mod tests {
|
||||
let injection = shell_integration::Injection {
|
||||
env,
|
||||
args: Vec::new(),
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
};
|
||||
|
||||
|
||||
+26
-3
@@ -132,14 +132,21 @@ pub struct PaneInfo {
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// A foreground remote session the daemon can prove from the local process table.
|
||||
/// A pane whose filesystem is not the host's — either a remote session, or a
|
||||
/// local one behind a boundary the host's own tools can't follow (WSL).
|
||||
///
|
||||
/// The common consequence, whatever the kind, is that the pane's cwd names a
|
||||
/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps
|
||||
/// a local `git` / `read_dir` / spawn away from it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RemoteContext {
|
||||
pub kind: RemoteKind,
|
||||
/// Original foreground argv. Kept so follow-up operations can preserve ssh
|
||||
/// config flags such as `-F`, `-p`, and `-J` rather than guessing.
|
||||
/// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty
|
||||
/// for kinds that aren't detected from a foreground process.
|
||||
pub argv: Vec<String>,
|
||||
/// The destination token (`host`, `user@host`, or ssh config alias).
|
||||
/// The destination token: `host`, `user@host`, or ssh config alias for the
|
||||
/// ssh kinds; the distro name for [`RemoteKind::Wsl`].
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
@@ -154,6 +161,15 @@ pub enum RemoteKind {
|
||||
/// (`daemon::ssh`). Forwarding / SFTP reach the connection through the
|
||||
/// in-memory registry.
|
||||
NativeSsh,
|
||||
/// A `wsl.exe` pane: not remote in the network sense, but its shell lives
|
||||
/// inside a distro with its own filesystem namespace, so a cwd it reports
|
||||
/// (`/home/me/proj`) means nothing to the Windows-side host — and on
|
||||
/// Windows is *drive-relative* rather than invalid, so it silently resolves
|
||||
/// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not
|
||||
/// detected from the process table. Nothing SSH-specific applies to it:
|
||||
/// callers that mean "an SSH pane" must test the kind, not merely that a
|
||||
/// `RemoteContext` is present.
|
||||
Wsl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -1544,6 +1560,13 @@ mod tests {
|
||||
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
|
||||
target: "dev".into(),
|
||||
})),
|
||||
// A WSL pane's context rides the same wire; `kind` is serialized
|
||||
// kebab-case, so this pins the encoding of the new variant.
|
||||
DaemonMsg::RemoteContext(Some(RemoteContext {
|
||||
kind: RemoteKind::Wsl,
|
||||
argv: Vec::new(),
|
||||
target: "Ubuntu-24.04".into(),
|
||||
})),
|
||||
DaemonMsg::RemoteContext(None),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
|
||||
|
||||
+411
-81
@@ -30,13 +30,18 @@
|
||||
//! plain non-login shell instead and have our rcfile manually replay the
|
||||
//! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` &
|
||||
//! co.) before layering hooks on top — see [`setup_bash`] and
|
||||
//! [`Injection::force_non_login`]. Bash also has no native precmd/preexec,
|
||||
//! [`Injection::replaces_argv`]. Bash also has no native precmd/preexec,
|
||||
//! so the hook body vendors the relevant parts of
|
||||
//! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the
|
||||
//! same shim VS Code relies on for this. This path covers Git Bash too —
|
||||
//! the msys2 bash Git for Windows ships is spawned as `bash.exe` by
|
||||
//! absolute path, and needs only its rcfile path spelled with forward
|
||||
//! slashes (see [`bash_path`]).
|
||||
//! - **WSL** is not a shell but a launcher: `wsl.exe` starts a shell *inside*
|
||||
//! a distro, so the integration has to reach through it. We probe the
|
||||
//! distro's login shell, write the matching rcfile on the Windows side, and
|
||||
//! pass its path in via `WSLENV`, which translates it to the distro's view
|
||||
//! of the filesystem. See [`setup_wsl`]. Only bash is wired up so far.
|
||||
//! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile
|
||||
//! redirect either, but `-EncodedCommand` runs a script *after* its own
|
||||
//! profiles load — like fish's `-C`, no file on disk. It has no
|
||||
@@ -45,19 +50,22 @@
|
||||
//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to
|
||||
//! a preexec, for the C mark). See [`setup_powershell`].
|
||||
//!
|
||||
//! Across all four: **the user's own dotfiles are never modified** — the
|
||||
//! Across all of them: **the user's own dotfiles are never modified** — the
|
||||
//! mechanisms above only affect shells tty7 itself launches.
|
||||
//!
|
||||
//! The two remaining Windows dropdown entries stay unintegrated by design, not
|
||||
//! omission. **cmd** exposes exactly one hook, the `PROMPT` env var, which can
|
||||
//! emit the `A`/`B` marks but not `C` or `D`: it has no preexec/postexec, and
|
||||
//! `PROMPT` is expanded when it is *set*, so even `%ERRORLEVEL%` is out of
|
||||
//! reach. Since only `C` clears `at_prompt` (see `pane::handle_osc133`), an
|
||||
//! A/B-only shell would leave the line editor owning the keyboard for the
|
||||
//! whole of every command — worse than no integration. **WSL** is spawned as
|
||||
//! `wsl.exe`, the Windows-side launcher; reaching the distro's own shell would
|
||||
//! mean detecting which shell that is per distro and routing the injection
|
||||
//! through `WSLENV` path translation, which is its own piece of work.
|
||||
//! **cmd** stays unintegrated by design, not omission. It exposes exactly one
|
||||
//! hook, the `PROMPT` env var, which can emit the `A`/`B` marks but not `C` or
|
||||
//! `D`: it has no preexec/postexec, and `PROMPT` is expanded when it is *set*,
|
||||
//! so even `%ERRORLEVEL%` is out of reach. Since only `C` clears `at_prompt`
|
||||
//! (see `pane::handle_osc133`), an A/B-only shell would leave the line editor
|
||||
//! owning the keyboard for the whole of every command — worse than no
|
||||
//! integration at all.
|
||||
//!
|
||||
//! The install-guard sentinel (`TTY7_SHELL_INTEGRATION`, see [`setup`]) does
|
||||
//! not cross into WSL, and deliberately isn't listed in `WSLENV`: only vars
|
||||
//! named there cross, so a distro shell always starts with it unset — which is
|
||||
//! correct, since it *is* a fresh top-level interactive shell. Its own
|
||||
//! descendants inside the distro then see the `1` it exports, as on any Linux.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -709,13 +717,20 @@ pub struct Injection {
|
||||
pub env: HashMap<String, String>,
|
||||
/// Extra argv entries to append after the program (e.g. bash's
|
||||
/// `--rcfile <path>`, fish's `-C <script>`). Empty for zsh, which needs no
|
||||
/// spawn-time changes at all.
|
||||
/// spawn-time changes at all. When [`replaces_argv`](Self::replaces_argv)
|
||||
/// is set these are the *whole* argv, not an addition to it.
|
||||
pub args: Vec<String>,
|
||||
/// If set, the caller must spawn the shell as a plain (non-login) process
|
||||
/// rather than however it normally would — only bash needs this (see the
|
||||
/// module docs), and only when the caller can freely choose the spawn
|
||||
/// invocation (i.e. no user-configured custom shell args to preserve).
|
||||
pub force_non_login: bool,
|
||||
/// If set, [`args`](Self::args) replace the argv the caller would otherwise
|
||||
/// have used, rather than extending it. Only offered when the caller can
|
||||
/// freely choose the spawn invocation (i.e. no user-configured custom shell
|
||||
/// args to preserve). Two integrations need it, for different reasons:
|
||||
///
|
||||
/// - **bash**, because `--rcfile` is ignored for a *login* shell, so the
|
||||
/// caller's login invocation has to become a plain one (the rcfile
|
||||
/// replays the login chain itself — see the module docs).
|
||||
/// - **WSL**, because the launch flags and the command must be reordered
|
||||
/// around a `--` separator, which appending cannot express.
|
||||
pub replaces_argv: bool,
|
||||
/// The throwaway dir we created, if any; the terminal owns it and removes
|
||||
/// it on drop so it doesn't accumulate across sessions. `None` for fish,
|
||||
/// which needs no files on disk at all.
|
||||
@@ -763,6 +778,10 @@ enum ShellKind {
|
||||
Bash,
|
||||
Fish,
|
||||
PowerShell,
|
||||
/// `wsl.exe`, the Windows-side launcher. Not a shell itself — the
|
||||
/// integration has to reach *through* it to the distro's own shell. See
|
||||
/// [`setup_wsl`].
|
||||
Wsl,
|
||||
}
|
||||
|
||||
fn shell_kind(program: Option<&str>) -> Option<ShellKind> {
|
||||
@@ -786,10 +805,59 @@ fn shell_kind(program: Option<&str>) -> Option<ShellKind> {
|
||||
"bash" => Some(ShellKind::Bash),
|
||||
"fish" => Some(ShellKind::Fish),
|
||||
"powershell" | "pwsh" => Some(ShellKind::PowerShell),
|
||||
"wsl" => Some(ShellKind::Wsl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The distro named by a `wsl.exe` argv, if any. tty7's own launch args spell it
|
||||
/// `--distribution <name>` (`core::shells::detect_shells`); `-d` is the short
|
||||
/// form a user-configured shell may use. Absent means "the default distro",
|
||||
/// which is also what `wsl.exe` does with no flag — so `None` is a valid answer,
|
||||
/// not a failure.
|
||||
///
|
||||
/// Shared with `pane::wsl_remote_context`, which names the same distro in the
|
||||
/// pane's [`RemoteContext`](crate::daemon::protocol::RemoteContext) from the
|
||||
/// same argv: two parsers for one flag would be free to disagree.
|
||||
pub(crate) fn wsl_distro(args: &[String]) -> Option<String> {
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--distribution" || a == "-d" {
|
||||
return it.next().cloned();
|
||||
}
|
||||
if let Some(v) = a.strip_prefix("--distribution=") {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Add our entries to a `WSLENV` value, preserving whatever was already there.
|
||||
///
|
||||
/// `WSLENV` is a colon-separated list of variable names, each optionally
|
||||
/// suffixed with flags — `/p` meaning "translate this value as a path when it
|
||||
/// crosses the boundary", which is how the rcfile's Windows path becomes a
|
||||
/// `/mnt/c/...` one the distro can read. Overwriting it wholesale would silently
|
||||
/// drop the user's own entries, so append and de-duplicate by name.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn wslenv_with(existing: Option<&str>, additions: &[&str]) -> String {
|
||||
let mut out: Vec<String> = existing
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
for add in additions {
|
||||
let name = add.split('/').next().unwrap_or(add);
|
||||
// A name already present wins whatever flags the user gave it; ours is
|
||||
// additive, not a correction of their configuration.
|
||||
if !out.iter().any(|e| e.split('/').next().unwrap_or(e) == name) {
|
||||
out.push((*add).to_string());
|
||||
}
|
||||
}
|
||||
out.join(":")
|
||||
}
|
||||
|
||||
/// Whether a Windows `bash` program path is the msys bash that Git for Windows
|
||||
/// (or msys2) ships, as opposed to `C:\Windows\System32\bash.exe` — the WSL
|
||||
/// launcher, which exists on any machine with WSL and normally sits ahead of
|
||||
@@ -860,7 +928,7 @@ fn setup_zsh() -> Option<Injection> {
|
||||
Some(Injection {
|
||||
env,
|
||||
args: Vec::new(),
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: Some(dir),
|
||||
})
|
||||
}
|
||||
@@ -872,7 +940,7 @@ fn setup_fish() -> Option<Injection> {
|
||||
Some(Injection {
|
||||
env: HashMap::new(),
|
||||
args: vec!["-C".to_string(), FISH_INTEGRATION.to_string()],
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
})
|
||||
}
|
||||
@@ -894,7 +962,7 @@ fn setup_powershell() -> Option<Injection> {
|
||||
"-EncodedCommand".to_string(),
|
||||
powershell_encoded_command(POWERSHELL_INTEGRATION),
|
||||
],
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
})
|
||||
}
|
||||
@@ -988,11 +1056,123 @@ fn setup_bash() -> Option<Injection> {
|
||||
// macOS's shipped `/bin/bash` — refuses to parse a long option once a
|
||||
// short one has been seen.
|
||||
args: vec!["--rcfile".to_string(), bash_path(&rcfile), "-i".to_string()],
|
||||
force_non_login: true,
|
||||
replaces_argv: true,
|
||||
dir: Some(dir),
|
||||
})
|
||||
}
|
||||
|
||||
/// Env var carrying the rcfile path across the Windows/WSL boundary. Listed in
|
||||
/// `WSLENV` with the `/p` flag so WSL rewrites it to the distro's view of the
|
||||
/// path (`C:\Users\…` -> `/mnt/c/Users/…`), which is why we don't hardcode the
|
||||
/// `/mnt` automount root ourselves — it is configurable in `/etc/wsl.conf`.
|
||||
const WSL_RCFILE_ENV: &str = "TTY7_RC";
|
||||
|
||||
/// Pick the distro's shell and exec it, *inside the distro*.
|
||||
///
|
||||
/// Deliberately not a Windows-side probe. Spawning `wsl.exe` to ask which shell
|
||||
/// a distro uses blocks the whole spawn path: the client waits synchronously for
|
||||
/// the daemon's `Spawn` reply (see `terminal::remote::spawn`), so on a cold WSL
|
||||
/// start — seconds, while the distro boots — the entire window freezes. Folding
|
||||
/// the decision into the one `wsl.exe` invocation we were always going to make
|
||||
/// costs nothing and cannot block, because there is no second invocation.
|
||||
///
|
||||
/// `$SHELL` rather than `getent passwd`: WSL populates it from the user's passwd
|
||||
/// entry, so inside the distro it already *is* the login shell of record — the
|
||||
/// same source `shell_kind` trusts on Unix. Written without a variable
|
||||
/// assignment so the whole thing stays one `case`, which keeps it robust to the
|
||||
/// layers of quoting between here and `sh`.
|
||||
const WSL_EXEC_SCRIPT: &str = concat!(
|
||||
r#"case "${SHELL:-}" in "#,
|
||||
r#"*/bash) exec "$SHELL" --rcfile "$TTY7_RC" -i ;; "#,
|
||||
r#"*) exec "${SHELL:-/bin/sh}" -l ;; "#,
|
||||
"esac"
|
||||
);
|
||||
|
||||
/// Reach through `wsl.exe` to the distro's own shell.
|
||||
///
|
||||
/// `wsl.exe` is a launcher, not a shell: injecting into it directly would never
|
||||
/// reach the thing that draws the prompt. So we write the integration rcfile on
|
||||
/// the Windows side and hand `wsl.exe` a command that starts the distro's shell
|
||||
/// with it — the distro's own startup chain replayed inside it exactly as on any
|
||||
/// other bash.
|
||||
///
|
||||
/// The argv shape is `[<launch flags>] -- sh -c <script>` rather than
|
||||
/// `-- <shell> --rcfile <path>` because the path only exists as an env var
|
||||
/// *inside* the distro after `WSLENV` translation, and `wsl.exe` execs its
|
||||
/// command directly without a shell to expand it. The one-shot `sh` costs a
|
||||
/// process and `exec`s away immediately.
|
||||
///
|
||||
/// Only bash is wired up. A distro on zsh or fish falls through to
|
||||
/// [`WSL_EXEC_SCRIPT`]'s second arm and launches as a plain login shell — the
|
||||
/// behavior every WSL pane had before this, just reached one `exec` later. They
|
||||
/// are integrable the same way (`ZDOTDIR` would need translating too; fish's
|
||||
/// `-C` needs no file at all), but each needs its own verification pass.
|
||||
///
|
||||
/// The rcfile is written unconditionally, before we know the shell — it is a
|
||||
/// local write into a throwaway dir the terminal already cleans up on drop, and
|
||||
/// paying it always is what buys the decision being free.
|
||||
#[cfg(windows)]
|
||||
fn setup_wsl(args: &[String]) -> Option<Injection> {
|
||||
let distro = wsl_distro(args);
|
||||
let dir = throwaway_dir("tty7-wslrc-")?;
|
||||
let rcfile = dir.join("bashrc");
|
||||
std::fs::write(&rcfile, bash_rcfile()).ok()?;
|
||||
|
||||
// Rebuild the launch flags rather than appending to them: `--` must come
|
||||
// last, and everything after it is the command. Preserve the distro and
|
||||
// `--cd` the caller asked for.
|
||||
let mut argv: Vec<String> = Vec::new();
|
||||
if let Some(d) = &distro {
|
||||
argv.push("--distribution".to_string());
|
||||
argv.push(d.clone());
|
||||
}
|
||||
if let Some(cd) = wsl_cd(args) {
|
||||
argv.push("--cd".to_string());
|
||||
argv.push(cd);
|
||||
}
|
||||
argv.push("--".to_string());
|
||||
argv.push("sh".to_string());
|
||||
argv.push("-c".to_string());
|
||||
argv.push(WSL_EXEC_SCRIPT.to_string());
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
WSL_RCFILE_ENV.to_string(),
|
||||
rcfile.to_string_lossy().into_owned(),
|
||||
);
|
||||
env.insert(
|
||||
"WSLENV".to_string(),
|
||||
wslenv_with(
|
||||
std::env::var("WSLENV").ok().as_deref(),
|
||||
&[&format!("{WSL_RCFILE_ENV}/p")],
|
||||
),
|
||||
);
|
||||
|
||||
Some(Injection {
|
||||
env,
|
||||
args: argv,
|
||||
replaces_argv: true,
|
||||
dir: Some(dir),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `--cd` value from a `wsl.exe` argv. tty7's own launch args pass `--cd ~`
|
||||
/// so the shell lands in the distro's home rather than a translated Windows path
|
||||
/// (`core::shells::detect_shells`).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn wsl_cd(args: &[String]) -> Option<String> {
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--cd" {
|
||||
return it.next().cloned();
|
||||
}
|
||||
if let Some(v) = a.strip_prefix("--cd=") {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Set up shell integration for a shell tty7 is about to spawn. `program` is
|
||||
/// the resolved program path/name if the caller already knows it (e.g. the
|
||||
/// user's configured custom shell, or the default shell resolved from the
|
||||
@@ -1001,10 +1181,14 @@ fn setup_bash() -> Option<Injection> {
|
||||
/// `true` when the caller is about to pass user-configured shell args it can't
|
||||
/// safely override (only affects bash — see [`setup_bash`]).
|
||||
///
|
||||
/// `args` are the launch args the caller would otherwise use; only the WSL path
|
||||
/// reads them (for the distro), and only on Windows.
|
||||
///
|
||||
/// Returns the env/arg overrides and the temp dir to clean up, or `None` when
|
||||
/// the shell isn't supported or anything goes wrong — in which case the
|
||||
/// terminal launches bare, exactly as before (integration is best-effort).
|
||||
pub fn setup(program: Option<&str>, has_custom_args: bool) -> Option<Injection> {
|
||||
#[cfg_attr(not(windows), allow(unused_variables))]
|
||||
pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> Option<Injection> {
|
||||
let mut injection = match shell_kind(program)? {
|
||||
ShellKind::Zsh => setup_zsh(),
|
||||
ShellKind::Fish => setup_fish(),
|
||||
@@ -1015,6 +1199,11 @@ pub fn setup(program: Option<&str>, has_custom_args: bool) -> Option<Injection>
|
||||
// a custom-arg invocation; launch it bare.
|
||||
ShellKind::PowerShell if !has_custom_args => setup_powershell(),
|
||||
ShellKind::PowerShell => None,
|
||||
// WSL rebuilds the argv around a `--` separator, so — like bash — it
|
||||
// can't be reconciled with args the user wrote.
|
||||
#[cfg(windows)]
|
||||
ShellKind::Wsl if !has_custom_args => setup_wsl(args),
|
||||
ShellKind::Wsl => None,
|
||||
}?;
|
||||
|
||||
// Reset the install-guard sentinel for the shell we're about to spawn. Each
|
||||
@@ -1073,28 +1262,23 @@ mod tests {
|
||||
assert!(!is_our_zdotdir("/tmp/not-tty7-zdotdir-1"));
|
||||
}
|
||||
|
||||
/// End-to-end on a real PTY: spawn the actual Git Bash through the actual
|
||||
/// `setup` output and assert the full A/B/C/D cycle comes back. Guards the
|
||||
/// parts no pure test can see — that msys2 bash accepts the rcfile path we
|
||||
/// hand it, that our hooks survive Git Bash's own `/etc/profile` (which
|
||||
/// installs a `PROMPT_COMMAND` of its own), and that bash-preexec's DEBUG
|
||||
/// trap actually fires under a Windows pty. Skips when Git for Windows
|
||||
/// isn't installed, so it's a no-op on a machine without it.
|
||||
/// Drive a real shell over a real PTY through `injection`, submit one
|
||||
/// failing command, and return everything it wrote up to the `D` mark.
|
||||
///
|
||||
/// Shared by the Git Bash and WSL end-to-end tests. Two ConPTY behaviors
|
||||
/// are baked in and must not be "simplified" away:
|
||||
///
|
||||
/// - the writer is held for the whole call, because closing a ConPTY's
|
||||
/// input side raises a console control event that kills the shell with
|
||||
/// `STATUS_CONTROL_C_EXIT` before it ever reaches a prompt; and
|
||||
/// - draining happens on a worker thread against a deadline, because a
|
||||
/// ConPTY master does not reliably EOF when its child exits, so an
|
||||
/// inline read would block forever rather than fail.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() {
|
||||
fn prompt_cycle_over_pty(program: &str, injection: &Injection) -> String {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let Some(bash) = crate::core::shells::git_bash_path() else {
|
||||
eprintln!("skipping: Git for Windows not installed");
|
||||
return;
|
||||
};
|
||||
let bash = bash.to_string_lossy().into_owned();
|
||||
// `has_custom_args: false` — the dropdown's `-i -l` are tty7's own, so
|
||||
// the real spawn path reaches setup_bash with them overridable.
|
||||
let injection = setup(Some(&bash), false).expect("bash integration");
|
||||
|
||||
let pty = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
@@ -1103,28 +1287,20 @@ mod tests {
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("openpty");
|
||||
let mut cmd = CommandBuilder::new(&bash);
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
cmd.args(&injection.args);
|
||||
for (k, v) in &injection.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = pty.slave.spawn_command(cmd).expect("spawn git bash");
|
||||
let mut child = pty.slave.spawn_command(cmd).expect("spawn shell");
|
||||
|
||||
let mut writer = pty.master.take_writer().expect("writer");
|
||||
let mut reader = pty.master.try_clone_reader().expect("reader");
|
||||
// `false` gives D a non-zero exit code to carry, so a hardcoded 0 in
|
||||
// the report path can't pass this. `writer` is deliberately held for
|
||||
// the rest of the test: closing a ConPTY's input side raises a console
|
||||
// control event, which kills the shell with STATUS_CONTROL_C_EXIT
|
||||
// before it ever reaches a prompt.
|
||||
// the report path can't pass these tests.
|
||||
writer.write_all(b"false\n").expect("write");
|
||||
writer.flush().expect("flush");
|
||||
|
||||
// Drain on a worker: a Windows ConPTY master does not reliably EOF when
|
||||
// its child exits, so reading inline would block past the shell's own
|
||||
// exit. The worker feeds chunks over a channel and the test stops at the
|
||||
// D mark that ends the cycle — or at a deadline, so a shell that never
|
||||
// reports fails the assert instead of hanging.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
@@ -1150,7 +1326,41 @@ mod tests {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
drop(pty.master);
|
||||
let text = String::from_utf8_lossy(&out);
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The OSC 7 cwd a captured PTY transcript reported, decoded by the
|
||||
/// daemon's own parser so emitter and consumer are proven to agree.
|
||||
#[cfg(windows)]
|
||||
fn reported_cwd(text: &str) -> PathBuf {
|
||||
let payload = text
|
||||
.split("\u{1b}]")
|
||||
.find(|s| s.starts_with("7;file://"))
|
||||
.and_then(|s| s.split(['\u{7}', '\u{1b}']).next())
|
||||
.unwrap_or_else(|| panic!("expected OSC 7; got:\n{text}"));
|
||||
crate::daemon::pane::parse_osc7(payload.as_bytes())
|
||||
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"))
|
||||
}
|
||||
|
||||
/// End-to-end on a real PTY: spawn the actual Git Bash through the actual
|
||||
/// `setup` output and assert the full A/B/C/D cycle comes back. Guards the
|
||||
/// parts no pure test can see — that msys2 bash accepts the rcfile path we
|
||||
/// hand it, that our hooks survive Git Bash's own `/etc/profile` (which
|
||||
/// installs a `PROMPT_COMMAND` of its own), and that bash-preexec's DEBUG
|
||||
/// trap actually fires under a Windows pty. Skips when Git for Windows
|
||||
/// isn't installed, so it's a no-op on a machine without it.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() {
|
||||
let Some(bash) = crate::core::shells::git_bash_path() else {
|
||||
eprintln!("skipping: Git for Windows not installed");
|
||||
return;
|
||||
};
|
||||
let bash = bash.to_string_lossy().into_owned();
|
||||
// `has_custom_args: false` — the dropdown's `-i -l` are tty7's own, so
|
||||
// the real spawn path reaches setup_bash with them overridable.
|
||||
let injection = setup(Some(&bash), &[], false).expect("bash integration");
|
||||
let text = prompt_cycle_over_pty(&bash, &injection);
|
||||
|
||||
for mark in ["133;A", "133;B", "133;C", "133;D;1"] {
|
||||
assert!(
|
||||
@@ -1162,20 +1372,58 @@ mod tests {
|
||||
// path, not just the marker's presence: Git Bash's `$PWD` is an msys
|
||||
// path (`/c/Users/x`) that Windows resolves drive-relative to a
|
||||
// non-existent `C:\c\Users\x`, which silently disables the git-status
|
||||
// probe and breaks split/new-tab. Round-tripping through the daemon's
|
||||
// own parser is what proves the two halves agree.
|
||||
let payload = text
|
||||
.split("\u{1b}]")
|
||||
.find(|s| s.starts_with("7;file://"))
|
||||
.and_then(|s| s.split(['\u{7}', '\u{1b}']).next())
|
||||
.unwrap_or_else(|| panic!("expected OSC 7; got:\n{text}"));
|
||||
let cwd = crate::daemon::pane::parse_osc7(payload.as_bytes())
|
||||
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"));
|
||||
// probe and breaks split/new-tab.
|
||||
let cwd = reported_cwd(&text);
|
||||
assert!(
|
||||
cwd.exists(),
|
||||
"Git Bash reported a cwd the Windows side cannot resolve: {cwd:?} \
|
||||
(from {payload:?}) — a drive-relative msys path, so `pwd -W` \
|
||||
translation regressed"
|
||||
— a drive-relative msys path, so `pwd -W` translation regressed"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end on a real PTY, through `wsl.exe` into an actual distro.
|
||||
/// This is the only thing that can show the injection survives the whole
|
||||
/// chain: `WSLENV` translating the rcfile path to the distro's view of the
|
||||
/// filesystem, `wsl.exe` passing our `sh -c` through without a shell to
|
||||
/// mangle its quoting, and the distro's own `/etc/profile` + `~/.bashrc`
|
||||
/// running before our hooks layer on top.
|
||||
///
|
||||
/// Also covers the in-distro shell pick: this machine's distro runs bash, so
|
||||
/// reaching the marks at all means [`WSL_EXEC_SCRIPT`]'s `case` took its
|
||||
/// bash arm after surviving Windows argv quoting.
|
||||
///
|
||||
/// Skips when WSL isn't installed, so it's a no-op on a machine without it.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_reports_the_full_prompt_cycle_over_a_real_pty() {
|
||||
let Some(distro) = crate::core::shells::wsl_distros().into_iter().next() else {
|
||||
eprintln!("skipping: no WSL distributions installed");
|
||||
return;
|
||||
};
|
||||
// Exactly the args the new-tab dropdown produces for this distro.
|
||||
let args: Vec<String> = vec![
|
||||
"--distribution".into(),
|
||||
distro.clone(),
|
||||
"--cd".into(),
|
||||
"~".into(),
|
||||
];
|
||||
let injection = setup(Some("wsl.exe"), &args, false).expect("wsl integration");
|
||||
let text = prompt_cycle_over_pty("wsl.exe", &injection);
|
||||
|
||||
for mark in ["133;A", "133;B", "133;C", "133;D;1"] {
|
||||
assert!(
|
||||
text.contains(mark),
|
||||
"WSL ({distro}) must report {mark}; got:\n{text}"
|
||||
);
|
||||
}
|
||||
// The distro's cwd is a *Linux* path, and must stay one — translating it
|
||||
// to something Windows-resolvable would be wrong, not helpful. What
|
||||
// matters is that the pane is tagged so nothing local consumes it; that
|
||||
// tagging is asserted in `pane`'s `wsl_remote_context` tests.
|
||||
let cwd = reported_cwd(&text);
|
||||
assert!(
|
||||
cwd.to_string_lossy().starts_with('/'),
|
||||
"expected the distro's own absolute path, got {cwd:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1210,11 +1458,93 @@ mod tests {
|
||||
}
|
||||
// Unknown shells (and absolute paths to them) resolve to None.
|
||||
assert!(shell_kind(Some("/bin/sh")).is_none());
|
||||
// cmd has no preexec hook of any kind, so it stays unsupported on
|
||||
// purpose (see the module docs).
|
||||
assert!(shell_kind(Some("cmd.exe")).is_none());
|
||||
// cmd has no preexec hook of any kind, and `wsl.exe` is only the
|
||||
// Windows-side launcher — injecting into it would never reach the
|
||||
// distro's own shell. Both stay unsupported on purpose.
|
||||
assert!(shell_kind(Some("wsl.exe")).is_none());
|
||||
// `wsl.exe` is the launcher, not a shell — it maps to its own kind so
|
||||
// `setup` can reach through it into the distro.
|
||||
assert!(matches!(shell_kind(Some("wsl.exe")), Some(ShellKind::Wsl)));
|
||||
assert!(matches!(shell_kind(Some("wsl")), Some(ShellKind::Wsl)));
|
||||
}
|
||||
|
||||
/// Regression: `setup_wsl` used to probe the distro's login shell with a
|
||||
/// synchronous `wsl.exe` call. The client waits for the daemon's `Spawn`
|
||||
/// reply (`terminal::remote::spawn`), so on a cold WSL start — seconds,
|
||||
/// while the distro boots — that froze the entire window.
|
||||
///
|
||||
/// Naming a distro that cannot exist is the deterministic form of the
|
||||
/// check: if anything asked the distro a question, this could not succeed.
|
||||
/// A timing bound would only catch it on a cold machine.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_setup_never_contacts_the_distro() {
|
||||
let args: Vec<String> = vec![
|
||||
"--distribution".into(),
|
||||
"tty7-no-such-distro-exists".into(),
|
||||
"--cd".into(),
|
||||
"~".into(),
|
||||
];
|
||||
let inj = setup(Some("wsl.exe"), &args, false)
|
||||
.expect("setup must not depend on reaching the distro");
|
||||
|
||||
// The launch flags are rebuilt, not appended to, and the command sits
|
||||
// after `--`.
|
||||
let sep = inj.args.iter().position(|a| a == "--").expect("`--`");
|
||||
assert_eq!(
|
||||
&inj.args[..sep],
|
||||
&[
|
||||
"--distribution".to_string(),
|
||||
"tty7-no-such-distro-exists".to_string(),
|
||||
"--cd".to_string(),
|
||||
"~".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(inj.args[sep + 1], "sh");
|
||||
assert_eq!(inj.args[sep + 2], "-c");
|
||||
// The shell decision is inside the script, not resolved out here.
|
||||
assert!(inj.args[sep + 3].contains("$SHELL"));
|
||||
assert!(inj.args[sep + 3].contains("--rcfile"));
|
||||
assert!(inj.replaces_argv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_distro_and_cd_are_read_from_either_flag_spelling() {
|
||||
let long: Vec<String> = ["--distribution", "Ubuntu-24.04", "--cd", "~"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(wsl_distro(&long).as_deref(), Some("Ubuntu-24.04"));
|
||||
assert_eq!(wsl_cd(&long).as_deref(), Some("~"));
|
||||
|
||||
let short: Vec<String> = ["-d", "Debian"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(wsl_distro(&short).as_deref(), Some("Debian"));
|
||||
assert_eq!(wsl_cd(&short), None);
|
||||
|
||||
let eq: Vec<String> = ["--distribution=Arch", "--cd=/tmp"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(wsl_distro(&eq).as_deref(), Some("Arch"));
|
||||
assert_eq!(wsl_cd(&eq).as_deref(), Some("/tmp"));
|
||||
|
||||
// No distro flag is a valid answer — `wsl.exe` then picks the default.
|
||||
assert_eq!(wsl_distro(&[]), None);
|
||||
// A trailing flag with no value must not panic.
|
||||
assert_eq!(wsl_distro(&["--distribution".to_string()]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wslenv_preserves_the_users_own_entries() {
|
||||
// Regression guard: overwriting `WSLENV` silently drops whatever the
|
||||
// user configured, breaking *their* Windows->WSL variable passing.
|
||||
assert_eq!(
|
||||
wslenv_with(Some("MYVAR/p:OTHER"), &["TTY7_RC/p"]),
|
||||
"MYVAR/p:OTHER:TTY7_RC/p"
|
||||
);
|
||||
assert_eq!(wslenv_with(None, &["TTY7_RC/p"]), "TTY7_RC/p");
|
||||
assert_eq!(wslenv_with(Some(""), &["TTY7_RC/p"]), "TTY7_RC/p");
|
||||
// Already present: left exactly as the user spelled it, not duplicated.
|
||||
assert_eq!(wslenv_with(Some("TTY7_RC/l"), &["TTY7_RC/p"]), "TTY7_RC/l");
|
||||
}
|
||||
|
||||
/// Git Bash is spawned by its absolute `bash.exe` path, so `.exe` must be
|
||||
@@ -1575,7 +1905,7 @@ mod tests {
|
||||
assert!(inj.args[1].contains("__tty7"));
|
||||
assert!(inj.args[1].contains("133;"));
|
||||
assert!(inj.env.is_empty());
|
||||
assert!(!inj.force_non_login);
|
||||
assert!(!inj.replaces_argv);
|
||||
// fish needs no throwaway dir on disk.
|
||||
assert!(inj.dir.is_none());
|
||||
}
|
||||
@@ -1589,7 +1919,7 @@ mod tests {
|
||||
inj.env.get("ZDOTDIR").map(String::as_str),
|
||||
Some(dir.to_string_lossy().as_ref())
|
||||
);
|
||||
assert!(!inj.force_non_login);
|
||||
assert!(!inj.replaces_argv);
|
||||
assert!(inj.args.is_empty());
|
||||
// All four redirector files landed on disk with the expected content.
|
||||
for (name, body) in zsh_redirectors() {
|
||||
@@ -1608,7 +1938,7 @@ mod tests {
|
||||
// argv is `--rcfile <path> -i`, in that order.
|
||||
assert_eq!(inj.args[0], "--rcfile");
|
||||
assert_eq!(inj.args[2], "-i");
|
||||
assert!(inj.force_non_login);
|
||||
assert!(inj.replaces_argv);
|
||||
// The rc file on disk matches the generated template.
|
||||
let rc = std::fs::read_to_string(&inj.args[1]).expect("rcfile written");
|
||||
assert_eq!(rc, bash_rcfile());
|
||||
@@ -1618,7 +1948,7 @@ mod tests {
|
||||
#[test]
|
||||
fn setup_dispatches_by_shell_and_sets_sentinel() {
|
||||
// zsh → an injection carrying the "already active" sentinel (empty value).
|
||||
let inj = setup(Some("zsh"), false).expect("zsh setup");
|
||||
let inj = setup(Some("zsh"), &[], false).expect("zsh setup");
|
||||
assert_eq!(
|
||||
inj.env.get("TTY7_SHELL_INTEGRATION").map(String::as_str),
|
||||
Some("")
|
||||
@@ -1628,7 +1958,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// fish → same sentinel, no files.
|
||||
let inj = setup(Some("fish"), false).expect("fish setup");
|
||||
let inj = setup(Some("fish"), &[], false).expect("fish setup");
|
||||
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
|
||||
|
||||
// bash without custom args → full injection with non-login override.
|
||||
@@ -1639,28 +1969,28 @@ mod tests {
|
||||
} else {
|
||||
"bash"
|
||||
};
|
||||
let inj = setup(Some(bash), false).expect("bash setup");
|
||||
assert!(inj.force_non_login);
|
||||
let inj = setup(Some(bash), &[], false).expect("bash setup");
|
||||
assert!(inj.replaces_argv);
|
||||
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
|
||||
if let Some(d) = inj.dir {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
// bash WITH custom args → we must not second-guess the user: no injection.
|
||||
assert!(setup(Some(bash), true).is_none());
|
||||
assert!(setup(Some(bash), &[], true).is_none());
|
||||
|
||||
// PowerShell without custom args → encoded-command injection, no files.
|
||||
let inj = setup(Some("powershell.exe"), false).expect("powershell setup");
|
||||
let inj = setup(Some("powershell.exe"), &[], false).expect("powershell setup");
|
||||
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
|
||||
assert!(inj.dir.is_none());
|
||||
assert!(!inj.force_non_login);
|
||||
assert!(!inj.replaces_argv);
|
||||
|
||||
// PowerShell WITH custom args → `-EncodedCommand` would collide with the
|
||||
// user's own `-Command`/`-File`, so we launch bare.
|
||||
assert!(setup(Some("pwsh"), true).is_none());
|
||||
assert!(setup(Some("pwsh"), &[], true).is_none());
|
||||
|
||||
// Unknown shell → no integration at all.
|
||||
assert!(setup(Some("/bin/sh"), false).is_none());
|
||||
assert!(setup(Some("/bin/sh"), &[], false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1684,7 +2014,7 @@ mod tests {
|
||||
// No throwaway dir, no forced spawn mode, no env of its own.
|
||||
assert!(inj.env.is_empty());
|
||||
assert!(inj.dir.is_none());
|
||||
assert!(!inj.force_non_login);
|
||||
assert!(!inj.replaces_argv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-3
@@ -3733,8 +3733,14 @@ impl Tty7App {
|
||||
}
|
||||
};
|
||||
Some(rgb)
|
||||
} else if v.remote_context().is_some() {
|
||||
// A foreground `ssh` typed into a shell: a plain neutral dot.
|
||||
} else if v
|
||||
.remote_context()
|
||||
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
|
||||
{
|
||||
// A foreground `ssh` typed into a shell: a plain neutral dot. The
|
||||
// kind check matters: a WSL pane also carries a `RemoteContext` (so
|
||||
// its cwd is treated as foreign — see `local_cwd`), but it is not an
|
||||
// SSH session and this dot means "SSH".
|
||||
Some(0x9CA3AF)
|
||||
} else {
|
||||
None
|
||||
@@ -3792,6 +3798,11 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The focused pane when it is an SSH session of either kind.
|
||||
///
|
||||
/// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too,
|
||||
/// so that its cwd is treated as foreign (see `TerminalView::local_cwd`),
|
||||
/// and it must not reach anything SSH-shaped from here.
|
||||
pub(crate) fn active_ssh_pane(
|
||||
&self,
|
||||
window: &Window,
|
||||
@@ -3803,7 +3814,8 @@ impl Tty7App {
|
||||
.pane
|
||||
.focused_or_first(window, cx)?;
|
||||
let pane = pane.read(cx);
|
||||
Some((pane.pane_id, pane.remote_context()?))
|
||||
let remote = pane.remote_context()?;
|
||||
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
|
||||
}
|
||||
|
||||
/// The focused pane when it is a *connected native* SSH session — the gate for
|
||||
|
||||
Reference in New Issue
Block a user