mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-23 08:02:27 +00:00
On Windows a bare `bash` resolves through PATH to `C:\Windows\System32\bash.exe` — the WSL launcher, not a shell. With no distro installed it exits non-zero with an empty stderr, which is indistinguishable from "the shell rejected this script", so the checks failed the Windows job while reporting nothing to explain why. `is_msys_bash` guards the production path against the same trap; the test had no such guard. Nothing is lost by gating them to unix: these scripts are destined for a remote POSIX host, so their syntax has nothing to do with the platform running the test, and the macOS and Linux jobs already exercise them.
2611 lines
116 KiB
Rust
2611 lines
116 KiB
Rust
//! Shell integration: inject a small startup snippet into the shell tty7 spawns
|
|
//! so the shell *actively reports* its state — prompt boundaries, command
|
|
//! start/finish, exit codes, and cwd — instead of us guessing from the outside.
|
|
//!
|
|
//! This is the foundation the inline input editor builds on. The reporting
|
|
//! protocol is the FinalTerm / iTerm2 **OSC 133** semantic-prompt standard, so it
|
|
//! interoperates with the wider ecosystem rather than a bespoke scheme:
|
|
//! - `OSC 133 ; A ST` prompt start
|
|
//! - `OSC 133 ; B ST` prompt end / command input begins
|
|
//! - `OSC 133 ; C [; <cmd>] ST` command output begins; all four integrations
|
|
//! append the submitted command line percent-encoded (tty7 extension — the
|
|
//! Windows coding-agent detection input, see `core::cli_agent`)
|
|
//! - `OSC 133 ; D ; <exit> ST` command finished, with its exit code
|
|
//! - `OSC 133 ; V ; 0/1 ST` tty7 extension: shell edit mode
|
|
//!
|
|
//! plus `OSC 7` to report the cwd precisely (many login shells don't emit it
|
|
//! unless they think they're in Terminal.app).
|
|
//!
|
|
//! Supports zsh, bash, fish and PowerShell; each needs a different injection
|
|
//! mechanism because the shells disagree on how much control they hand an
|
|
//! integrator:
|
|
//! - **zsh** has `ZDOTDIR`, an env var that retargets *all* of its startup
|
|
//! files at once — the cleanest hook of the four. See [`zsh_redirectors`].
|
|
//! - **fish** has no such redirect, but its `-C`/`--init-command` flag runs
|
|
//! extra commands after fish's own (unmodified) config load — no throwaway
|
|
//! directory needed at all. See [`setup_fish`].
|
|
//! - **bash** has neither: no env var retargets its rc file, and `--rcfile`
|
|
//! (the only override it does have) is silently ignored for *login*
|
|
//! shells, which is how terminals normally spawn it. So we spawn bash as a
|
|
//! 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::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
|
|
//! precmd/preexec, so — following VS Code — the body wraps two
|
|
//! host hooks: the `prompt` function (for the A/B/D marks + cwd) and
|
|
//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to
|
|
//! a preexec, for the C mark). See [`setup_powershell`].
|
|
//!
|
|
//! Across all of them: **the user's own dotfiles are never modified** — the
|
|
//! mechanisms above only affect shells tty7 itself launches.
|
|
//!
|
|
//! All of the above configure a *local* process spawn. Native-SSH panes have no
|
|
//! spawn to configure — only the command string an `exec` channel request
|
|
//! carries — so they take a different route to the same place: probe the remote
|
|
//! for its login shell, then send a script that recreates these very files on
|
|
//! the remote side and `exec`s through them. See [`remote`].
|
|
//!
|
|
//! **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};
|
|
|
|
/// The zsh integration body, sourced from our injected `.zshrc` after the user's
|
|
/// own `.zshrc` has run. Guarded so it installs exactly once per interactive
|
|
/// shell. See the module docs for the OSC 133 semantics.
|
|
const ZSH_INTEGRATION: &str = r#"
|
|
# --- tty7 shell integration (zsh) ---
|
|
if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then
|
|
export TTY7_SHELL_INTEGRATION=1
|
|
|
|
__tty7_osc() { builtin printf '\e]%s\a' "$1"; }
|
|
|
|
# Vi mode links the `main` keymap to `viins` (`bindkey -A viins main`);
|
|
# emacs mode links it to `emacs`. The link survives plugins like
|
|
# zsh-vi-mode that rebind `^[` to their own widgets, so it beats sniffing
|
|
# the Esc widget name.
|
|
__tty7_report_edit_mode() {
|
|
if [[ "$(builtin bindkey -lL main)" == *viins* ]]; then
|
|
__tty7_osc "133;V;1"
|
|
else
|
|
__tty7_osc "133;V;0"
|
|
fi
|
|
}
|
|
|
|
# OSC 7: report the working directory so the app tracks it precisely (used for
|
|
# opening new tabs / splits in the same place). The daemon percent-DECODES the
|
|
# payload (OSC 7 carries a file: URI), so a literal `%` in the path must be
|
|
# escaped as %25 or a dir like `/tmp/a%20b` would decode to `/tmp/a b`.
|
|
__tty7_report_cwd() { builtin printf '\e]7;file://%s%s\a' "${HOST:-localhost}" "${PWD//\%/%25}"; }
|
|
|
|
# D (command finished + its exit code) gets its own hook, *prepended* to
|
|
# precmd_functions rather than bundled into __tty7_precmd below: the app only
|
|
# switches back to prompt-editing when D arrives, so every hook that runs
|
|
# before it is a window where keystrokes go raw to the PTY, get kernel-echoed
|
|
# into the grid, and bait zsh's PROMPT_SP into leaving a stray `char + %` line.
|
|
# The user's precmd chain (git-status prompts, conda, …) can take hundreds of
|
|
# ms — D must not wait for it.
|
|
__tty7_precmd_d() {
|
|
local ret=$?
|
|
if [[ -n "$__tty7_cmd_active" ]]; then
|
|
__tty7_osc "133;D;$ret"
|
|
unset __tty7_cmd_active
|
|
fi
|
|
}
|
|
|
|
# The rest of the prompt bookkeeping runs right before the prompt is drawn,
|
|
# *after* the user's hooks: report cwd, then open a fresh prompt (A).
|
|
__tty7_precmd() {
|
|
__tty7_report_cwd
|
|
__tty7_report_edit_mode
|
|
__tty7_osc "133;A"
|
|
# Prompt-end marker (B): emitted at the very end of the prompt — exactly where
|
|
# input begins — by living in PS1 (wrapped in %{...%} so zsh excludes it from
|
|
# prompt width). We (re)append it here in precmd rather than once at load,
|
|
# because prompt frameworks (powerlevel10k / starship / oh-my-zsh) rebuild
|
|
# PS1 in their own precmd and would otherwise drop it. This precmd runs last
|
|
# (added after the user's), and the sentinel check keeps a static PS1 from
|
|
# accumulating duplicate markers.
|
|
[[ "$PS1" != *$'\e]133;B\a'* ]] && PS1="$PS1"$'%{\e]133;B\a%}'
|
|
}
|
|
|
|
# preexec runs after the user hits Enter, before the command runs: mark the
|
|
# start of command output (C). We track an "active" flag so the very first
|
|
# prompt (no command yet) doesn't emit a bogus D. The C mark carries the
|
|
# submitted line ($1), truncated (detection only reads the front) and with
|
|
# the bytes that would break OSC framing or the daemon's percent-decode
|
|
# escaped (% ESC BEL CR NL) — the coding-agent detection input on Windows,
|
|
# where ConPTY has no process table to poll (see core::cli_agent).
|
|
__tty7_preexec() {
|
|
__tty7_cmd_active=1
|
|
local cmd=$1
|
|
cmd=${cmd[1,512]}
|
|
cmd=${cmd//\%/%25}
|
|
cmd=${cmd//$'\e'/%1B}
|
|
cmd=${cmd//$'\a'/%07}
|
|
cmd=${cmd//$'\r'/%0D}
|
|
cmd=${cmd//$'\n'/%0A}
|
|
__tty7_osc "133;C;$cmd"
|
|
}
|
|
|
|
autoload -Uz add-zsh-hook
|
|
add-zsh-hook precmd __tty7_precmd
|
|
add-zsh-hook preexec __tty7_preexec
|
|
# add-zsh-hook can only append, and the user's hooks are all registered by now
|
|
# (their .zshrc ran before this file) — prepend the D emitter by hand so it's
|
|
# the first thing to run when a command exits. Users who define a classic
|
|
# `precmd()` function still get ahead of us (zsh calls it before the array);
|
|
# that's out of reach without wrapping their function.
|
|
precmd_functions=(__tty7_precmd_d $precmd_functions)
|
|
|
|
# Startup kept ZDOTDIR aimed at our throwaway redirector dir so zsh read every
|
|
# one of our startup files. Now they've all run, point it back at the user's
|
|
# real config dir for the live session: tools that resolve state via
|
|
# ${ZDOTDIR:-$HOME} *at runtime* (compinit's .zcompdump, lazily-compiled plugin
|
|
# caches) — and a nested plain `zsh` — must land in the user's dir, not our
|
|
# empty temp one. One-shot: fires on the first precmd, then removes itself.
|
|
__tty7_restore_zdotdir() {
|
|
ZDOTDIR=${TTY7_USER_ZDOTDIR:-$HOME}
|
|
add-zsh-hook -d precmd __tty7_restore_zdotdir
|
|
unfunction __tty7_restore_zdotdir
|
|
}
|
|
add-zsh-hook precmd __tty7_restore_zdotdir
|
|
fi
|
|
# --- end tty7 shell integration ---
|
|
"#;
|
|
|
|
/// The fish integration body, passed verbatim as a `-C`/`--init-command`
|
|
/// argument (see [`setup_fish`]) — fish has already loaded the user's *real*
|
|
/// `config.fish` by the time this runs, so unlike zsh/bash there's nothing here
|
|
/// to source manually.
|
|
///
|
|
/// fish has no event that fires *after* the prompt is drawn, so the B marker
|
|
/// (prompt end / input begins) can't be emitted from an `--on-event` handler
|
|
/// the way A/C/D are — it has to be spliced into `fish_prompt` itself. We
|
|
/// capture whatever `fish_prompt` already is (the user's own, or a prompt
|
|
/// framework's) and wrap it: call the original, then emit B right after.
|
|
const FISH_INTEGRATION: &str = r#"
|
|
# --- tty7 shell integration (fish) ---
|
|
# Guard on *emptiness* (`test -z`), not definedness (`set -q`): `setup()` resets the
|
|
# sentinel to an empty-but-exported "" at each spawn boundary, and fish reports an
|
|
# empty exported var as *set*, so `not set -q` would skip the install on every fish
|
|
# launch (OSC 133 never arms). `-z` matches the zsh/bash guards and the empty reset —
|
|
# it installs once for a fresh top-level shell while an inherited `1` still blocks it.
|
|
if status is-interactive; and test -z "$TTY7_SHELL_INTEGRATION"
|
|
set -gx TTY7_SHELL_INTEGRATION 1
|
|
|
|
function __tty7_osc
|
|
printf '\e]%s\a' $argv[1]
|
|
end
|
|
|
|
function __tty7_report_edit_mode
|
|
switch $fish_key_bindings
|
|
case '*vi*'
|
|
__tty7_osc "133;V;1"
|
|
case '*'
|
|
__tty7_osc "133;V;0"
|
|
end
|
|
end
|
|
|
|
# The daemon percent-decodes the OSC 7 payload; escape literal `%` as %25 so
|
|
# a path like /tmp/a%20b round-trips instead of decoding to /tmp/a b.
|
|
function __tty7_report_cwd
|
|
printf '\e]7;file://%s%s\a' (hostname) (string replace --all '%' '%25' -- $PWD)
|
|
end
|
|
|
|
# The C mark carries the submitted line, truncated and with the bytes that
|
|
# would break OSC framing or the daemon's percent-decode escaped (% ESC BEL
|
|
# CR NL) — the Windows agent-detection input (see core::cli_agent). fish
|
|
# command substitution splits output on newlines, so a multi-line command
|
|
# arrives as a list; the final `string join` re-joins it with the escaped
|
|
# newline. `%` must be escaped first (the other escapes introduce `%`).
|
|
function __tty7_preexec --on-event fish_preexec
|
|
set -g __tty7_cmd_active 1
|
|
set -l cmd (string sub -l 512 -- $argv[1] | string replace -a '%' '%25' | string replace -a \e '%1B' | string replace -a \a '%07' | string replace -a \r '%0D' | string join '%0A')
|
|
__tty7_osc "133;C;$cmd"
|
|
end
|
|
|
|
# Runs on the fish_prompt *event*, which fires before fish calls the
|
|
# fish_prompt *function* to render the prompt text — i.e. exactly where A
|
|
# (prompt start) belongs.
|
|
function __tty7_precmd --on-event fish_prompt
|
|
set -l ret $status
|
|
if set -q __tty7_cmd_active
|
|
__tty7_osc "133;D;$ret"
|
|
set -e __tty7_cmd_active
|
|
end
|
|
__tty7_report_cwd
|
|
__tty7_report_edit_mode
|
|
__tty7_osc "133;A"
|
|
end
|
|
|
|
functions -c fish_prompt __tty7_original_fish_prompt
|
|
function fish_prompt
|
|
__tty7_original_fish_prompt
|
|
__tty7_osc "133;B"
|
|
end
|
|
end
|
|
# --- end tty7 shell integration ---
|
|
"#;
|
|
|
|
/// The bash integration body, appended after the replayed login-file chain
|
|
/// (see [`setup_bash`]). Bash has no native precmd/preexec, so this vendors the
|
|
/// core mechanism from [bash-preexec](https://github.com/rcaloras/bash-preexec)
|
|
/// (MIT) — the same shim VS Code uses — trimmed of everything but the
|
|
/// precmd/preexec plumbing: a `DEBUG` trap infers "a command is genuinely about
|
|
/// to run interactively" (as opposed to firing mid-completion, mid readline
|
|
/// binding, or for a piece of `PROMPT_COMMAND` itself), and `PROMPT_COMMAND`
|
|
/// runs registered precmd functions before each prompt.
|
|
///
|
|
/// If the user's own `.bashrc` already loaded bash-preexec (several prompt
|
|
/// frameworks bundle it) we don't install it a second time — re-running the
|
|
/// install sequence would clear and never restore the already-installed
|
|
/// `DEBUG` trap. We detect that via bash-preexec's own `bash_preexec_imported`
|
|
/// sentinel and, either way, register our hooks through its public extension
|
|
/// points (`precmd_functions` / `preexec_functions`) rather than the "function
|
|
/// literally named `precmd`/`preexec`" convenience, which could collide with
|
|
/// the user's own.
|
|
const BASH_INTEGRATION: &str = r#"
|
|
# --- tty7 shell integration (bash) ---
|
|
if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then
|
|
export TTY7_SHELL_INTEGRATION=1
|
|
|
|
__tty7_osc() { builtin printf '\e]%s\a' "$1"; }
|
|
# `bind -v` reports readline's actual editing mode; `[[ -o vi ]]` misses
|
|
# vi mode configured only in ~/.inputrc (`set editing-mode vi` flips
|
|
# readline without setting the shell option).
|
|
__tty7_report_edit_mode() {
|
|
if [[ "$(builtin bind -v 2>/dev/null)" == *"set editing-mode vi"* ]]; then
|
|
__tty7_osc "133;V;1"
|
|
else
|
|
__tty7_osc "133;V;0"
|
|
fi
|
|
}
|
|
# Escape literal `%` as %25 — the daemon percent-decodes the OSC 7 payload.
|
|
#
|
|
# Under Git Bash (msys) `$PWD` is an msys path — `/c/Users/x`, and `/tmp` for
|
|
# mounts with no drive at all. The daemon runs Windows-side, where `/c/Users/x`
|
|
# is not absolute but *drive-relative*, so it resolves to a bogus `C:\c\Users\x`
|
|
# (see `pane::strip_uri_drive_slash`, which only un-prefixes `/C:/…`). `pwd -W`
|
|
# is msys's own translation to the real Windows path, and it resolves mounts
|
|
# that have a real backing directory (`/tmp` -> `C:/Users/x/AppData/Local/Temp`).
|
|
# It has no leading slash, so add one to make it the absolute-path shape a
|
|
# file: URI expects.
|
|
#
|
|
# For msys-only virtual mounts (`/proc`, `/dev`) there *is* no Windows path
|
|
# and `pwd -W` is the identity, so require a drive letter and stay silent
|
|
# otherwise — a `/proc` payload would land as drive-relative `C:\proc` and
|
|
# fail the next spawn, whereas reporting nothing leaves the daemon holding
|
|
# the last usable cwd. Testing the shape beats testing for a leading slash,
|
|
# which cannot tell a translated path from an untranslated one.
|
|
#
|
|
# The branch is resolved once at install time; the `$(…)` inside still forks
|
|
# per prompt (~6.8 ms under msys, vs ~0.3 ms for the plain `$PWD` path), which
|
|
# is the price of a correct path and only paid by Git Bash panes.
|
|
if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then
|
|
__tty7_report_cwd() {
|
|
local d
|
|
d="$(builtin pwd -W 2>/dev/null)" || return 0
|
|
[[ "$d" == ?:* ]] || return 0
|
|
builtin printf '\e]7;file://%s/%s\a' "${HOSTNAME:-localhost}" "${d//\%/%25}"
|
|
}
|
|
else
|
|
__tty7_report_cwd() { builtin printf '\e]7;file://%s%s\a' "${HOSTNAME:-localhost}" "${PWD//\%/%25}"; }
|
|
fi
|
|
|
|
# Own hook for D, prepended to precmd_functions (same rationale as the zsh
|
|
# path): the app flips back to prompt-editing on D, so it must fire the
|
|
# instant the command exits, not after the user's precmd functions.
|
|
__tty7_precmd_d() {
|
|
local ret=$?
|
|
if [[ -n "$__tty7_cmd_active" ]]; then
|
|
__tty7_osc "133;D;$ret"
|
|
unset __tty7_cmd_active
|
|
fi
|
|
return $ret
|
|
}
|
|
|
|
__tty7_precmd() {
|
|
local ret=$?
|
|
__tty7_report_cwd
|
|
__tty7_report_edit_mode
|
|
__tty7_osc "133;A"
|
|
# Prompt-end marker (B), wrapped in \[...\] so readline excludes it from the
|
|
# prompt's on-screen width. Re-appended every precmd (like the zsh path)
|
|
# since prompt frameworks that rebuild PS1 in their own precmd would
|
|
# otherwise drop it; the case-check keeps a static PS1 from accumulating
|
|
# duplicates.
|
|
case "$PS1" in
|
|
*'\[\033]133;B\a\]'*) ;;
|
|
*) PS1="$PS1"'\[\033]133;B\a\]' ;;
|
|
esac
|
|
return $ret
|
|
}
|
|
|
|
# The C mark carries the submitted line ($1, from bash-preexec), truncated
|
|
# and escaped the same way as the zsh path — the Windows agent-detection
|
|
# input (git-bash; see core::cli_agent).
|
|
__tty7_preexec() {
|
|
__tty7_cmd_active=1
|
|
local cmd=${1:0:512}
|
|
cmd=${cmd//\%/%25}
|
|
cmd=${cmd//$'\e'/%1B}
|
|
cmd=${cmd//$'\a'/%07}
|
|
cmd=${cmd//$'\r'/%0D}
|
|
cmd=${cmd//$'\n'/%0A}
|
|
__tty7_osc "133;C;$cmd"
|
|
}
|
|
|
|
if [[ -z "${bash_preexec_imported:-}" ]]; then
|
|
# --- vendored from bash-preexec.sh (https://github.com/rcaloras/bash-preexec, MIT) ---
|
|
bash_preexec_imported="defined"
|
|
__bp_imported="$bash_preexec_imported"
|
|
|
|
__bp_last_ret_value="$?"
|
|
BP_PIPESTATUS=("${PIPESTATUS[@]}")
|
|
__bp_last_argument_prev_command="$_"
|
|
__bp_inside_precmd=0
|
|
__bp_inside_preexec=0
|
|
__bp_preexec_interactive_mode=""
|
|
__bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install'
|
|
|
|
declare -a precmd_functions
|
|
declare -a preexec_functions
|
|
|
|
__bp_require_not_readonly() {
|
|
local var
|
|
for var; do
|
|
if ! ( unset "$var" 2> /dev/null ); then
|
|
echo "bash-preexec requires write access to ${var}" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
__bp_trim_whitespace() {
|
|
local var=${1:?} text=${2:-}
|
|
text="${text#"${text%%[![:space:]]*}"}"
|
|
text="${text%"${text##*[![:space:]]}"}"
|
|
printf -v "$var" '%s' "$text"
|
|
}
|
|
|
|
__bp_sanitize_string() {
|
|
local var=${1:?} text=${2:-} sanitized
|
|
__bp_trim_whitespace sanitized "$text"
|
|
sanitized=${sanitized%;}
|
|
sanitized=${sanitized#;}
|
|
__bp_trim_whitespace sanitized "$sanitized"
|
|
printf -v "$var" '%s' "$sanitized"
|
|
}
|
|
|
|
__bp_interactive_mode() { __bp_preexec_interactive_mode="on"; }
|
|
|
|
__bp_precmd_invoke_cmd() {
|
|
__bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}")
|
|
if (( __bp_inside_precmd > 0 )); then return; fi
|
|
local __bp_inside_precmd=1
|
|
local precmd_function
|
|
for precmd_function in "${precmd_functions[@]}"; do
|
|
if type -t "$precmd_function" 1>/dev/null; then
|
|
__bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command"
|
|
"$precmd_function"
|
|
fi
|
|
done
|
|
__bp_set_ret_value "$__bp_last_ret_value"
|
|
}
|
|
|
|
__bp_set_ret_value() { return ${1:+"$1"}; }
|
|
|
|
__bp_in_prompt_command() {
|
|
local prompt_command_array IFS=$'\n;'
|
|
read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}"
|
|
local trimmed_arg
|
|
__bp_trim_whitespace trimmed_arg "${1:-}"
|
|
local command trimmed_command
|
|
for command in "${prompt_command_array[@]:-}"; do
|
|
__bp_trim_whitespace trimmed_command "$command"
|
|
if [[ "$trimmed_command" == "$trimmed_arg" ]]; then return 0; fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
__bp_preexec_invoke_exec() {
|
|
__bp_last_argument_prev_command="${1:-}"
|
|
if (( __bp_inside_preexec > 0 )); then return; fi
|
|
local __bp_inside_preexec=1
|
|
if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then return; fi
|
|
if [[ -n "${COMP_LINE:-}" ]]; then return; fi
|
|
if [[ -n "${READLINE_LINE+x}" ]]; then return; fi
|
|
if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then
|
|
return
|
|
else
|
|
if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then
|
|
__bp_preexec_interactive_mode=""
|
|
fi
|
|
fi
|
|
if __bp_in_prompt_command "${BASH_COMMAND:-}"; then
|
|
__bp_preexec_interactive_mode=""
|
|
return
|
|
fi
|
|
local this_command
|
|
this_command=$(
|
|
export LC_ALL=C
|
|
HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
|
|
)
|
|
if [[ -z "$this_command" ]]; then return; fi
|
|
local preexec_function
|
|
local preexec_function_ret_value
|
|
local preexec_ret_value=0
|
|
for preexec_function in "${preexec_functions[@]:-}"; do
|
|
if type -t "$preexec_function" 1>/dev/null; then
|
|
__bp_set_ret_value "${__bp_last_ret_value:-}"
|
|
"$preexec_function" "$this_command"
|
|
preexec_function_ret_value="$?"
|
|
if [[ "$preexec_function_ret_value" != 0 ]]; then
|
|
preexec_ret_value="$preexec_function_ret_value"
|
|
fi
|
|
fi
|
|
done
|
|
__bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command"
|
|
}
|
|
|
|
__bp_install() {
|
|
if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then return 1; fi
|
|
trap '__bp_preexec_invoke_exec "$_"' DEBUG
|
|
local prior_trap
|
|
prior_trap=$(sed "s/[^']*'\(.*\)'[^']*/\1/" <<<"${__bp_trap_string:-}")
|
|
unset __bp_trap_string
|
|
if [[ -n "$prior_trap" ]]; then
|
|
eval '__bp_original_debug_trap() {
|
|
'"$prior_trap"'
|
|
}'
|
|
preexec_functions+=(__bp_original_debug_trap)
|
|
fi
|
|
if [[ -n "${__bp_enable_subshells:-}" ]]; then
|
|
set -o functrace > /dev/null 2>&1
|
|
shopt -s extdebug > /dev/null 2>&1
|
|
fi;
|
|
local existing_prompt_command
|
|
existing_prompt_command="${PROMPT_COMMAND:-}"
|
|
existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}"
|
|
existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}"
|
|
existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}"
|
|
__bp_sanitize_string existing_prompt_command "$existing_prompt_command"
|
|
if [[ "${existing_prompt_command:-:}" == ":" ]]; then
|
|
existing_prompt_command=
|
|
fi
|
|
PROMPT_COMMAND='__bp_precmd_invoke_cmd'
|
|
PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command}
|
|
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
|
PROMPT_COMMAND+=('__bp_interactive_mode')
|
|
else
|
|
PROMPT_COMMAND+=$'\n__bp_interactive_mode'
|
|
fi
|
|
precmd_functions+=(precmd)
|
|
preexec_functions+=(preexec)
|
|
__bp_precmd_invoke_cmd
|
|
__bp_interactive_mode
|
|
}
|
|
|
|
__bp_install_after_session_init() {
|
|
__bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return
|
|
local sanitized_prompt_command
|
|
__bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}"
|
|
if [[ -n "$sanitized_prompt_command" ]]; then
|
|
PROMPT_COMMAND=${sanitized_prompt_command}$'\n'
|
|
fi;
|
|
PROMPT_COMMAND+=${__bp_install_string}
|
|
}
|
|
# --- end vendored bash-preexec.sh ---
|
|
|
|
__bp_install_after_session_init
|
|
fi
|
|
|
|
# D first (before any user precmds bash-preexec already knows about), the
|
|
# prompt bookkeeping last — mirroring the zsh registration order.
|
|
precmd_functions=(__tty7_precmd_d "${precmd_functions[@]}")
|
|
precmd_functions+=(__tty7_precmd)
|
|
preexec_functions+=(__tty7_preexec)
|
|
fi
|
|
# --- end tty7 shell integration ---
|
|
"#;
|
|
|
|
/// The PowerShell integration body, base64-encoded (see
|
|
/// [`powershell_encoded_command`]) and passed as `-EncodedCommand`, which
|
|
/// PowerShell runs *after* loading the user's profiles — so, like fish's `-C`,
|
|
/// it layers hooks on top of the user's own prompt without a file on disk and
|
|
/// without touching their config.
|
|
///
|
|
/// PowerShell has no precmd/preexec, so — mirroring VS Code — we wrap
|
|
/// two host hooks:
|
|
/// - **`prompt`** runs before each prompt is drawn. It emits `133;D` (the
|
|
/// last command's exit code) and the `OSC 7` cwd as side effects, then
|
|
/// returns the user's own prompt wrapped in `133;A` … `133;B`. The byte
|
|
/// order is therefore `[D][cwd][A]prompt[B]`, exactly what the daemon's
|
|
/// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`).
|
|
/// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest
|
|
/// thing PowerShell has to a preexec. After it returns the submitted line,
|
|
/// before the command runs, we emit `133;C;<command>` (command output
|
|
/// begins), carrying the submitted line percent-encoded as a tty7
|
|
/// extension. That capture is the Windows coding-agent detection input:
|
|
/// ConPTY has no foreground process group for the daemon's process-table
|
|
/// poll to read an `argv` from, so — like Warp — the daemon learns what
|
|
/// runs from the line the shell itself reported (see
|
|
/// `core::cli_agent::CLIAgent::detect_from_command_with`).
|
|
///
|
|
/// `$?` must be captured as the very first statement of `prompt` (an
|
|
/// assignment sets `$?` to true, clobbering it), and is restored before the
|
|
/// user's own prompt runs so a status-aware prompt still sees the real result.
|
|
const POWERSHELL_INTEGRATION: &str = r#"
|
|
# --- tty7 shell integration (PowerShell) ---
|
|
if (-not $env:TTY7_SHELL_INTEGRATION) {
|
|
$env:TTY7_SHELL_INTEGRATION = '1'
|
|
|
|
$global:__Tty7Esc = [char]0x1b
|
|
$global:__Tty7Bel = [char]0x07
|
|
# Whatever prompt the user's profile settled on; we call through to it.
|
|
$global:__Tty7OrigPrompt = $function:prompt
|
|
# Gates the D marker so the first prompt (no command yet) emits no bogus exit.
|
|
$global:__Tty7CmdActive = $false
|
|
|
|
function global:prompt {
|
|
# $? first: an assignment sets $? to true, so read it before anything else.
|
|
$ok = $?
|
|
$lastExit = $LASTEXITCODE
|
|
|
|
if ($global:__Tty7CmdActive) {
|
|
$global:__Tty7CmdActive = $false
|
|
# $? is the reliable success signal; $LASTEXITCODE can be stale, so only
|
|
# trust it when $? already says the command failed.
|
|
$code = if ($ok) { 0 } elseif ($lastExit) { $lastExit } else { 1 }
|
|
Write-Host -NoNewline "$($global:__Tty7Esc)]133;D;$code$($global:__Tty7Bel)"
|
|
}
|
|
|
|
# cwd + title, for real filesystem locations only.
|
|
if ($PWD.Provider.Name -eq 'FileSystem') {
|
|
$fsPath = $PWD.ProviderPath
|
|
|
|
# OSC 7 cwd. Escape a literal % as %25 (the daemon percent-decodes the
|
|
# payload) and use forward slashes. Force one leading slash so a Windows
|
|
# drive path (`C:/…`) becomes `/C:/…` — the absolute-path shape the URI
|
|
# expects — while a POSIX path keeps its single slash instead of doubling it.
|
|
$p = $fsPath.Replace('%', '%25').Replace('\', '/')
|
|
if (-not $p.StartsWith('/')) { $p = '/' + $p }
|
|
Write-Host -NoNewline "$($global:__Tty7Esc)]7;file://$($env:COMPUTERNAME)$p$($global:__Tty7Bel)"
|
|
|
|
# OSC 0 window/tab title "user@host:dir". PowerShell profiles don't set a
|
|
# title the way macOS's default zsh does, so without this every tty7 tab on
|
|
# Windows stays generic. Forward slashes (so tty7's tab-label parser can take
|
|
# the last path segment) and home shown as `~`. Re-emitted each prompt so it
|
|
# tracks cwd; a full-screen app's own title still overrides it while it runs.
|
|
$titlePath = $fsPath.Replace('\', '/')
|
|
if ($env:USERPROFILE) {
|
|
$userHome = $env:USERPROFILE.Replace('\', '/')
|
|
if ($titlePath.StartsWith($userHome)) {
|
|
$titlePath = '~' + $titlePath.Substring($userHome.Length)
|
|
}
|
|
}
|
|
Write-Host -NoNewline "$($global:__Tty7Esc)]0;$($env:USERNAME)@$($env:COMPUTERNAME):$titlePath$($global:__Tty7Bel)"
|
|
}
|
|
|
|
# Restore the captured status so the user's own prompt sees the real result,
|
|
# then re-restore $LASTEXITCODE afterwards in case the prompt clobbered it.
|
|
$global:LASTEXITCODE = $lastExit
|
|
if (-not $ok) { Write-Error '' -ErrorAction Ignore }
|
|
$base = & $global:__Tty7OrigPrompt
|
|
if ($base -is [array]) { $base = $base -join [char]0x0a }
|
|
$global:LASTEXITCODE = $lastExit
|
|
|
|
"$($global:__Tty7Esc)]133;A$($global:__Tty7Bel)$base$($global:__Tty7Esc)]133;B$($global:__Tty7Bel)"
|
|
}
|
|
|
|
# C (command output begins): wrap PSReadLine's line reader. Force-load the
|
|
# module first so the function exists even if the host hasn't imported it yet;
|
|
# all best-effort (an empty Enter submits no command, so it arms nothing).
|
|
Import-Module PSReadLine -ErrorAction SilentlyContinue
|
|
if ((Test-Path Function:\PSConsoleHostReadLine) -and -not $global:__Tty7ReadLineWrapped) {
|
|
$global:__Tty7ReadLineWrapped = $true
|
|
$global:__Tty7OrigReadLine = $function:global:PSConsoleHostReadLine
|
|
function global:PSConsoleHostReadLine {
|
|
$line = & $global:__Tty7OrigReadLine
|
|
if (-not [string]::IsNullOrWhiteSpace($line)) {
|
|
$global:__Tty7CmdActive = $true
|
|
# Carry the submitted line on the C mark (tty7 extension): the daemon
|
|
# detects coding agents from it on Windows, where ConPTY exposes no
|
|
# foreground process group to read an argv from. Truncated (detection
|
|
# only reads the front) and percent-encoded so the payload can't carry
|
|
# a raw `;`, ESC or BEL into the OSC framing. The cut can split a
|
|
# surrogate pair, and a lone high surrogate makes EscapeDataString
|
|
# throw on .NET Framework (PS 5.1) — inside this wrapper that would
|
|
# swallow the submitted line, so drop it and keep the whole mark
|
|
# best-effort: a plain `C` still flips the prompt state.
|
|
$cmd = if ($line.Length -gt 512) { $line.Substring(0, 512) } else { $line }
|
|
if ([char]::IsHighSurrogate($cmd[$cmd.Length - 1])) {
|
|
$cmd = $cmd.Substring(0, $cmd.Length - 1)
|
|
}
|
|
try {
|
|
$cmd = [Uri]::EscapeDataString($cmd)
|
|
Write-Host -NoNewline "$($global:__Tty7Esc)]133;C;$cmd$($global:__Tty7Bel)"
|
|
} catch {
|
|
Write-Host -NoNewline "$($global:__Tty7Esc)]133;C$($global:__Tty7Bel)"
|
|
}
|
|
}
|
|
$line
|
|
}
|
|
}
|
|
}
|
|
# --- end tty7 shell integration ---
|
|
"#;
|
|
|
|
/// The redirector files written into our throwaway `ZDOTDIR`. zsh reads its
|
|
/// startup files from `$ZDOTDIR`, so for zsh to reach all four of ours we must
|
|
/// keep `ZDOTDIR` pointing at *our* dir at every hand-off between files. But
|
|
/// while each redirector actually *sources the user's real file* — and once the
|
|
/// live session begins — `ZDOTDIR` has to point at the user's real config dir
|
|
/// instead: a whole ecosystem of zsh tooling (Zim, oh-my-zsh, `compinit`'s
|
|
/// `.zcompdump`) locates its own state via `${ZDOTDIR:-$HOME}`, and if that
|
|
/// resolved to our *empty* throwaway dir it would reinstall / rebuild from
|
|
/// scratch on every new pane — the 3-second stall and Zim "Installed" spam of
|
|
/// issue #15. So each redirector swaps `ZDOTDIR` to the real dir around the
|
|
/// `source`, then swaps our dir back so zsh still reaches the next redirector;
|
|
/// the integration body ([`ZSH_INTEGRATION`]) restores the real dir for good
|
|
/// once every startup file has run.
|
|
///
|
|
/// The source is done at top level (never wrapped in a function) so the user's
|
|
/// config keeps its normal global scope.
|
|
fn zsh_redirectors() -> [(&'static str, String); 4] {
|
|
// Run the user's file of the same name with ZDOTDIR aimed at their *real*
|
|
// config dir, then restore ours so zsh reads the next redirector. The real
|
|
// dir is `TTY7_USER_ZDOTDIR`, captured into the env before launch; when it's
|
|
// absent we *unset* ZDOTDIR (not fall back to $HOME) so the file sees exactly
|
|
// what a real launch gives it — an unset ZDOTDIR — and the classic relocate
|
|
// idiom `: ${ZDOTDIR:=~/.config/zsh}` still fires. `tail` runs after the
|
|
// source but before the restore.
|
|
let redirect = |name: &str, tail: &str| {
|
|
format!(
|
|
"__tty7_ztmp=$ZDOTDIR\n\
|
|
if [[ -n \"$TTY7_USER_ZDOTDIR\" ]]; then ZDOTDIR=$TTY7_USER_ZDOTDIR; else unset ZDOTDIR; fi\n\
|
|
[[ -f \"${{ZDOTDIR:-$HOME}}/{name}\" ]] && source \"${{ZDOTDIR:-$HOME}}/{name}\"\n\
|
|
{tail}ZDOTDIR=$__tty7_ztmp\n\
|
|
unset __tty7_ztmp\n"
|
|
)
|
|
};
|
|
[
|
|
// The user's own .zshenv may itself relocate ZDOTDIR — the classic tiny
|
|
// `~/.zshenv` that does `ZDOTDIR=~/.config/zsh`. Capture wherever it points
|
|
// *after* sourcing as the real dir for the later redirectors (and nested
|
|
// tty7); otherwise they'd look under $HOME and miss the user's real config.
|
|
(
|
|
".zshenv",
|
|
redirect(".zshenv", "export TTY7_USER_ZDOTDIR=${ZDOTDIR:-$HOME}\n"),
|
|
),
|
|
(".zprofile", redirect(".zprofile", "")),
|
|
// Our integration is appended *after* the user's .zshrc (and after ZDOTDIR
|
|
// is restored to ours) so it extends — not gets clobbered by — the user's
|
|
// PROMPT / hooks.
|
|
(
|
|
".zshrc",
|
|
format!("{}{ZSH_INTEGRATION}", redirect(".zshrc", "")),
|
|
),
|
|
(".zlogin", redirect(".zlogin", "")),
|
|
]
|
|
}
|
|
|
|
/// Environment overrides + spawn adjustments produced by `setup`.
|
|
pub struct Injection {
|
|
/// Env vars to add to the child shell's environment.
|
|
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. When [`replaces_argv`](Self::replaces_argv)
|
|
/// is set these are the *whole* argv, not an addition to it.
|
|
pub args: Vec<String>,
|
|
/// 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.
|
|
pub dir: Option<PathBuf>,
|
|
}
|
|
|
|
/// Prefix of the throwaway redirector dirs we create under the temp dir (see
|
|
/// `setup`). Used to recognize *our own* `ZDOTDIR` when it's inherited.
|
|
const ZDOTDIR_PREFIX: &str = "tty7-zdotdir-";
|
|
|
|
/// True if `path` is one of our own redirector dirs (by basename). When tty7 is
|
|
/// launched from inside a tty7 shell, the inherited `ZDOTDIR` already points at
|
|
/// such a dir — chaining to it would source a `.zshrc` that doesn't hold the
|
|
/// user's real config, dropping their dotfiles (oh-my-zsh, aliases, prompt).
|
|
fn is_our_zdotdir(path: &str) -> bool {
|
|
Path::new(path)
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with(ZDOTDIR_PREFIX))
|
|
}
|
|
|
|
/// Resolve the user's *real* ZDOTDIR for the redirectors to source from,
|
|
/// surviving nested tty7 launches:
|
|
/// 1. An outer tty7 may have already exported `TTY7_USER_ZDOTDIR` (the real
|
|
/// one it resolved) — trust it, keeping the chain anchored to the user.
|
|
/// 2. Otherwise use the inherited `ZDOTDIR`, but only if it isn't one of *our*
|
|
/// throwaway dirs (which would have no user dotfiles).
|
|
/// 3. Otherwise `None` → the redirectors fall back to `$HOME`, as zsh would.
|
|
fn real_user_zdotdir() -> Option<String> {
|
|
if let Ok(z) = std::env::var("TTY7_USER_ZDOTDIR") {
|
|
if !z.is_empty() {
|
|
return Some(z);
|
|
}
|
|
}
|
|
std::env::var("ZDOTDIR")
|
|
.ok()
|
|
.filter(|z| !z.is_empty() && !is_our_zdotdir(z))
|
|
}
|
|
|
|
/// Detected interactive shell kind, resolved from the program tty7 is actually
|
|
/// about to spawn (falling back to `$SHELL` when the caller doesn't know it,
|
|
/// e.g. because it'll be resolved from the passwd database at spawn time).
|
|
enum ShellKind {
|
|
Zsh,
|
|
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> {
|
|
let owned = match program {
|
|
Some(p) => p.to_string(),
|
|
None => std::env::var("SHELL").ok()?,
|
|
};
|
|
// Lowercase the basename and drop any `.exe`: Windows program names are
|
|
// case-insensitive and carry the suffix, so `PowerShell.exe` and `pwsh`
|
|
// must both match — as must Git Bash, which tty7 launches by its absolute
|
|
// `...\Git\bin\bash.exe` path (`core::shells::find_git_bash`). The Unix
|
|
// shells are conventionally lowercase and suffix-free already, so this only
|
|
// ever normalizes the Windows spellings.
|
|
let base = Path::new(&owned)
|
|
.file_name()?
|
|
.to_str()?
|
|
.to_ascii_lowercase();
|
|
match base.strip_suffix(".exe").unwrap_or(&base) {
|
|
"zsh" => Some(ShellKind::Zsh),
|
|
"bash" if cfg!(windows) && !is_msys_bash(&owned) => None,
|
|
"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
|
|
/// `Git\bin` on PATH.
|
|
///
|
|
/// The asymmetry is why this fails closed: getting it wrong in the WSL
|
|
/// direction is destructive, because `--rcfile` *replaces* `~/.bashrc` rather
|
|
/// than supplementing it and the path we pass does not exist inside the distro
|
|
/// — the user silently loses aliases, prompt, and PATH. Getting it wrong in the
|
|
/// Git Bash direction merely costs them shell integration, which they did not
|
|
/// have before it was implemented. So anything not positively identifiable as
|
|
/// msys is declined, including a bare `bash` / `bash.exe` whose PATH lookup we
|
|
/// cannot predict.
|
|
///
|
|
/// Always `true` off Windows: `cfg!(windows)` gates the only call site, and
|
|
/// keeping the body platform-neutral lets the tests run everywhere.
|
|
fn is_msys_bash(program: &str) -> bool {
|
|
if !cfg!(windows) {
|
|
return true;
|
|
}
|
|
let normalized = program.replace('/', "\\").to_ascii_lowercase();
|
|
let Some((dir, _)) = normalized.rsplit_once('\\') else {
|
|
return false; // bare name — resolved through PATH at spawn time
|
|
};
|
|
let system_root = std::env::var("SystemRoot")
|
|
.unwrap_or_else(|_| r"C:\Windows".to_string())
|
|
.replace('/', "\\")
|
|
.to_ascii_lowercase();
|
|
let system_root = system_root.trim_end_matches('\\');
|
|
!(dir == system_root || dir.starts_with(&format!("{system_root}\\")))
|
|
}
|
|
|
|
/// A unique throwaway dir under the OS temp dir, prefixed for later
|
|
/// recognition (see `is_our_zdotdir`), one *per pane*. We avoid Date/random by
|
|
/// combining the process id with a monotonic counter: the daemon is one
|
|
/// long-lived process that spawns many panes, so keying on pid alone would
|
|
/// have every pane share a single dir, and the first pane's cleanup (removed
|
|
/// on drop) would yank the integration files out from under all the others
|
|
/// still running.
|
|
fn throwaway_dir(prefix: &str) -> Option<PathBuf> {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let mut dir = std::env::temp_dir();
|
|
dir.push(format!("{prefix}{}-{seq}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).ok()?;
|
|
Some(dir)
|
|
}
|
|
|
|
fn setup_zsh() -> Option<Injection> {
|
|
let dir = throwaway_dir(ZDOTDIR_PREFIX)?;
|
|
for (name, contents) in zsh_redirectors() {
|
|
std::fs::write(dir.join(name), contents).ok()?;
|
|
}
|
|
|
|
let mut env = HashMap::new();
|
|
// Preserve the user's real ZDOTDIR so our redirectors can source from it;
|
|
// when unset they fall back to $HOME, as zsh itself would. Crucially this
|
|
// resolves correctly under *nested* tty7 (launching tty7 from a tty7 shell):
|
|
// the inherited ZDOTDIR there points at an outer redirector dir of ours, not
|
|
// the user's config — `real_user_zdotdir` sees through that. We always
|
|
// (re)export it so deeper nesting stays anchored to the same real dir.
|
|
if let Some(user_zdotdir) = real_user_zdotdir() {
|
|
env.insert("TTY7_USER_ZDOTDIR".to_string(), user_zdotdir);
|
|
}
|
|
env.insert("ZDOTDIR".to_string(), dir.to_string_lossy().into_owned());
|
|
|
|
Some(Injection {
|
|
env,
|
|
args: Vec::new(),
|
|
replaces_argv: false,
|
|
dir: Some(dir),
|
|
})
|
|
}
|
|
|
|
/// fish reads `-C`/`--init-command` after its own (untouched) `config.fish`, so
|
|
/// there's nothing to write to disk or redirect — the whole body is just an
|
|
/// extra argv entry.
|
|
fn setup_fish() -> Option<Injection> {
|
|
Some(Injection {
|
|
env: HashMap::new(),
|
|
args: vec!["-C".to_string(), FISH_INTEGRATION.to_string()],
|
|
replaces_argv: false,
|
|
dir: None,
|
|
})
|
|
}
|
|
|
|
/// PowerShell reads `-EncodedCommand` after loading its own profiles, so — like
|
|
/// fish — the whole body is just extra argv, with nothing on disk. We pass it
|
|
/// base64-encoded rather than as a plain `-Command` string so an arbitrary
|
|
/// script (quotes, `$`, newlines) survives the Windows command line intact, and
|
|
/// because an encoded command isn't subject to the script-file execution policy
|
|
/// that would otherwise block a dot-sourced `.ps1` on a stock Windows install.
|
|
/// `-NoLogo` drops the startup banner; `-NoExit` keeps the session interactive
|
|
/// after the command runs.
|
|
fn setup_powershell() -> Option<Injection> {
|
|
Some(Injection {
|
|
env: HashMap::new(),
|
|
args: vec![
|
|
"-NoLogo".to_string(),
|
|
"-NoExit".to_string(),
|
|
"-EncodedCommand".to_string(),
|
|
powershell_encoded_command(POWERSHELL_INTEGRATION),
|
|
],
|
|
replaces_argv: false,
|
|
dir: None,
|
|
})
|
|
}
|
|
|
|
/// Encode a PowerShell script for `-EncodedCommand`, which expects base64 of the
|
|
/// command's UTF-16LE bytes. Hand-rolled (both steps) rather than pulling in a
|
|
/// base64 crate for this single call site.
|
|
fn powershell_encoded_command(script: &str) -> String {
|
|
let utf16le: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
|
base64_encode(&utf16le)
|
|
}
|
|
|
|
/// Standard base64 (RFC 4648) with `=` padding.
|
|
fn base64_encode(input: &[u8]) -> String {
|
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
|
for chunk in input.chunks(3) {
|
|
let b0 = chunk[0] as u32;
|
|
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
|
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
|
let n = (b0 << 16) | (b1 << 8) | b2;
|
|
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
|
|
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
|
|
out.push(if chunk.len() > 1 {
|
|
ALPHABET[((n >> 6) & 0x3f) as usize] as char
|
|
} else {
|
|
'='
|
|
});
|
|
out.push(if chunk.len() > 2 {
|
|
ALPHABET[(n & 0x3f) as usize] as char
|
|
} else {
|
|
'='
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Bash rcfile content: replay the login-shell startup-file chain (since we're
|
|
/// about to force a non-login spawn so `--rcfile` takes effect at all — see the
|
|
/// module docs), then append the integration body.
|
|
fn bash_rcfile() -> String {
|
|
format!(
|
|
r#"
|
|
# Replays what a real *login* shell would have sourced, in the same order —
|
|
# necessary because tty7 spawns bash non-login (see shell_integration.rs) so
|
|
# that --rcfile is honored at all; bash silently ignores it for login shells.
|
|
if [[ -f /etc/profile ]]; then source /etc/profile; fi
|
|
if [[ -f ~/.bash_profile ]]; then
|
|
source ~/.bash_profile
|
|
elif [[ -f ~/.bash_login ]]; then
|
|
source ~/.bash_login
|
|
elif [[ -f ~/.profile ]]; then
|
|
source ~/.profile
|
|
fi
|
|
if [[ -f ~/.bashrc ]]; then source ~/.bashrc; fi
|
|
{BASH_INTEGRATION}"#
|
|
)
|
|
}
|
|
|
|
/// Render a path for bash's own consumption. The only bash reachable on
|
|
/// Windows is the msys2 one Git for Windows ships: its runtime does accept a
|
|
/// native `C:\...` path, but the backslashes then survive into any bash string
|
|
/// context that path is later interpolated into (`$BASH_SOURCE`, an error
|
|
/// message re-evaluated by a user's `PROMPT_COMMAND`) where they *are* escape
|
|
/// characters. Forward slashes are accepted just as readily by the msys
|
|
/// runtime and carry no such second meaning, so normalize to them. No-op on
|
|
/// Unix, where the separator is already `/`.
|
|
fn bash_path(path: &Path) -> String {
|
|
let s = path.to_string_lossy().into_owned();
|
|
if cfg!(windows) {
|
|
s.replace('\\', "/")
|
|
} else {
|
|
s
|
|
}
|
|
}
|
|
|
|
/// Force a non-login bash with our rcfile, replaying the login-file chain
|
|
/// ourselves inside it (see [`bash_rcfile`]) since `--rcfile` only takes effect
|
|
/// on non-login shells in the first place. Only offered when the caller has no
|
|
/// user-configured custom args to preserve (`setup`'s `has_custom_args`) — we
|
|
/// can't safely guess how `--rcfile <path> -i` should combine with arbitrary
|
|
/// user-supplied bash args.
|
|
fn setup_bash() -> Option<Injection> {
|
|
let dir = throwaway_dir("tty7-bashrc-")?;
|
|
let rcfile = dir.join("bashrc");
|
|
std::fs::write(&rcfile, bash_rcfile()).ok()?;
|
|
|
|
Some(Injection {
|
|
env: HashMap::new(),
|
|
// `--rcfile` (a GNU long option) must precede `-i`: bash 3.2 — still
|
|
// 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()],
|
|
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
|
|
/// passwd database) — passing it, rather than relying on `$SHELL`, is what
|
|
/// makes detection correct when they disagree. `has_custom_args` should be
|
|
/// `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).
|
|
#[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(),
|
|
ShellKind::Bash if !has_custom_args => setup_bash(),
|
|
ShellKind::Bash => None,
|
|
// PowerShell's `-EncodedCommand` is mutually exclusive with a
|
|
// user-supplied `-Command`/`-File`, so — like bash — don't second-guess
|
|
// 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
|
|
// integration body sets e.g. `TTY7_SHELL_INTEGRATION=1` and *exports* it, so
|
|
// it leaks to every child process — including a tty7 launched from inside a
|
|
// tty7 shell, and (crucially) the persistent daemon, which inherits it and
|
|
// would otherwise hand it to every shell it spawns. Since the PTY child
|
|
// inherits our process env, that stale `1` makes the guard skip the install
|
|
// → no OSC 133 → no inline line editor. Every shell tty7 spawns is a
|
|
// fresh top-level interactive shell that *should* install the hooks, so we
|
|
// blank the sentinel at this spawn boundary (empty still satisfies the
|
|
// guard's emptiness check); the body re-exports `1` for that shell's own
|
|
// descendants.
|
|
injection
|
|
.env
|
|
.insert("TTY7_SHELL_INTEGRATION".to_string(), String::new());
|
|
|
|
Some(injection)
|
|
}
|
|
|
|
/// Reaching a shell on *another machine* — the native-SSH panes (`daemon::ssh`).
|
|
///
|
|
/// Everything above this point injects by arranging a *local* process spawn: we
|
|
/// write files into a throwaway dir and hand the child an env var or an argv
|
|
/// entry pointing at them. None of that is available over SSH, where the only
|
|
/// lever is the one string the `exec` channel request carries — sshd runs it as
|
|
/// `$SHELL -c <string>` and that is the whole interface.
|
|
///
|
|
/// So the remote path inverts the mechanism: instead of *configuring* a spawn,
|
|
/// we send a short script that **materializes the same files on the remote side
|
|
/// and then `exec`s the real shell through them**. The shell that comes out the
|
|
/// other end is configured exactly as a local one — same `ZDOTDIR` redirector
|
|
/// chain, same bash rcfile replaying the login-file chain, same fish `-C` — so
|
|
/// the integration bodies above are reused verbatim rather than forked.
|
|
///
|
|
/// **Why probe first.** The bootstrap script cannot be shell-agnostic: sshd
|
|
/// hands it to the user's *login* shell, so a POSIX script is parsed by fish (a
|
|
/// syntax error) and a fish script by zsh. Warp solves this with a single
|
|
/// expression contorted to parse identically in sh/bash/zsh/fish; we instead
|
|
/// spend one cheap round-trip on [`PROBE_COMMAND`] — deliberately written to be
|
|
/// valid in all of them because it contains no substitution, no assignment and
|
|
/// no grouping — and then emit a script in the dialect we now know we're
|
|
/// talking to. The result reads like ordinary shell code instead of a puzzle,
|
|
/// and it also tells us when to keep our hands off entirely: a remote whose
|
|
/// login shell isn't one of the three (or isn't POSIX at all — a Windows
|
|
/// `cmd.exe`, where the probe echoes a literal `$SHELL`) parses as `None` and
|
|
/// the caller falls back to a plain shell request. That negative answer is the
|
|
/// load-bearing half: it is what keeps a non-Unix remote from being handed a
|
|
/// script it would choke on, and it's why the probe exists rather than us just
|
|
/// sending a bootstrap and hoping.
|
|
///
|
|
/// The probe's cost is paid once per *connection*, not once per pane — the
|
|
/// caller caches it on the connection registry key, so extra tabs to a host
|
|
/// already open cost nothing.
|
|
pub mod remote {
|
|
use super::{FISH_INTEGRATION, bash_rcfile, zsh_redirectors};
|
|
|
|
/// Asks the remote for the login shell it would have started.
|
|
///
|
|
/// Runs under whatever that shell is, so it is restricted to the
|
|
/// intersection of sh/bash/zsh/fish/csh syntax: two `echo`s and a `;`.
|
|
/// Notably absent is any command substitution — fish only learned `$(…)` in
|
|
/// 3.4 and csh never had it — and any assignment, which fish spells
|
|
/// differently from everyone else.
|
|
///
|
|
/// The marker line is what makes the answer parseable rather than guessed:
|
|
/// a remote `.zshenv`/`config.fish` that prints something of its own (banner,
|
|
/// version-manager chatter) is ignored, because we only read the line that
|
|
/// follows the marker. See [`parse_probe`].
|
|
pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL";
|
|
|
|
/// The line [`PROBE_COMMAND`] prints immediately before the shell path.
|
|
const PROBE_MARKER: &str = "__tty7_shell";
|
|
|
|
/// Heredoc delimiter for the rc files the bootstrap writes remotely. Quoted
|
|
/// at the use site (`<<'…'`) so the bodies are copied *literally* — they are
|
|
/// full of `$`, backticks and backslashes that must reach the file intact.
|
|
const HEREDOC: &str = "__TTY7_RC_EOF__";
|
|
|
|
/// A remote login shell we know how to integrate.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum RemoteShell {
|
|
Zsh,
|
|
Bash,
|
|
Fish,
|
|
}
|
|
|
|
impl RemoteShell {
|
|
fn from_path(path: &str) -> Option<Self> {
|
|
match path.rsplit('/').next()? {
|
|
"zsh" => Some(Self::Zsh),
|
|
"bash" => Some(Self::Bash),
|
|
"fish" => Some(Self::Fish),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read [`PROBE_COMMAND`]'s output: the first non-empty line after the
|
|
/// marker is the remote's `$SHELL`.
|
|
///
|
|
/// Returns `None` — meaning "launch a plain shell, inject nothing" — unless
|
|
/// that line is an absolute path to a shell we support. The absolute-path
|
|
/// requirement is what rejects a non-POSIX remote: `cmd.exe` echoes the
|
|
/// string `$SHELL` back unexpanded, and PowerShell prints an empty line, so
|
|
/// neither can be mistaken for an answer.
|
|
pub fn parse_probe(output: &str) -> Option<(RemoteShell, String)> {
|
|
let mut lines = output
|
|
.lines()
|
|
.map(|l| l.trim_end_matches('\r').trim())
|
|
.skip_while(|l| *l != PROBE_MARKER);
|
|
lines.next()?; // the marker itself
|
|
let path = lines.find(|l| !l.is_empty())?;
|
|
if !path.starts_with('/') {
|
|
return None;
|
|
}
|
|
RemoteShell::from_path(path).map(|shell| (shell, path.to_string()))
|
|
}
|
|
|
|
/// The script to send as the channel's `exec` request: it sets the remote up
|
|
/// for integration and `exec`s `shell_path` as the session's shell.
|
|
///
|
|
/// Every arm ends in an `exec` of the user's own shell, and every arm
|
|
/// reaches that `exec` even if its setup failed — a remote with a full or
|
|
/// read-only `$TMPDIR` loses the integration, never the session.
|
|
pub fn bootstrap_command(shell: RemoteShell, shell_path: &str) -> String {
|
|
match shell {
|
|
RemoteShell::Zsh => zsh_bootstrap(shell_path),
|
|
RemoteShell::Bash => bash_bootstrap(shell_path),
|
|
RemoteShell::Fish => fish_bootstrap(shell_path),
|
|
}
|
|
}
|
|
|
|
/// Quote for a POSIX-family shell (zsh/bash): wrap in single quotes and
|
|
/// spell an embedded quote the only way single-quoting allows.
|
|
fn shell_quote(s: &str) -> String {
|
|
format!("'{}'", s.replace('\'', r"'\''"))
|
|
}
|
|
|
|
/// Quote for fish, whose single quotes — unlike POSIX's fully literal ones —
|
|
/// honour exactly two escapes, `\\` and `\'`. Both need doubling up, and
|
|
/// nothing else may be touched: the fish body is dense with `\e`, `\a` and
|
|
/// `$argv`, all of which must survive verbatim.
|
|
fn fish_quote(s: &str) -> String {
|
|
format!("'{}'", s.replace('\\', r"\\").replace('\'', r"\'"))
|
|
}
|
|
|
|
/// `command cat > <dir>/<name> <<'EOF' … EOF`, the remote-side equivalent of
|
|
/// the local `std::fs::write`.
|
|
///
|
|
/// `command` bypasses any alias or function the remote's own startup files
|
|
/// have put in the way — sshd's `$SHELL -c` still reads `.zshenv`, so we are
|
|
/// not running in a pristine environment.
|
|
fn write_file(out: &mut String, name: &str, body: &str) {
|
|
out.push_str(&format!(
|
|
"command cat > \"$__tty7_d/{name}\" <<'{HEREDOC}'\n{}\n{HEREDOC}\n",
|
|
body.trim_end_matches('\n')
|
|
));
|
|
}
|
|
|
|
/// Recreate the local `ZDOTDIR` redirector dir on the remote, point `ZDOTDIR`
|
|
/// at it, and exec zsh as a login shell — the same shape as [`setup_zsh`],
|
|
/// with the throwaway dir built by a script instead of by `std::fs`.
|
|
///
|
|
/// `ZDOTDIR` is only exported once every redirector is confirmed written:
|
|
/// pointing zsh at a half-populated dir would silently cost the user their
|
|
/// dotfiles, which is far worse than not integrating at all.
|
|
///
|
|
/// [`setup_zsh`]: super::setup_zsh
|
|
fn zsh_bootstrap(shell_path: &str) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("__tty7_d=${TMPDIR:-/tmp}/tty7-zdotdir-$$\n");
|
|
out.push_str("command mkdir -p \"$__tty7_d\" 2>/dev/null\n");
|
|
|
|
let mut guard = String::new();
|
|
for (name, contents) in zsh_redirectors() {
|
|
// The cleanup hook rides along in .zshrc, after the integration body
|
|
// — see ZSH_CLEANUP_HOOK for why it can't be a plain `rm` here.
|
|
let body = if name == ".zshrc" {
|
|
format!("{contents}{ZSH_CLEANUP_HOOK}")
|
|
} else {
|
|
contents
|
|
};
|
|
write_file(&mut out, name, &body);
|
|
guard.push_str(&format!("[ -s \"$__tty7_d/{name}\" ] && "));
|
|
}
|
|
out.push_str(&format!(
|
|
"{guard}export ZDOTDIR=\"$__tty7_d\" TTY7_RM_DIR=\"$__tty7_d\"\n"
|
|
));
|
|
out.push_str(&format!("exec {} -l\n", shell_quote(shell_path)));
|
|
out
|
|
}
|
|
|
|
/// Write the rcfile and exec bash *non-login* through it, exactly as
|
|
/// [`setup_bash`] does locally and for the same reason: `--rcfile` is
|
|
/// silently ignored for a login shell, so the rcfile replays the login-file
|
|
/// chain itself.
|
|
///
|
|
/// [`setup_bash`]: super::setup_bash
|
|
fn bash_bootstrap(shell_path: &str) -> String {
|
|
let quoted = shell_quote(shell_path);
|
|
let mut out = String::new();
|
|
out.push_str("__tty7_d=${TMPDIR:-/tmp}/tty7-bashrc-$$\n");
|
|
out.push_str("command mkdir -p \"$__tty7_d\" 2>/dev/null\n");
|
|
write_file(
|
|
&mut out,
|
|
"bashrc",
|
|
&format!("{}{BASH_CLEANUP_HOOK}", bash_rcfile()),
|
|
);
|
|
out.push_str("if [ -s \"$__tty7_d/bashrc\" ]; then\n");
|
|
out.push_str("export TTY7_RM_DIR=\"$__tty7_d\"\n");
|
|
out.push_str(&format!("exec {quoted} --rcfile \"$__tty7_d/bashrc\" -i\n"));
|
|
out.push_str("fi\n");
|
|
out.push_str(&format!("exec {quoted} -l\n"));
|
|
out
|
|
}
|
|
|
|
/// fish needs nothing on disk at either end: `-C` runs the body after the
|
|
/// user's own `config.fish`, so the whole bootstrap is one `exec`.
|
|
fn fish_bootstrap(shell_path: &str) -> String {
|
|
format!(
|
|
"exec {} -C {} -l\n",
|
|
fish_quote(shell_path),
|
|
fish_quote(FISH_INTEGRATION)
|
|
)
|
|
}
|
|
|
|
/// Remove the throwaway rc dir once the shell has finished reading it.
|
|
///
|
|
/// Unlike a local pane — where the terminal owns the dir and deletes it on
|
|
/// drop — nothing on the tty7 side can reach the remote filesystem, so the
|
|
/// shell has to clean up after itself. The deletion can't happen in the
|
|
/// bootstrap script (zsh hasn't read the files yet) nor at the end of the
|
|
/// rc file (zsh still has `.zlogin` to read), so it hangs off the first
|
|
/// `precmd`: by the time a prompt is drawn every startup file has been read,
|
|
/// and unlinking them is invisible to the running shell.
|
|
///
|
|
/// The path arrives in an exported `TTY7_RM_DIR` because a quoted heredoc
|
|
/// copies its body literally — there is no interpolation to splice a Rust
|
|
/// value into. It's immediately demoted to a plain shell variable so it
|
|
/// doesn't leak into every child process.
|
|
///
|
|
/// The hook doesn't unregister itself, unlike its neighbours: re-running a
|
|
/// two-line no-op each prompt is cheaper than the array surgery removing it
|
|
/// would cost.
|
|
const ZSH_CLEANUP_HOOK: &str = r#"
|
|
# --- tty7 remote cleanup (zsh) ---
|
|
if [[ -n "$TTY7_RM_DIR" ]]; then
|
|
typeset -g __tty7_rm_dir=$TTY7_RM_DIR
|
|
unset TTY7_RM_DIR
|
|
__tty7_rm_rcdir() {
|
|
[[ -n "$__tty7_rm_dir" ]] || return 0
|
|
command rm -rf -- "$__tty7_rm_dir"
|
|
unset __tty7_rm_dir
|
|
}
|
|
autoload -Uz add-zsh-hook
|
|
add-zsh-hook precmd __tty7_rm_rcdir
|
|
fi
|
|
# --- end tty7 remote cleanup ---
|
|
"#;
|
|
|
|
/// The bash counterpart of [`ZSH_CLEANUP_HOOK`], registered through the
|
|
/// `precmd_functions` array that the integration body's vendored
|
|
/// bash-preexec drives.
|
|
///
|
|
/// This sits *outside* that body's install guard, so on a remote that
|
|
/// already has tty7 integration in its own `.bashrc` the guard skips the
|
|
/// install and no `precmd_functions` ever runs — the dir then outlives the
|
|
/// session. That is the one case we let leak: a few KB under `/tmp` on a
|
|
/// host the user has explicitly set up, versus the alternative of an `EXIT`
|
|
/// trap that would clobber whatever trap their dotfiles installed.
|
|
const BASH_CLEANUP_HOOK: &str = r#"
|
|
# --- tty7 remote cleanup (bash) ---
|
|
if [[ -n "$TTY7_RM_DIR" ]]; then
|
|
__tty7_rm_dir=$TTY7_RM_DIR
|
|
unset TTY7_RM_DIR
|
|
__tty7_rm_rcdir() {
|
|
[[ -n "$__tty7_rm_dir" ]] || return 0
|
|
command rm -rf -- "$__tty7_rm_dir"
|
|
unset __tty7_rm_dir
|
|
}
|
|
precmd_functions+=(__tty7_rm_rcdir)
|
|
fi
|
|
# --- end tty7 remote cleanup ---
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::{BASH_INTEGRATION, ZSH_INTEGRATION};
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn probe_reads_the_line_after_the_marker() {
|
|
assert_eq!(
|
|
parse_probe("__tty7_shell\n/bin/zsh\n"),
|
|
Some((RemoteShell::Zsh, "/bin/zsh".to_string()))
|
|
);
|
|
// Startup chatter ahead of the marker is ignored — a remote
|
|
// `.zshenv` that echoes a banner must not be read as the answer.
|
|
assert_eq!(
|
|
parse_probe("Welcome to prod!\n__tty7_shell\n/usr/local/bin/fish\n"),
|
|
Some((RemoteShell::Fish, "/usr/local/bin/fish".to_string()))
|
|
);
|
|
assert_eq!(
|
|
parse_probe("__tty7_shell\r\n/bin/bash\r\n"),
|
|
Some((RemoteShell::Bash, "/bin/bash".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_declines_anything_that_isnt_a_shell_we_know() {
|
|
// cmd.exe echoes the variable back unexpanded; PowerShell prints
|
|
// nothing. Neither may be mistaken for a POSIX remote.
|
|
assert_eq!(parse_probe("__tty7_shell\n$SHELL\n"), None);
|
|
assert_eq!(parse_probe("__tty7_shell\n\n"), None);
|
|
// A shell we have no integration body for.
|
|
assert_eq!(parse_probe("__tty7_shell\n/bin/ksh\n"), None);
|
|
// No marker at all: the command never ran as intended.
|
|
assert_eq!(parse_probe("/bin/zsh\n"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn zsh_bootstrap_gates_zdotdir_on_every_redirector_landing() {
|
|
let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh");
|
|
// Pointing zsh at a partially-written dir would drop the user's
|
|
// dotfiles, so all four files are checked before ZDOTDIR is set.
|
|
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
|
|
assert!(
|
|
script.contains(&format!("[ -s \"$__tty7_d/{name}\" ] &&")),
|
|
"missing landing check for {name}"
|
|
);
|
|
}
|
|
let export = script.find("export ZDOTDIR=").expect("exports ZDOTDIR");
|
|
let exec = script.find("exec '/bin/zsh' -l").expect("execs zsh");
|
|
assert!(export < exec);
|
|
// The integration body must actually reach the remote .zshrc.
|
|
assert!(script.contains("__tty7_report_cwd"));
|
|
}
|
|
|
|
#[test]
|
|
fn file_writing_bootstraps_end_in_a_bare_exec_of_the_users_shell() {
|
|
// The last thing either script does is hand over an unintegrated
|
|
// shell, so a remote where the setup failed loses the integration
|
|
// and nothing else. (fish writes no files and so has no failure
|
|
// path to fall out of — it is a single `exec`, checked below.)
|
|
for (shell, path) in [
|
|
(RemoteShell::Zsh, "/bin/zsh"),
|
|
(RemoteShell::Bash, "/bin/bash"),
|
|
] {
|
|
let script = bootstrap_command(shell, path);
|
|
let last = script.trim_end().lines().last().unwrap();
|
|
assert_eq!(
|
|
last,
|
|
format!("exec '{path}' -l"),
|
|
"{shell:?} bootstrap must end by exec'ing {path} bare"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bash_bootstrap_forces_a_non_login_shell_through_the_rcfile() {
|
|
let script = bootstrap_command(RemoteShell::Bash, "/bin/bash");
|
|
// `--rcfile` is ignored for login shells, so the integrated arm must
|
|
// be `-i`, with the rcfile replaying the login chain itself.
|
|
assert!(script.contains("exec '/bin/bash' --rcfile \"$__tty7_d/bashrc\" -i"));
|
|
assert!(script.contains("source /etc/profile"));
|
|
}
|
|
|
|
#[test]
|
|
fn fish_bootstrap_is_one_exec_carrying_the_escaped_body() {
|
|
let script = bootstrap_command(RemoteShell::Fish, "/usr/bin/fish");
|
|
// The whole bootstrap is a single (multi-line) command: fish reads
|
|
// `-C` after its own config.fish, so there is nothing to write to
|
|
// disk and no failure path to fall out of.
|
|
assert!(script.starts_with("exec '/usr/bin/fish' -C '"));
|
|
assert!(script.trim_end().ends_with("' -l"));
|
|
assert!(!script.contains("mkdir"));
|
|
|
|
// fish single quotes honour exactly \\ and \', so both must be
|
|
// doubled up on the way in. The body's `printf '\e]%s\a' $argv[1]`
|
|
// therefore arrives with its quotes escaped *and* its backslashes
|
|
// doubled — get either wrong and fish sees a terminated string or
|
|
// an escape sequence instead of the literal text.
|
|
assert!(script.contains(r"printf \'\\e]%s\\a\' $argv[1]"));
|
|
}
|
|
|
|
#[test]
|
|
fn quoting_survives_paths_and_bodies_that_fight_back() {
|
|
assert_eq!(shell_quote("/o'dd/zsh"), r"'/o'\''dd/zsh'");
|
|
assert_eq!(fish_quote(r"a'b\c"), r"'a\'b\\c'");
|
|
}
|
|
|
|
#[test]
|
|
fn heredoc_delimiter_cannot_appear_in_a_body_it_delimits() {
|
|
// A body containing the delimiter on its own line would end the
|
|
// heredoc early and spill shell code into the script.
|
|
for body in [ZSH_INTEGRATION, BASH_INTEGRATION, FISH_INTEGRATION] {
|
|
assert!(!body.contains(HEREDOC));
|
|
}
|
|
assert!(!bash_rcfile().contains(HEREDOC));
|
|
}
|
|
|
|
/// Parse `script` with the real shell, without running it. Returns
|
|
/// `None` when that shell isn't installed here, which is a skip and not
|
|
/// a failure — these tests are a local safety net, not a CI dependency.
|
|
///
|
|
/// Unix-only, and not merely for convenience: on Windows a bare `bash`
|
|
/// resolves through `PATH` to `C:\Windows\System32\bash.exe` — the WSL
|
|
/// launcher, not a shell — which on a machine with no distro installed
|
|
/// exits non-zero with an empty stderr and is indistinguishable from a
|
|
/// rejected script. (`is_msys_bash` above exists for the same trap on
|
|
/// the production path.) Nothing is lost by skipping: these scripts are
|
|
/// destined for a remote POSIX host, so their syntax has nothing to do
|
|
/// with the platform running the test, and the Unix CI jobs cover them.
|
|
#[cfg(unix)]
|
|
fn parse_check(
|
|
shell: &str,
|
|
syntax_only_flag: &str,
|
|
script: &str,
|
|
) -> Option<(bool, String)> {
|
|
use std::io::Write as _;
|
|
use std::process::{Command, Stdio};
|
|
|
|
let mut child = Command::new(shell)
|
|
.arg(syntax_only_flag)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.ok()?;
|
|
child
|
|
.stdin
|
|
.take()
|
|
.expect("piped stdin")
|
|
.write_all(script.as_bytes())
|
|
.expect("write script");
|
|
let out = child.wait_with_output().expect("wait for parse check");
|
|
Some((
|
|
out.status.success(),
|
|
String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
))
|
|
}
|
|
|
|
/// The bootstrap scripts, and the rc files they carry, must parse under
|
|
/// the shells they're written for.
|
|
///
|
|
/// This is worth a real subprocess where the local paths' unit tests
|
|
/// aren't, because a syntax error costs far more here: a local shell
|
|
/// with a broken rcfile still opens (bash just complains), but a remote
|
|
/// one takes the whole `exec` request down with it and the user gets a
|
|
/// session that dies on connect. Quoting is also doing much more work
|
|
/// on this path — a heredoc, two escaping dialects, and a body that
|
|
/// travels as an argv entry — so there is correspondingly more to break.
|
|
///
|
|
/// Skipped where the shell isn't installed; `-n` / `--no-execute` parse
|
|
/// without running anything, so this never spawns a shell session.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn bootstrap_scripts_parse_under_their_real_shells() {
|
|
let cases = [
|
|
(RemoteShell::Zsh, "zsh", "-n", "/bin/zsh"),
|
|
(RemoteShell::Bash, "bash", "-n", "/bin/bash"),
|
|
(RemoteShell::Fish, "fish", "--no-execute", "/usr/bin/fish"),
|
|
];
|
|
for (shell, bin, flag, path) in cases {
|
|
let script = bootstrap_command(shell, path);
|
|
if let Some((ok, stderr)) = parse_check(bin, flag, &script) {
|
|
assert!(ok, "{bin} rejected its bootstrap script:\n{stderr}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A quoted heredoc's body is data, so the check above never parses the
|
|
/// rc files it writes — they have to be fed to the shell separately.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn heredoc_bodies_parse_under_their_real_shells() {
|
|
for (name, contents) in zsh_redirectors() {
|
|
let body = if name == ".zshrc" {
|
|
format!("{contents}{ZSH_CLEANUP_HOOK}")
|
|
} else {
|
|
contents
|
|
};
|
|
if let Some((ok, stderr)) = parse_check("zsh", "-n", &body) {
|
|
assert!(ok, "zsh rejected the remote {name}:\n{stderr}");
|
|
}
|
|
}
|
|
let rcfile = format!("{}{BASH_CLEANUP_HOOK}", bash_rcfile());
|
|
if let Some((ok, stderr)) = parse_check("bash", "-n", &rcfile) {
|
|
assert!(ok, "bash rejected the remote rcfile:\n{stderr}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn edit_mode_detection_survives_rebound_escape_and_inputrc() {
|
|
// zsh: plugins like zsh-vi-mode rebind `^[` to their own widgets
|
|
// (`zvm_readkeys_handler`), so sniffing the Esc widget for
|
|
// `vi-cmd-mode` misses them. The `main` keymap link is durable: both
|
|
// plain `bindkey -v` and zsh-vi-mode link main to viins, and emacs
|
|
// mode links it to emacs (`bindkey -A viins main` vs `-A emacs main`).
|
|
assert!(
|
|
ZSH_INTEGRATION.contains("bindkey -lL main"),
|
|
"zsh edit-mode detection must key off the main keymap link"
|
|
);
|
|
assert!(ZSH_INTEGRATION.contains("viins"));
|
|
// bash: `[[ -o vi ]]` misses vi mode set only via ~/.inputrc
|
|
// (`set editing-mode vi` flips readline but not the shell option);
|
|
// `bind -v` reports readline's actual mode either way.
|
|
assert!(
|
|
BASH_INTEGRATION.contains("editing-mode vi"),
|
|
"bash edit-mode detection must read readline's mode via bind -v"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn is_our_zdotdir_matches_only_our_prefix() {
|
|
// A dir we created (basename carries the tty7 prefix) is recognized.
|
|
assert!(is_our_zdotdir("/tmp/tty7-zdotdir-1234-0"));
|
|
assert!(is_our_zdotdir("tty7-zdotdir-x"));
|
|
// The user's real dirs and unrelated paths are not ours.
|
|
assert!(!is_our_zdotdir("/home/alice/.config/zsh"));
|
|
assert!(!is_our_zdotdir("/tmp/other-zdotdir"));
|
|
assert!(!is_our_zdotdir(""));
|
|
// A component that only contains the prefix mid-name is not a match.
|
|
assert!(!is_our_zdotdir("/tmp/not-tty7-zdotdir-1"));
|
|
}
|
|
|
|
/// 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)]
|
|
fn prompt_cycle_over_pty(program: &str, injection: &Injection) -> String {
|
|
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
|
use std::io::{Read, Write};
|
|
|
|
let pty = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.expect("openpty");
|
|
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 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 these tests.
|
|
writer.write_all(b"false\n").expect("write");
|
|
writer.flush().expect("flush");
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let mut buf = [0u8; 4096];
|
|
while let Ok(n) = reader.read(&mut buf) {
|
|
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let mut out = Vec::new();
|
|
while std::time::Instant::now() < deadline {
|
|
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
|
|
Ok(chunk) => out.extend_from_slice(&chunk),
|
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
|
}
|
|
if String::from_utf8_lossy(&out).contains("133;D") {
|
|
break;
|
|
}
|
|
}
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
drop(pty.master);
|
|
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!(
|
|
text.contains(mark),
|
|
"Git Bash must report {mark}; got:\n{text}"
|
|
);
|
|
}
|
|
// cwd reporting rides along on the same hooks. Assert the *decoded*
|
|
// 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.
|
|
let cwd = reported_cwd(&text);
|
|
assert!(
|
|
cwd.exists(),
|
|
"Git Bash reported a cwd the Windows side cannot resolve: {cwd:?} \
|
|
— 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:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_kind_maps_known_basenames() {
|
|
assert!(matches!(shell_kind(Some("/bin/zsh")), Some(ShellKind::Zsh)));
|
|
assert!(matches!(shell_kind(Some("zsh")), Some(ShellKind::Zsh)));
|
|
assert!(matches!(
|
|
shell_kind(Some("/bin/bash")),
|
|
Some(ShellKind::Bash)
|
|
));
|
|
assert!(matches!(
|
|
shell_kind(Some("/usr/local/bin/fish")),
|
|
Some(ShellKind::Fish)
|
|
));
|
|
// PowerShell, in every spelling: bare and `.exe`, Windows PowerShell and
|
|
// pwsh 7+, and case-insensitively (Windows program names ignore case).
|
|
// Paths use `/` so `Path::file_name` splits them the same on every host;
|
|
// backslash separators are `std::path`'s job and only split on Windows.
|
|
for prog in [
|
|
"powershell.exe",
|
|
"powershell",
|
|
"pwsh",
|
|
"pwsh.exe",
|
|
"C:/Program Files/PowerShell/7/pwsh.exe",
|
|
"PowerShell.EXE",
|
|
] {
|
|
assert!(
|
|
matches!(shell_kind(Some(prog)), Some(ShellKind::PowerShell)),
|
|
"{prog} should map to PowerShell"
|
|
);
|
|
}
|
|
// 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());
|
|
// `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
|
|
/// stripped for *every* shell and not just PowerShell — otherwise the one
|
|
/// bash reachable on Windows silently gets no integration.
|
|
#[test]
|
|
fn shell_kind_strips_exe_for_non_powershell_shells() {
|
|
for prog in [
|
|
"C:/Program Files/Git/bin/bash.exe",
|
|
"C:/msys64/usr/bin/bash.exe",
|
|
] {
|
|
assert!(
|
|
matches!(shell_kind(Some(prog)), Some(ShellKind::Bash)),
|
|
"{prog} should map to Bash"
|
|
);
|
|
}
|
|
// Off Windows the guard is inert, so the bare spellings still resolve.
|
|
if !cfg!(windows) {
|
|
for prog in ["bash.exe", "BASH.EXE", "bash"] {
|
|
assert!(matches!(shell_kind(Some(prog)), Some(ShellKind::Bash)));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `C:\Windows\System32\bash.exe` is the WSL launcher, not a shell we can
|
|
/// inject into: `--rcfile` replaces `~/.bashrc` rather than adding to it,
|
|
/// and the Windows path we pass does not exist inside the distro, so the
|
|
/// user would silently lose their whole bash config. It also normally sits
|
|
/// ahead of `Git\bin` on PATH, which is why a bare name is declined too.
|
|
#[test]
|
|
#[cfg(windows)]
|
|
fn shell_kind_declines_the_wsl_bash_launcher() {
|
|
let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
|
|
for prog in [
|
|
format!(r"{system_root}\System32\bash.exe"),
|
|
format!(r"{system_root}/System32/bash.exe"),
|
|
format!(r"{system_root}\SysWOW64\bash.exe"),
|
|
format!(r"{system_root}\system32\BASH.EXE"),
|
|
] {
|
|
assert!(
|
|
shell_kind(Some(&prog)).is_none(),
|
|
"{prog} is the WSL launcher and must not be treated as Bash"
|
|
);
|
|
}
|
|
// A bare name resolves through PATH at spawn time, where System32
|
|
// usually wins — unpredictable, so fail closed.
|
|
for prog in ["bash", "bash.exe", "BASH.EXE"] {
|
|
assert!(
|
|
shell_kind(Some(prog)).is_none(),
|
|
"{prog} cannot be identified as msys bash and must be declined"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The rcfile path is handed to msys2 bash, which reads `\` as an escape in
|
|
/// the string contexts the path can later reach — see [`bash_path`].
|
|
#[test]
|
|
fn bash_rcfile_path_uses_forward_slashes_on_windows() {
|
|
let rendered = bash_path(Path::new(
|
|
r"C:\Users\a\AppData\Local\Temp\tty7-bashrc-1-0\bashrc",
|
|
));
|
|
if cfg!(windows) {
|
|
assert_eq!(
|
|
rendered,
|
|
"C:/Users/a/AppData/Local/Temp/tty7-bashrc-1-0/bashrc"
|
|
);
|
|
}
|
|
// Unix paths are already separator-correct and must pass through
|
|
// untouched on every host.
|
|
assert_eq!(
|
|
bash_path(Path::new("/tmp/tty7-bashrc-1-0/bashrc")),
|
|
"/tmp/tty7-bashrc-1-0/bashrc"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zsh_redirectors_source_user_files_and_append_integration() {
|
|
let files = zsh_redirectors();
|
|
assert_eq!(files.len(), 4);
|
|
let names: Vec<&str> = files.iter().map(|(n, _)| *n).collect();
|
|
assert_eq!(names, [".zshenv", ".zprofile", ".zshrc", ".zlogin"]);
|
|
for (name, body) in &files {
|
|
// Every redirector sources the user's real file of the same name,
|
|
// resolved via the captured real ZDOTDIR (`$TTY7_USER_ZDOTDIR`, or
|
|
// $HOME when the user never set one).
|
|
assert!(
|
|
body.contains("$TTY7_USER_ZDOTDIR"),
|
|
"{name} should reference the user's real ZDOTDIR"
|
|
);
|
|
assert!(body.contains(name), "{name} should source its own name");
|
|
assert!(body.contains("source"), "{name} should source");
|
|
}
|
|
// Only .zshrc carries our integration body (so it extends the user's PROMPT).
|
|
let zshrc = &files[2].1;
|
|
assert!(zshrc.contains("__tty7_precmd"));
|
|
assert!(zshrc.contains("133;A"));
|
|
assert!(!files[0].1.contains("__tty7_precmd"));
|
|
}
|
|
|
|
#[test]
|
|
fn zsh_redirectors_point_zdotdir_at_the_real_dir_only_while_sourcing() {
|
|
// Issue #15: ZDOTDIR must resolve to the user's *real* config dir while
|
|
// their startup files run — Zim/oh-my-zsh/compinit key their install state
|
|
// off ${ZDOTDIR:-$HOME}, and our throwaway dir is empty, so leaving ZDOTDIR
|
|
// pointed there makes them reinstall on every pane. Each redirector must:
|
|
// 1. stash our dir, 2. aim ZDOTDIR at the real dir, 3. source, then
|
|
// 4. restore our dir so zsh still finds the *next* redirector.
|
|
for (name, body) in zsh_redirectors() {
|
|
let save = body.find("__tty7_ztmp=$ZDOTDIR").expect("stashes our dir");
|
|
let aim = body
|
|
.find("ZDOTDIR=$TTY7_USER_ZDOTDIR")
|
|
.expect("aims at the real dir");
|
|
let source = body
|
|
.find(&format!("source \"${{ZDOTDIR:-$HOME}}/{name}\""))
|
|
.expect("sources the user's file");
|
|
let restore = body
|
|
.rfind("ZDOTDIR=$__tty7_ztmp")
|
|
.expect("restores our dir");
|
|
let unset = body
|
|
.rfind("unset __tty7_ztmp")
|
|
.expect("cleans up its scratch var");
|
|
assert!(
|
|
save < aim && aim < source && source < restore && restore < unset,
|
|
"{name}: order must be stash → aim-at-real → source → restore-ours → unset"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zshenv_recaptures_a_user_relocated_zdotdir() {
|
|
// The canonical layout is a tiny ~/.zshenv that does `ZDOTDIR=~/.config/zsh`,
|
|
// with the real config living there. After sourcing the user's .zshenv we
|
|
// must capture wherever ZDOTDIR now points so the .zprofile/.zshrc/.zlogin
|
|
// redirectors source from the *relocated* dir (and nested tty7 sees it too),
|
|
// rather than falling back to $HOME and dropping the user's config.
|
|
let files = zsh_redirectors();
|
|
let zshenv = &files[0].1;
|
|
let source = zshenv.find("source \"${ZDOTDIR:-$HOME}/.zshenv\"").unwrap();
|
|
let recapture = zshenv
|
|
.find("export TTY7_USER_ZDOTDIR=${ZDOTDIR:-$HOME}")
|
|
.unwrap();
|
|
let restore = zshenv.rfind("ZDOTDIR=$__tty7_ztmp").unwrap();
|
|
assert!(
|
|
source < recapture && recapture < restore,
|
|
"recapture must run after sourcing the user's .zshenv, before we restore our dir"
|
|
);
|
|
// Only .zshenv recaptures; the other three just source and restore.
|
|
for (name, body) in &files[1..] {
|
|
assert!(
|
|
!body.contains("export TTY7_USER_ZDOTDIR"),
|
|
"{name} must not re-export TTY7_USER_ZDOTDIR"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zsh_integration_restores_real_zdotdir_after_startup() {
|
|
// With every startup file read, the integration body must hand ZDOTDIR back
|
|
// to the user's real dir for the live session (runtime ${ZDOTDIR:-$HOME}
|
|
// lookups, a nested plain `zsh`). It's a one-shot precmd hook that unhooks
|
|
// itself so it doesn't re-fire on every prompt.
|
|
assert!(ZSH_INTEGRATION.contains("__tty7_restore_zdotdir"));
|
|
assert!(ZSH_INTEGRATION.contains("ZDOTDIR=${TTY7_USER_ZDOTDIR:-$HOME}"));
|
|
assert!(
|
|
ZSH_INTEGRATION.contains("add-zsh-hook -d precmd __tty7_restore_zdotdir"),
|
|
"the restore hook must deregister itself so it runs exactly once"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bash_rcfile_sources_user_config_then_appends_integration() {
|
|
let rc = bash_rcfile();
|
|
assert!(rc.contains("/etc/profile"));
|
|
assert!(rc.contains("~/.bash_profile"));
|
|
assert!(rc.contains("~/.bashrc"));
|
|
// Our integration (bash-preexec derived) is appended.
|
|
assert!(rc.contains("__tty7"));
|
|
assert!(rc.contains("133;"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_integration_guards_install_on_empty_sentinel() {
|
|
// `setup()` resets TTY7_SHELL_INTEGRATION to an empty-but-exported "" at each
|
|
// spawn boundary (never *unsets* it), so every shell's install-once guard must
|
|
// key off the sentinel being *empty*, i.e. the `-z "$TTY7_SHELL_INTEGRATION"`
|
|
// idiom shared by zsh/bash. Fish once used `not set -q TTY7_SHELL_INTEGRATION`
|
|
// (definedness), and fish reports an empty exported var as *set* — so the guard
|
|
// was false on every launch and OSC 133 never armed. All three must share the
|
|
// emptiness test so the reset installs a fresh top-level shell while an inherited
|
|
// `1` still blocks re-install.
|
|
for (shell, body) in [
|
|
("zsh", ZSH_INTEGRATION),
|
|
("bash", BASH_INTEGRATION),
|
|
("fish", FISH_INTEGRATION),
|
|
] {
|
|
assert!(
|
|
body.contains(r#"-z "$TTY7_SHELL_INTEGRATION""#),
|
|
"{shell} integration must guard install on the sentinel being empty \
|
|
(matching setup()'s empty-string reset), not on its mere definedness",
|
|
);
|
|
}
|
|
// Fish specifically must not regress to the definedness test that broke it: an
|
|
// empty exported sentinel reads as *set*, which would skip the install.
|
|
assert!(
|
|
!FISH_INTEGRATION.contains("set -q TTY7_SHELL_INTEGRATION"),
|
|
"fish must guard on emptiness (`test -z`), never `set -q`",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn d_emitter_is_prepended_ahead_of_user_precmd_hooks() {
|
|
// The app only switches back to prompt-editing mode when `133;D` arrives.
|
|
// If D waited for the user's whole precmd chain (git-status prompts,
|
|
// conda — easily 100ms+), keys typed right after a command finished
|
|
// would be passed raw to the PTY and kernel-echoed into the grid — the
|
|
// stray-char + PROMPT_SP `%` artifact. So zsh/bash must emit D from a
|
|
// dedicated hook *prepended* to precmd_functions, while the rest of the
|
|
// bookkeeping (cwd, A, the PS1 B marker) stays appended/last.
|
|
assert!(
|
|
ZSH_INTEGRATION.contains("precmd_functions=(__tty7_precmd_d $precmd_functions)"),
|
|
"zsh must prepend the D emitter (add-zsh-hook can only append)"
|
|
);
|
|
assert!(
|
|
BASH_INTEGRATION
|
|
.contains(r#"precmd_functions=(__tty7_precmd_d "${precmd_functions[@]}")"#),
|
|
"bash must prepend the D emitter"
|
|
);
|
|
// D comes from exactly one hook per shell — a second emission site would
|
|
// double-fire on every prompt.
|
|
for (shell, body) in [
|
|
("zsh", ZSH_INTEGRATION),
|
|
("bash", BASH_INTEGRATION),
|
|
("fish", FISH_INTEGRATION),
|
|
] {
|
|
assert_eq!(
|
|
body.matches("133;D").count(),
|
|
1,
|
|
"{shell} must emit D from exactly one place"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_cwd_report_escapes_literal_percent() {
|
|
// Regression: the daemon percent-DECODES the OSC 7 payload, so the
|
|
// reporters must escape a literal `%` in `$PWD` as %25 — otherwise a
|
|
// real dir like `/tmp/a%20b` is recorded as `/tmp/a b` (and `%2F`
|
|
// rewrites the path *structure*), breaking cwd-inheriting new tabs and
|
|
// session restore.
|
|
for (shell, body, escape) in [
|
|
("zsh", ZSH_INTEGRATION, r"${PWD//\%/%25}"),
|
|
("bash", BASH_INTEGRATION, r"${PWD//\%/%25}"),
|
|
(
|
|
"fish",
|
|
FISH_INTEGRATION,
|
|
"string replace --all '%' '%25' -- $PWD",
|
|
),
|
|
] {
|
|
assert!(
|
|
body.contains(escape),
|
|
"{shell}'s OSC 7 reporter must %-escape the literal percent"
|
|
);
|
|
assert!(
|
|
!body.contains(r#" "$PWD";"#),
|
|
"{shell} must not emit the raw $PWD in its OSC 7 report"
|
|
);
|
|
}
|
|
// bash's msys branch reports `pwd -W` output rather than $PWD, so it
|
|
// needs the same escaping on its own variable.
|
|
assert!(
|
|
BASH_INTEGRATION.contains(r"${d//\%/%25}"),
|
|
"bash's msys OSC 7 reporter must %-escape the literal percent too"
|
|
);
|
|
}
|
|
|
|
/// Under Git Bash `$PWD` is an msys path (`/c/Users/x`). Windows reads that
|
|
/// as drive-relative, so it would land on `C:\c\Users\x` — a directory that
|
|
/// does not exist, silently killing the git-status probe and path completion,
|
|
/// and actively breaking split/new-tab (the client cwd wins over every
|
|
/// fallback in `pane::initial_working_directory`, so the next shell is
|
|
/// spawned with a bogus working directory). `pwd -W` is msys's translation
|
|
/// to the real Windows path.
|
|
#[test]
|
|
fn bash_reports_a_windows_path_under_msys() {
|
|
let s = BASH_INTEGRATION;
|
|
assert!(
|
|
s.contains(r#"if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]"#),
|
|
"bash must detect msys/cygwin to pick its cwd reporter"
|
|
);
|
|
assert!(
|
|
s.contains("builtin pwd -W"),
|
|
"bash's msys branch must translate the cwd with `pwd -W`"
|
|
);
|
|
// `pwd -W` yields `C:/Users/x` with no leading slash; a file: URI needs
|
|
// one so the daemon's `strip_uri_drive_slash` recognises the drive.
|
|
assert!(
|
|
s.contains(r#"file://%s/%s"#),
|
|
"bash's msys branch must make the translated path URI-absolute"
|
|
);
|
|
// `pwd -W` is the identity for msys-only virtual mounts (`/proc`,
|
|
// `/dev`), which have no Windows path at all. Requiring a drive letter
|
|
// is what separates a translated path from an untranslated one — a
|
|
// leading-slash test cannot. Falling back to `$PWD` would defeat the
|
|
// whole point, so silence is the only safe answer here.
|
|
assert!(
|
|
s.contains(r#"[[ "$d" == ?:* ]] || return 0"#),
|
|
"bash's msys branch must report nothing when `pwd -W` yields no drive"
|
|
);
|
|
assert!(
|
|
!s.contains(r#"d="$PWD""#),
|
|
"bash's msys branch must never fall back to the untranslated $PWD"
|
|
);
|
|
}
|
|
|
|
/// The payload the msys branch builds must survive the daemon's own parser
|
|
/// and come out as a path Windows can actually use — the shape assertions
|
|
/// above cannot see that. Mirrors what `__tty7_report_cwd` emits.
|
|
#[test]
|
|
fn msys_payload_round_trips_through_parse_osc7() {
|
|
let parse = |payload: &str| {
|
|
crate::daemon::pane::parse_osc7(payload.as_bytes())
|
|
.unwrap_or_else(|| panic!("{payload} should parse"))
|
|
};
|
|
for (translated, want) in [
|
|
("C:/Users/thoma/repo", "C:/Users/thoma/repo"),
|
|
("C:/", "C:/"),
|
|
("D:/work/a b", "D:/work/a b"),
|
|
// The `%` the reporter escapes must survive the round trip.
|
|
("C:/tmp/a%25c", "C:/tmp/a%c"),
|
|
] {
|
|
let got = parse(&format!("7;file://localhost/{translated}"));
|
|
let want = if cfg!(windows) {
|
|
PathBuf::from(want)
|
|
} else {
|
|
PathBuf::from(format!("/{want}"))
|
|
};
|
|
assert_eq!(got, want, "payload for {translated}");
|
|
}
|
|
|
|
// And the shape the guard exists to suppress: an untranslated msys path
|
|
// parses fine but yields a drive-relative path Windows resolves against
|
|
// the current drive, which is how `/c/Users/x` became `C:\c\Users\x`.
|
|
if cfg!(windows) {
|
|
let got = parse("7;file://localhost/c/Users/thoma");
|
|
assert_ne!(got, PathBuf::from("C:/Users/thoma"));
|
|
assert!(
|
|
!got.is_absolute(),
|
|
"{got:?} is drive-relative — Windows resolves it against the \
|
|
current drive, which is why it must never be emitted"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn setup_fish_injects_startup_command_without_files() {
|
|
let inj = setup_fish().expect("fish injection is infallible");
|
|
assert_eq!(inj.args[0], "-C");
|
|
assert!(inj.args[1].contains("__tty7"));
|
|
assert!(inj.args[1].contains("133;"));
|
|
assert!(inj.env.is_empty());
|
|
assert!(!inj.replaces_argv);
|
|
// fish needs no throwaway dir on disk.
|
|
assert!(inj.dir.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn setup_zsh_writes_redirectors_and_points_zdotdir_at_them() {
|
|
let inj = setup_zsh().expect("zsh setup should succeed");
|
|
let dir = inj.dir.clone().expect("zsh needs a throwaway dir");
|
|
// ZDOTDIR points the shell at our throwaway dir.
|
|
assert_eq!(
|
|
inj.env.get("ZDOTDIR").map(String::as_str),
|
|
Some(dir.to_string_lossy().as_ref())
|
|
);
|
|
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() {
|
|
let written = std::fs::read_to_string(dir.join(name)).expect("redirector written");
|
|
assert_eq!(written, body);
|
|
}
|
|
// The dir basename is recognizable as ours (so a nested launch skips it).
|
|
assert!(is_our_zdotdir(&dir.to_string_lossy()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_bash_writes_rcfile_and_forces_non_login() {
|
|
let inj = setup_bash().expect("bash setup should succeed");
|
|
let dir = inj.dir.clone().expect("bash needs a throwaway dir");
|
|
// argv is `--rcfile <path> -i`, in that order.
|
|
assert_eq!(inj.args[0], "--rcfile");
|
|
assert_eq!(inj.args[2], "-i");
|
|
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());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[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");
|
|
assert_eq!(
|
|
inj.env.get("TTY7_SHELL_INTEGRATION").map(String::as_str),
|
|
Some("")
|
|
);
|
|
if let Some(d) = inj.dir {
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
// fish → same sentinel, no files.
|
|
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.
|
|
// On Windows only a path identifiable as msys counts as Bash, since a
|
|
// bare name could resolve to the WSL launcher — see `is_msys_bash`.
|
|
let bash = if cfg!(windows) {
|
|
"C:/Program Files/Git/bin/bash.exe"
|
|
} else {
|
|
"bash"
|
|
};
|
|
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());
|
|
|
|
// PowerShell without custom args → encoded-command injection, no files.
|
|
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.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());
|
|
|
|
// Unknown shell → no integration at all.
|
|
assert!(setup(Some("/bin/sh"), &[], false).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn setup_powershell_injects_encoded_command_without_files() {
|
|
let inj = setup_powershell().expect("powershell injection is infallible");
|
|
// `-NoLogo -NoExit -EncodedCommand <base64>`, in that order — the encoded
|
|
// command must come last, since PowerShell treats it as the value.
|
|
assert_eq!(inj.args[0], "-NoLogo");
|
|
assert_eq!(inj.args[1], "-NoExit");
|
|
assert_eq!(inj.args[2], "-EncodedCommand");
|
|
assert_eq!(inj.args.len(), 4);
|
|
// The payload is pure base64 (so it survives the Windows command line and
|
|
// needs no quoting) and decodes, as UTF-16LE, back to our script.
|
|
let b64 = &inj.args[3];
|
|
assert!(
|
|
b64.bytes()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'='),
|
|
"encoded command must be pure base64"
|
|
);
|
|
assert_eq!(decode_utf16le_base64(b64), POWERSHELL_INTEGRATION);
|
|
// No throwaway dir, no forced spawn mode, no env of its own.
|
|
assert!(inj.env.is_empty());
|
|
assert!(inj.dir.is_none());
|
|
assert!(!inj.replaces_argv);
|
|
}
|
|
|
|
#[test]
|
|
fn powershell_integration_emits_every_osc_133_mark_and_cwd() {
|
|
let s = POWERSHELL_INTEGRATION;
|
|
// A/B wrap the returned prompt; C from the readline hook; D with the exit
|
|
// code from the prompt hook; plus the OSC 7 cwd report.
|
|
assert!(s.contains("]133;A"));
|
|
assert!(s.contains("]133;B"));
|
|
assert!(s.contains("]133;C"));
|
|
assert!(s.contains("]133;D;$code"));
|
|
assert!(s.contains("]7;file://"));
|
|
// Guarded on the empty sentinel like the other shells (PowerShell's own
|
|
// idiom for "unset or empty"), so an inherited `1` blocks re-install.
|
|
assert!(s.contains("if (-not $env:TTY7_SHELL_INTEGRATION)"));
|
|
// $? must be captured before $LASTEXITCODE — an assignment resets $?.
|
|
let ok_at = s.find("$ok = $?").expect("captures $?");
|
|
let exit_at = s.find("$lastExit = $LASTEXITCODE").expect("captures exit");
|
|
assert!(ok_at < exit_at, "$? must be read before the exit code");
|
|
// The user's own prompt is preserved and called through, not replaced.
|
|
assert!(s.contains("$global:__Tty7OrigPrompt = $function:prompt"));
|
|
assert!(s.contains("& $global:__Tty7OrigPrompt"));
|
|
// The literal `%` in the cwd is escaped before the payload is built.
|
|
assert!(s.contains(".Replace('%', '%25')"));
|
|
}
|
|
|
|
#[test]
|
|
fn powershell_integration_sets_an_osc_title() {
|
|
let s = POWERSHELL_INTEGRATION;
|
|
// Without an OSC 0/2 title every Windows tab stays generic (PowerShell
|
|
// profiles, unlike macOS's default zsh, set no title). The prompt hook
|
|
// must emit an OSC 0 "user@host:dir" title.
|
|
assert!(s.contains("]0;$($env:USERNAME)@$($env:COMPUTERNAME):"));
|
|
// Home is abbreviated to `~`, matching how the other shells' titles read.
|
|
assert!(s.contains("$titlePath = '~'"));
|
|
// The title path uses forward slashes so the tab-label parser (which splits
|
|
// on `/`) can take the last path segment on Windows too.
|
|
assert!(s.contains("$titlePath = $fsPath.Replace('\\', '/')"));
|
|
}
|
|
|
|
#[test]
|
|
fn base64_encode_matches_rfc4648_vectors() {
|
|
// The canonical RFC 4648 §10 test vectors, covering both padding cases.
|
|
assert_eq!(base64_encode(b""), "");
|
|
assert_eq!(base64_encode(b"f"), "Zg==");
|
|
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
|
assert_eq!(base64_encode(b"foo"), "Zm9v");
|
|
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
|
|
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
|
|
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
|
}
|
|
|
|
#[test]
|
|
fn powershell_encoded_command_round_trips_utf16le() {
|
|
// A string with a multi-byte char to exercise the UTF-16LE step.
|
|
let script = "Write-Host 'héllo ✓'";
|
|
assert_eq!(
|
|
decode_utf16le_base64(&powershell_encoded_command(script)),
|
|
script
|
|
);
|
|
}
|
|
|
|
/// Decode a base64 UTF-16LE string back to a Rust `String` — the inverse of
|
|
/// [`powershell_encoded_command`], used to check the encoder round-trips
|
|
/// without a PowerShell interpreter.
|
|
fn decode_utf16le_base64(b64: &str) -> String {
|
|
fn val(c: u8) -> Option<u32> {
|
|
match c {
|
|
b'A'..=b'Z' => Some((c - b'A') as u32),
|
|
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
|
|
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
|
|
b'+' => Some(62),
|
|
b'/' => Some(63),
|
|
_ => None,
|
|
}
|
|
}
|
|
let mut bytes = Vec::new();
|
|
let mut acc = 0u32;
|
|
let mut nbits = 0;
|
|
for c in b64.bytes() {
|
|
let Some(v) = val(c) else { continue }; // skip padding
|
|
acc = (acc << 6) | v;
|
|
nbits += 6;
|
|
if nbits >= 8 {
|
|
nbits -= 8;
|
|
bytes.push((acc >> nbits) as u8);
|
|
}
|
|
}
|
|
let units: Vec<u16> = bytes
|
|
.chunks_exact(2)
|
|
.map(|p| u16::from_le_bytes([p[0], p[1]]))
|
|
.collect();
|
|
String::from_utf16(&units).expect("valid UTF-16LE")
|
|
}
|
|
|
|
#[test]
|
|
fn throwaway_dir_is_unique_per_call() {
|
|
let a = throwaway_dir("tty7-test-").expect("dir a");
|
|
let b = throwaway_dir("tty7-test-").expect("dir b");
|
|
// The monotonic counter guarantees distinct dirs even within one process.
|
|
assert_ne!(a, b);
|
|
assert!(
|
|
a.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.starts_with("tty7-test-")
|
|
);
|
|
assert!(a.is_dir() && b.is_dir());
|
|
let _ = std::fs::remove_dir_all(&a);
|
|
let _ = std::fs::remove_dir_all(&b);
|
|
}
|
|
}
|