mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 00:01:55 +00:00
fix(ssh): start a redialled SSH pane in the remote directory it was in (#1035)
* fix(ssh): start a redialled SSH pane in the remote directory it was in A native SSH pane dialled again (restore after a daemon restart, Reconnect, a split, ⌘T on an SSH tab, waking a sleeping tab) always landed in the login directory. The client already sent the pane's remote cwd as `SpawnNativeSsh.cwd`, but the daemon dropped it on the floor. The daemon now threads it through `Pane::spawn_native_ssh` and `SshManager::run_session` into the shell-integration bootstrap, whose first line becomes `builtin cd -- '<dir>' 2>/dev/null` (fish-quoted for fish). It is never typed at the prompt, never lands in history, and a directory that is gone leaves the shell in the login directory without a word. Sessions without integration take the plain shell request as before, so jump-host menus never see it. The per-host probe cache still holds only the shell. Only absolute paths are honoured. No wire or protocol change. Callers now say what they mean: a saved host or quick connect passes no start dir instead of the local cwd of whatever tab was in front; ⌘T and a split on an SSH pane pass that pane's remote cwd; a sleeping SSH tab keeps its remote cwd in the session layout. The daemon's replay now sends the remote context before the cwd, so a window that reattaches to an SSH pane does not wipe the remote directory it was just told. Refs #1028 Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq * fix(ssh): a local shell standing in for a restored SSH leaf starts locally pane_to_session now keeps a native SSH leaf's far directory, so the session_to_pane fallback that brings such a leaf back as a local shell (the redial failed, or its daemon pane is gone on reattach) would hand that remote path to a local spawn. Pass no cwd there for an SSH leaf. Claude-Session: https://claude.ai/code/session_011mDkkQhwx4RJJBHee3yVpq
This commit is contained in:
@@ -2030,10 +2030,17 @@ impl DaemonPane {
|
||||
))
|
||||
}
|
||||
|
||||
/// A pane bridged to a shell channel on `spec`'s host.
|
||||
///
|
||||
/// `remote_start_dir` is a directory on that host for the shell to start
|
||||
/// in. The pane's own `cwd` is deliberately left unknown until the far
|
||||
/// shell reports one: the `cd` that takes it there can quietly fail, and a
|
||||
/// guess recorded now would be persisted as fact.
|
||||
pub fn spawn_native_ssh(
|
||||
id: u64,
|
||||
size: WinSize,
|
||||
spec: Box<NativeSshSpec>,
|
||||
remote_start_dir: Option<String>,
|
||||
on_dead: impl FnOnce() + Send + 'static,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let allow_remote_clipboard_write = spec.remote_clipboard_write;
|
||||
@@ -2137,6 +2144,7 @@ impl DaemonPane {
|
||||
crate::daemon::ssh::SshManager::global().spawn_native_session(
|
||||
id,
|
||||
spec,
|
||||
remote_start_dir,
|
||||
size,
|
||||
broker,
|
||||
bridge.data_tx,
|
||||
@@ -3123,9 +3131,6 @@ fn replay_state(st: &PaneState, subscriber: &Sender<DaemonMsg>, foreground_comma
|
||||
let _ = subscriber.send(DaemonMsg::Snapshot(modes));
|
||||
}
|
||||
st.ring.replay(subscriber);
|
||||
if let Some(cwd) = &st.cwd {
|
||||
let _ = subscriber.send(DaemonMsg::Cwd(cwd.clone()));
|
||||
}
|
||||
if st.shell.active {
|
||||
let _ = subscriber.send(DaemonMsg::Prompt {
|
||||
active: st.shell.active,
|
||||
@@ -3136,6 +3141,17 @@ fn replay_state(st: &PaneState, subscriber: &Sender<DaemonMsg>, foreground_comma
|
||||
if st.remote.is_some() {
|
||||
let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone()));
|
||||
}
|
||||
// After the remote context, never before it. A client drops its cwd on
|
||||
// every `RemoteContext`, because live that frame means the pane just hopped
|
||||
// and the old directory belongs to the other side. Replayed, it is only
|
||||
// the standing context, and `st.cwd` is already the far shell's own report
|
||||
// (`apply_remote_context` clears it on every hop). Sent first, it would be
|
||||
// wiped, and a native SSH pane reopened by a client would have no remote
|
||||
// directory for ⌘T or a split to start the new pane in until its next
|
||||
// prompt.
|
||||
if let Some(cwd) = &st.cwd {
|
||||
let _ = subscriber.send(DaemonMsg::Cwd(cwd.clone()));
|
||||
}
|
||||
if let Some(phase) = &st.ssh_phase {
|
||||
let _ = subscriber.send(DaemonMsg::SshStatus {
|
||||
phase: phase.clone(),
|
||||
@@ -4947,6 +4963,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The client forgets its cwd on every `RemoteContext`, so a replay that
|
||||
/// sent the far shell's directory first would have it wiped straight away.
|
||||
#[test]
|
||||
fn a_window_reattaching_to_an_ssh_pane_keeps_its_remote_directory() {
|
||||
let mut st = test_state(true);
|
||||
st.remote = Some(RemoteContext {
|
||||
kind: RemoteKind::NativeSsh,
|
||||
argv: Vec::new(),
|
||||
target: "alice@box".into(),
|
||||
});
|
||||
st.cwd = Some(PathBuf::from("/home/alice/my_service"));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
replay_state(&st, &tx, false);
|
||||
drop(tx);
|
||||
let order: Vec<&str> = rx
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
DaemonMsg::RemoteContext(_) => Some("remote"),
|
||||
DaemonMsg::Cwd(_) => Some("cwd"),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(order, ["remote", "cwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_is_kept_until_it_changes_and_a_reset_clears_it() {
|
||||
let mut st = test_state(true);
|
||||
|
||||
@@ -905,6 +905,15 @@ pub enum ClientMsg {
|
||||
},
|
||||
EnsureLoopbackForward(LoopbackForwardRequest),
|
||||
SpawnNativeSsh {
|
||||
/// The directory *on the remote host* for the shell to start in —
|
||||
/// where a pane being dialled again (restore, Reconnect, a split, ⌘T)
|
||||
/// last reported it was. Never a path on this machine: it is not
|
||||
/// checked or canonicalized here, only handed to the far shell, and a
|
||||
/// fresh connection from a saved host sends `None`.
|
||||
///
|
||||
/// Honoured only when the session gets a shell-integration bootstrap
|
||||
/// to carry it; a relative path is ignored, and one that no longer
|
||||
/// exists leaves the shell in the login directory.
|
||||
cwd: Option<PathBuf>,
|
||||
size: WinSize,
|
||||
spec: Box<NativeSshSpec>,
|
||||
|
||||
@@ -830,8 +830,13 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
ClientMsg::SpawnNativeSsh { cwd: _, size, spec } => {
|
||||
ClientMsg::SpawnNativeSsh { cwd, size, spec } => {
|
||||
let allow_remote_clipboard_write = spec.remote_clipboard_write;
|
||||
// A far-host path that only travels as a `PathBuf`: taken as the
|
||||
// text it was sent as, never resolved against this machine. One
|
||||
// that is not UTF-8 could only reach the far shell mangled, so it
|
||||
// is dropped and the shell starts where it would have anyway.
|
||||
let remote_start_dir = cwd.and_then(|p| p.into_os_string().into_string().ok());
|
||||
let id = registry.alloc_id();
|
||||
let on_dead = {
|
||||
let registry = registry.clone();
|
||||
@@ -844,7 +849,8 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
let pane = match DaemonPane::spawn_native_ssh(id, size, spec, on_dead) {
|
||||
let pane = match DaemonPane::spawn_native_ssh(id, size, spec, remote_start_dir, on_dead)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let mut w = write_stream;
|
||||
|
||||
@@ -1309,12 +1309,56 @@ pub mod remote {
|
||||
RemoteShell::from_path(path).map(|shell| (shell, path.to_string()))
|
||||
}
|
||||
|
||||
pub fn bootstrap_command(shell: RemoteShell, shell_path: &str) -> String {
|
||||
match shell {
|
||||
/// The script a native SSH session execs in place of a bare shell request.
|
||||
///
|
||||
/// `start_dir` is a directory *on the remote host* — where the pane was the
|
||||
/// last time the far shell reported its cwd — and the session moves there
|
||||
/// before the user's shell is exec'd, so a redialled pane comes back where
|
||||
/// it was instead of in the login directory. It rides in the bootstrap
|
||||
/// rather than being typed at the prompt so it never shows on screen or
|
||||
/// lands in history, and a directory that has gone away since fails the
|
||||
/// `cd` quietly and leaves the session in the login directory, exactly as
|
||||
/// if nothing had been asked.
|
||||
///
|
||||
/// The `cd` is the script's first line, ahead of the rc staging: a
|
||||
/// relative `$TMPDIR` then resolves against one directory for every line
|
||||
/// that follows, not one before the `cd` and another after it.
|
||||
pub fn bootstrap_command(
|
||||
shell: RemoteShell,
|
||||
shell_path: &str,
|
||||
start_dir: Option<&str>,
|
||||
) -> String {
|
||||
let mut out = start_dir
|
||||
.and_then(usable_start_dir)
|
||||
.map(|dir| cd_line(shell, dir))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&match shell {
|
||||
RemoteShell::Zsh => zsh_bootstrap(shell_path),
|
||||
RemoteShell::Bash => bash_bootstrap(shell_path),
|
||||
RemoteShell::Fish => fish_bootstrap(shell_path),
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// `dir`, if it can only mean one place on the far host.
|
||||
///
|
||||
/// Absolute paths only: a relative one would resolve against the login
|
||||
/// directory and through `CDPATH`, and a `~` would reach the shell quoted
|
||||
/// and never expand. Neither is anything OSC 7 reports, so honouring one
|
||||
/// would be a guess. A NUL cannot travel in an exec request at all.
|
||||
fn usable_start_dir(dir: &str) -> Option<&str> {
|
||||
(dir.starts_with('/') && !dir.contains('\0')).then_some(dir)
|
||||
}
|
||||
|
||||
/// `builtin`, so a `cd` wrapper defined in a file the `-c` shell already
|
||||
/// read (`.zshenv`, fish's `config.fish`) cannot print, prompt or refuse;
|
||||
/// `--`, so no directory name is ever taken for an option.
|
||||
fn cd_line(shell: RemoteShell, dir: &str) -> String {
|
||||
let quoted = match shell {
|
||||
RemoteShell::Zsh | RemoteShell::Bash => shell_quote(dir),
|
||||
RemoteShell::Fish => fish_quote(dir),
|
||||
};
|
||||
format!("builtin cd -- {quoted} 2>/dev/null\n")
|
||||
}
|
||||
|
||||
fn fish_quote(s: &str) -> String {
|
||||
@@ -1438,7 +1482,7 @@ fi
|
||||
|
||||
#[test]
|
||||
fn zsh_bootstrap_gates_zdotdir_on_every_redirector_landing() {
|
||||
let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh");
|
||||
let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh", None);
|
||||
for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] {
|
||||
assert!(
|
||||
script.contains(&format!("[ -s \"$__tty7_d/{name}\" ] &&")),
|
||||
@@ -1457,7 +1501,7 @@ fi
|
||||
(RemoteShell::Zsh, "/bin/zsh"),
|
||||
(RemoteShell::Bash, "/bin/bash"),
|
||||
] {
|
||||
let script = bootstrap_command(shell, path);
|
||||
let script = bootstrap_command(shell, path, None);
|
||||
let last = script.trim_end().lines().last().unwrap();
|
||||
assert_eq!(
|
||||
last,
|
||||
@@ -1469,14 +1513,14 @@ fi
|
||||
|
||||
#[test]
|
||||
fn bash_bootstrap_forces_a_non_login_shell_through_the_rcfile() {
|
||||
let script = bootstrap_command(RemoteShell::Bash, "/bin/bash");
|
||||
let script = bootstrap_command(RemoteShell::Bash, "/bin/bash", None);
|
||||
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");
|
||||
let script = bootstrap_command(RemoteShell::Fish, "/usr/bin/fish", None);
|
||||
assert!(script.starts_with("exec '/usr/bin/fish' -C '"));
|
||||
assert!(script.trim_end().ends_with("' -l"));
|
||||
assert!(!script.contains("mkdir"));
|
||||
@@ -1536,13 +1580,144 @@ fi
|
||||
(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}");
|
||||
for start_dir in [None, Some(AWKWARD_DIR)] {
|
||||
let script = bootstrap_command(shell, path, start_dir);
|
||||
if let Some((ok, stderr)) = parse_check(bin, flag, &script) {
|
||||
assert!(
|
||||
ok,
|
||||
"{bin} rejected its bootstrap script (start dir {start_dir:?}):\n{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A remote directory with everything in it that quoting has to get
|
||||
/// right: a space, a single quote, a backslash and a `$`.
|
||||
const AWKWARD_DIR: &str = r"/srv/my service/it's \n $HOME";
|
||||
|
||||
#[test]
|
||||
fn a_start_dir_opens_every_bootstrap_with_one_quiet_cd() {
|
||||
for (shell, path, expected) in [
|
||||
(
|
||||
RemoteShell::Zsh,
|
||||
"/bin/zsh",
|
||||
r"builtin cd -- '/srv/my service/it'\''s \n $HOME' 2>/dev/null",
|
||||
),
|
||||
(
|
||||
RemoteShell::Bash,
|
||||
"/bin/bash",
|
||||
r"builtin cd -- '/srv/my service/it'\''s \n $HOME' 2>/dev/null",
|
||||
),
|
||||
(
|
||||
RemoteShell::Fish,
|
||||
"/usr/bin/fish",
|
||||
r"builtin cd -- '/srv/my service/it\'s \\n $HOME' 2>/dev/null",
|
||||
),
|
||||
] {
|
||||
let script = bootstrap_command(shell, path, Some(AWKWARD_DIR));
|
||||
let (first, rest) = script.split_once('\n').expect("more than one line");
|
||||
assert_eq!(first, expected, "{shell:?}");
|
||||
assert_eq!(
|
||||
rest,
|
||||
bootstrap_command(shell, path, None),
|
||||
"{shell:?}: the cd is added in front, and nothing else changes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_start_dir_the_bootstrap_has_no_cd_at_all() {
|
||||
for (shell, path) in [
|
||||
(RemoteShell::Zsh, "/bin/zsh"),
|
||||
(RemoteShell::Bash, "/bin/bash"),
|
||||
(RemoteShell::Fish, "/usr/bin/fish"),
|
||||
] {
|
||||
assert!(!bootstrap_command(shell, path, None).contains("builtin cd"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_dir_that_is_not_an_absolute_path_is_ignored() {
|
||||
for dir in ["", "~/my_service", "my_service", "./x", "/a\0b"] {
|
||||
let script = bootstrap_command(RemoteShell::Bash, "/bin/bash", Some(dir));
|
||||
assert_eq!(
|
||||
script,
|
||||
bootstrap_command(RemoteShell::Bash, "/bin/bash", None),
|
||||
"{dir:?} must not be turned into a cd"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the bootstrap's own `cd` line, then `pwd`, in `bin` the way
|
||||
/// sshd runs an exec request: `<shell> -c <script>`, started in the
|
||||
/// login directory. `None` when `bin` is not installed here.
|
||||
#[cfg(unix)]
|
||||
fn land(
|
||||
shell: RemoteShell,
|
||||
bin: &str,
|
||||
home: &std::path::Path,
|
||||
start_dir: &str,
|
||||
) -> Option<std::process::Output> {
|
||||
use std::process::Command;
|
||||
let script = format!("{}pwd\n", cd_line(shell, start_dir));
|
||||
Command::new(bin)
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.current_dir(home)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
|
||||
.env("HOME", home)
|
||||
.env("XDG_CONFIG_HOME", home.join(".config"))
|
||||
.env("XDG_DATA_HOME", home.join(".local/share"))
|
||||
.output()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_real_shell_lands_in_the_start_dir_and_falls_back_home_without_a_word() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
// Canonical, so `pwd` has no symlinked `/var` to disagree about.
|
||||
let root = root.path().canonicalize().expect("canonical tempdir");
|
||||
let home = root.join("home");
|
||||
let target = root.join(r"my service/it's \n $HOME");
|
||||
std::fs::create_dir_all(&home).expect("home");
|
||||
std::fs::create_dir_all(&target).expect("target");
|
||||
let target_str = target.to_str().expect("utf-8 temp path");
|
||||
let missing = root.join("gone since");
|
||||
let missing_str = missing.to_str().expect("utf-8 temp path");
|
||||
|
||||
for (shell, bin) in [
|
||||
(RemoteShell::Bash, "bash"),
|
||||
(RemoteShell::Zsh, "zsh"),
|
||||
(RemoteShell::Fish, "fish"),
|
||||
] {
|
||||
let Some(out) = land(shell, bin, &home, target_str) else {
|
||||
continue;
|
||||
};
|
||||
assert!(out.status.success(), "{bin}: {out:?}");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout).trim_end(),
|
||||
target_str,
|
||||
"{bin} did not land in the start dir"
|
||||
);
|
||||
|
||||
let out = land(shell, bin, &home, missing_str).expect("ran once already");
|
||||
assert!(out.status.success(), "{bin}: {out:?}");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout).trim_end(),
|
||||
home.to_str().unwrap(),
|
||||
"{bin} left the login directory for a directory that is gone"
|
||||
);
|
||||
assert!(
|
||||
out.stderr.is_empty(),
|
||||
"{bin} complained about the missing directory: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn heredoc_bodies_parse_under_their_real_shells() {
|
||||
|
||||
@@ -200,10 +200,17 @@ impl SshManager {
|
||||
))
|
||||
}
|
||||
|
||||
/// Dial `spec` and bridge its shell channel into the pane's pipes.
|
||||
///
|
||||
/// `remote_start_dir` is a directory on the far host for the shell to
|
||||
/// start in — see [`remote::bootstrap_command`] for how it gets there and
|
||||
/// why a session without shell integration ignores it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_native_session(
|
||||
&'static self,
|
||||
pane_id: u64,
|
||||
spec: Box<NativeSshSpec>,
|
||||
remote_start_dir: Option<String>,
|
||||
size: WinSize,
|
||||
broker: Arc<PromptBroker>,
|
||||
data_tx: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
@@ -215,6 +222,7 @@ impl SshManager {
|
||||
.run_session(
|
||||
pane_id,
|
||||
&spec,
|
||||
remote_start_dir.as_deref(),
|
||||
size,
|
||||
&broker,
|
||||
data_tx.clone(),
|
||||
@@ -274,10 +282,12 @@ impl SshManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_session(
|
||||
&'static self,
|
||||
pane_id: u64,
|
||||
spec: &NativeSshSpec,
|
||||
remote_start_dir: Option<&str>,
|
||||
size: WinSize,
|
||||
broker: &Arc<PromptBroker>,
|
||||
data_tx: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
@@ -365,8 +375,12 @@ impl SshManager {
|
||||
let _ = channel.agent_forward(false).await;
|
||||
}
|
||||
|
||||
// Only a bootstrap can carry the start directory. A bare shell request
|
||||
// has nowhere to put it short of typing a `cd` at the prompt, which
|
||||
// would show on screen, land in history, and go to whatever answers
|
||||
// first — a jump host's menu as readily as a shell.
|
||||
let bootstrap = match spec.shell_integration {
|
||||
true => self.remote_bootstrap(&conn).await,
|
||||
true => self.remote_bootstrap(&conn, remote_start_dir).await,
|
||||
false => None,
|
||||
};
|
||||
match bootstrap {
|
||||
@@ -583,7 +597,14 @@ impl SshManager {
|
||||
routes
|
||||
}
|
||||
|
||||
async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> {
|
||||
/// The bootstrap for a new session on `conn`, starting in `start_dir` on
|
||||
/// the far host. What is cached per connection is the probed shell only:
|
||||
/// the start directory belongs to the pane being dialled, not the host.
|
||||
async fn remote_bootstrap(
|
||||
&self,
|
||||
conn: &Arc<SshConnection>,
|
||||
start_dir: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let key = conn.key().clone();
|
||||
let cached = { self.probes.lock().unwrap().get(&key).cloned() };
|
||||
let probed = match cached {
|
||||
@@ -605,7 +626,7 @@ impl SshManager {
|
||||
probed
|
||||
}
|
||||
};
|
||||
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path))
|
||||
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path, start_dir))
|
||||
}
|
||||
|
||||
fn open_connection<'a>(
|
||||
@@ -850,7 +871,7 @@ mod tests {
|
||||
let mgr = manager();
|
||||
mgr.runtime.block_on(async {
|
||||
let sshd = FakeSshd::connect(Exec::Hangs, Some(0)).await;
|
||||
assert!(mgr.remote_bootstrap(&sshd.conn).await.is_none());
|
||||
assert!(mgr.remote_bootstrap(&sshd.conn, None).await.is_none());
|
||||
assert!(
|
||||
sshd.conn.is_saturated(),
|
||||
"a refused session marks the link full"
|
||||
@@ -867,7 +888,7 @@ mod tests {
|
||||
let mgr = manager();
|
||||
mgr.runtime.block_on(async {
|
||||
let sshd = FakeSshd::connect(Exec::Exits, None).await;
|
||||
assert!(mgr.remote_bootstrap(&sshd.conn).await.is_none());
|
||||
assert!(mgr.remote_bootstrap(&sshd.conn, None).await.is_none());
|
||||
assert!(sshd.conn.is_alive());
|
||||
assert!(mgr.probes.lock().unwrap().contains_key(sshd.conn.key()));
|
||||
sshd.wait_for_closed(1).await;
|
||||
|
||||
+13
-5
@@ -2101,21 +2101,29 @@ impl RemoteTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// `remote_start_dir` is a directory on the far host, carried as
|
||||
/// [`ClientMsg::SpawnNativeSsh`]'s `cwd`.
|
||||
pub fn spawn_native_ssh(
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
remote_start_dir: Option<PathBuf>,
|
||||
spec: Box<NativeSshSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
match Self::spawn_native_ssh_once(size, cell_w, cell_h, cwd.clone(), spec.clone()) {
|
||||
match Self::spawn_native_ssh_once(
|
||||
size,
|
||||
cell_w,
|
||||
cell_h,
|
||||
remote_start_dir.clone(),
|
||||
spec.clone(),
|
||||
) {
|
||||
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
|
||||
if let Err(restart_err) = crate::daemon::spawn::restart() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"daemon disconnected before SpawnNativeSsh reply ({first_err}); restart failed: {restart_err}"
|
||||
));
|
||||
}
|
||||
Self::spawn_native_ssh_once(size, cell_w, cell_h, cwd, spec).map_err(|second_err| {
|
||||
Self::spawn_native_ssh_once(size, cell_w, cell_h, remote_start_dir, spec).map_err(|second_err| {
|
||||
anyhow::anyhow!(
|
||||
"daemon disconnected before SpawnNativeSsh reply ({first_err}); restarted daemon but it still failed: {second_err}"
|
||||
)
|
||||
@@ -2129,7 +2137,7 @@ impl RemoteTerminal {
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
remote_start_dir: Option<PathBuf>,
|
||||
spec: Box<NativeSshSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
let mut stream = connect()?;
|
||||
@@ -2139,7 +2147,7 @@ impl RemoteTerminal {
|
||||
let auto_supplied_password = spec.password.is_some();
|
||||
|
||||
ClientMsg::SpawnNativeSsh {
|
||||
cwd,
|
||||
cwd: remote_start_dir,
|
||||
size: win,
|
||||
spec,
|
||||
}
|
||||
|
||||
@@ -1576,18 +1576,18 @@ impl TerminalView {
|
||||
self.owner_workspace
|
||||
}
|
||||
|
||||
/// Dials `spec` through the local daemon. `remote_start_dir` is a
|
||||
/// directory on the far host — see [`ClientMsg::SpawnNativeSsh`]'s `cwd`
|
||||
/// — and never a local one.
|
||||
///
|
||||
/// [`ClientMsg::SpawnNativeSsh`]: crate::daemon::protocol::ClientMsg::SpawnNativeSsh
|
||||
pub fn spawn_native_ssh_terminal(
|
||||
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
remote_start_dir: Option<std::path::PathBuf>,
|
||||
) -> anyhow::Result<NativeSshParts> {
|
||||
let persist = Box::new(spec.without_secrets());
|
||||
let (terminal, pane_id) = RemoteTerminal::spawn_native_ssh(
|
||||
TermSize::new(80, 24),
|
||||
8,
|
||||
17,
|
||||
working_directory,
|
||||
spec,
|
||||
)?;
|
||||
let (terminal, pane_id) =
|
||||
RemoteTerminal::spawn_native_ssh(TermSize::new(80, 24), 8, 17, remote_start_dir, spec)?;
|
||||
Ok(NativeSshParts {
|
||||
terminal,
|
||||
pane_id,
|
||||
|
||||
+143
-28
@@ -43,6 +43,39 @@ pub(crate) enum SpawnAs {
|
||||
Ssh(Box<crate::daemon::protocol::NativeSshSpec>),
|
||||
}
|
||||
|
||||
/// How a pane being opened relates to the pane it was opened from — which is
|
||||
/// what decides whether it may start in that pane's directory.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum OpenedFrom {
|
||||
/// A local shell.
|
||||
LocalShell,
|
||||
/// The source pane's own SSH host, dialled again (⌘T, ⌘D on an SSH pane).
|
||||
SameSshHost,
|
||||
/// A saved host or quick connect: a connection of its own, whatever pane
|
||||
/// happened to be in front when it was asked for.
|
||||
FreshSshHost,
|
||||
}
|
||||
|
||||
/// The directory a pane opened from another one starts in.
|
||||
///
|
||||
/// `source_cwd` is the source pane's last reported directory, and on a remote
|
||||
/// pane that is a path on the far host. It carries over only to a pane on the
|
||||
/// same side of the link: a local shell takes a local pane's directory, and a
|
||||
/// redial of the same host takes the far shell's. Anything else starts where a
|
||||
/// fresh pane would — a local path handed to a remote `cd` names nothing there,
|
||||
/// and a remote one handed to a local spawn names nothing here.
|
||||
fn inherited_start_dir(
|
||||
opened: OpenedFrom,
|
||||
source_cwd: Option<std::path::PathBuf>,
|
||||
source_is_remote: bool,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
match opened {
|
||||
OpenedFrom::LocalShell => source_cwd.filter(|_| !source_is_remote),
|
||||
OpenedFrom::SameSshHost => source_cwd,
|
||||
OpenedFrom::FreshSshHost => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a row taken out of the new-tab menu lands.
|
||||
///
|
||||
/// Windows Terminal's rule, and the reason this is a parameter rather than two
|
||||
@@ -3582,7 +3615,7 @@ impl Tty7App {
|
||||
verify,
|
||||
&crate::ui::ssh_connect::config_alias_resolver,
|
||||
);
|
||||
self.open_native_ssh_tab(Box::new(spec), window, cx);
|
||||
self.open_native_ssh_tab(Box::new(spec), None, window, cx);
|
||||
}
|
||||
Err(reason) => self.push_ssh_connect_error(reason, cx),
|
||||
}
|
||||
@@ -4036,6 +4069,8 @@ impl Tty7App {
|
||||
/// ⌘T. From an SSH pane it dials the same host again, the way ⌘D does:
|
||||
/// a local shell opened from one would land in a directory the pane never
|
||||
/// showed, and in Ungrouped rather than under the host the user was on.
|
||||
/// The new session starts in the far directory the pane was in, as a
|
||||
/// local ⌘T starts in the local one.
|
||||
pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let source = self
|
||||
.tabs
|
||||
@@ -4049,11 +4084,12 @@ impl Tty7App {
|
||||
(view.ssh_spec(), remote, view.cwd())
|
||||
});
|
||||
match source {
|
||||
Some((Some(spec), remote, _)) => {
|
||||
Some((Some(spec), remote, cwd)) => {
|
||||
let start_dir = inherited_start_dir(OpenedFrom::SameSshHost, cwd, true);
|
||||
let place = self.spawn_group(None, cx).on_host(remote.map(|r| r.target));
|
||||
let spec = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
|
||||
let before = self.tabs.len();
|
||||
self.open_native_ssh_tab(spec, window, cx);
|
||||
self.open_native_ssh_tab(spec, start_dir, window, cx);
|
||||
if self.tabs.len() > before
|
||||
&& let Some(tab) = self.tabs.get(self.active)
|
||||
{
|
||||
@@ -4215,18 +4251,18 @@ impl Tty7App {
|
||||
Some(tab)
|
||||
}
|
||||
|
||||
/// A tab dialling `spec`, whose shell starts in `remote_start_dir` on the
|
||||
/// far host. A connection opened from a saved host or quick connect
|
||||
/// passes `None`: the pane in front belongs to some other machine (usually
|
||||
/// this one), and its directory means nothing over there.
|
||||
pub(crate) fn open_native_ssh_tab(
|
||||
&mut self,
|
||||
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
||||
remote_start_dir: Option<std::path::PathBuf>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let cwd = self.tabs.get(self.active).and_then(|t| {
|
||||
t.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
});
|
||||
let view = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
||||
let view = match new_terminal_native(self.font_size, remote_start_dir, spec, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("native SSH spawn failed: {e}");
|
||||
@@ -4261,8 +4297,10 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let cwd = dead.read(cx).cwd();
|
||||
let fresh = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
||||
// The far shell's last report, not a local path: a native SSH pane's
|
||||
// cwd only ever comes from OSC 7 sent across the link.
|
||||
let remote_start_dir = dead.read(cx).cwd();
|
||||
let fresh = match new_terminal_native(self.font_size, remote_start_dir, spec, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("native SSH respawn failed: {e}");
|
||||
@@ -4321,19 +4359,32 @@ impl Tty7App {
|
||||
if !self.guard_local_spawn(window, cx) {
|
||||
return None;
|
||||
}
|
||||
let cwd = target.read(cx).spawnable_cwd();
|
||||
let spawn = match spawn {
|
||||
Some(spawn) => spawn,
|
||||
let (spawn, opened) = match spawn {
|
||||
Some(spawn) => {
|
||||
let opened = match spawn {
|
||||
SpawnAs::Ssh(_) => OpenedFrom::FreshSshHost,
|
||||
SpawnAs::Shell(_) => OpenedFrom::LocalShell,
|
||||
};
|
||||
(spawn, opened)
|
||||
}
|
||||
// A stored spec is resolved against the saved host before it is
|
||||
// dialled; one handed in by a caller was just built from that host
|
||||
// and needs no second pass.
|
||||
None => match target.read(cx).ssh_spec() {
|
||||
Some(spec) => {
|
||||
SpawnAs::Ssh(crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx))
|
||||
}
|
||||
None => SpawnAs::Shell(target.read(cx).shell_spec()),
|
||||
Some(spec) => (
|
||||
SpawnAs::Ssh(crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx)),
|
||||
OpenedFrom::SameSshHost,
|
||||
),
|
||||
None => (
|
||||
SpawnAs::Shell(target.read(cx).shell_spec()),
|
||||
OpenedFrom::LocalShell,
|
||||
),
|
||||
},
|
||||
};
|
||||
let cwd = {
|
||||
let target = target.read(cx);
|
||||
inherited_start_dir(opened, target.cwd(), target.remote_context().is_some())
|
||||
};
|
||||
let new = match spawn {
|
||||
SpawnAs::Ssh(spec) => {
|
||||
match new_terminal_native(self.font_size, cwd, spec, window, cx) {
|
||||
@@ -10028,7 +10079,16 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
Pane::Leaf(PaneSlot::Ready(view)) => {
|
||||
let view = view.read(cx);
|
||||
SessionPane::Leaf {
|
||||
cwd: view.spawnable_cwd(),
|
||||
// A native SSH leaf keeps the far shell's directory, so a
|
||||
// sleeping SSH tab wakes where it was: the redial that wakes it
|
||||
// takes this as its start directory on the same host, and the
|
||||
// tree record it seeds holds the far directory for such a pane
|
||||
// anyway. Any other remote pane's cwd is on a host no restore
|
||||
// dials, so it is still left out.
|
||||
cwd: match view.ssh_spec() {
|
||||
Some(_) => view.cwd(),
|
||||
None => view.spawnable_cwd(),
|
||||
},
|
||||
pane_id: Some(view.pane_id),
|
||||
// `None` for a pane this window attached to rather than
|
||||
// spawned: it never knew what was on the other end. The tree
|
||||
@@ -10353,17 +10413,27 @@ fn session_to_pane(
|
||||
if restore.is_none() {
|
||||
if let Some(spec) = ssh_spec.clone() {
|
||||
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
|
||||
// A native SSH leaf's recorded cwd is the far shell's own
|
||||
// report, so it is where the redialled shell starts.
|
||||
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
|
||||
Ok(view) => return Some(Pane::leaf(PaneSlot::Ready(view))),
|
||||
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Past here the leaf comes back as a shell on the window's own
|
||||
// machine: reattached, or spawned fresh in place of one that is
|
||||
// gone. A native SSH leaf's cwd is a path on the far host, so a
|
||||
// fresh shell standing in for one must not start in it here.
|
||||
let local_cwd = match ssh_spec {
|
||||
Some(_) => None,
|
||||
None => cwd.clone(),
|
||||
};
|
||||
let view = match new_terminal(
|
||||
workspace.cloned(),
|
||||
Some(owner),
|
||||
font_size,
|
||||
cwd.clone(),
|
||||
local_cwd,
|
||||
restore,
|
||||
shell.clone(),
|
||||
window,
|
||||
@@ -10640,14 +10710,17 @@ fn watch_pane_focus(
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// A pane dialling `spec`. `remote_start_dir` is a directory on the far host
|
||||
/// for its shell to start in — never a local path; see
|
||||
/// [`inherited_start_dir`] for which openings carry one.
|
||||
pub(crate) fn new_terminal_native(
|
||||
font_size: f32,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
remote_start_dir: Option<std::path::PathBuf>,
|
||||
spec: Box<crate::daemon::protocol::NativeSshSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> anyhow::Result<Entity<TerminalView>> {
|
||||
let parts = TerminalView::spawn_native_ssh_terminal(spec, working_directory)?;
|
||||
let parts = TerminalView::spawn_native_ssh_terminal(spec, remote_start_dir)?;
|
||||
let view = cx.new(|cx| {
|
||||
let mut view = TerminalView::from_native_ssh_parts(parts, window, cx);
|
||||
view.font_size = px(font_size);
|
||||
@@ -11170,12 +11243,12 @@ mod window_drag_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CloseReason, DOCUMENT_MIN_W, Dir, Pane, Rename, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, Tab,
|
||||
TabAgentSession, clear_window_override_values, close_prompt, document_column_px,
|
||||
join_shell_args, leaf_shares_the_window_daemon, mru_order, native_ssh_pane_alive,
|
||||
one_slot_move, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words,
|
||||
rename_outcome, side_panel_max, split_shell_args, step_in_order, strip_band,
|
||||
wd_path_saveable,
|
||||
CloseReason, DOCUMENT_MIN_W, Dir, OpenedFrom, Pane, Rename, TERMINAL_MIN_W,
|
||||
TITLE_BAR_HEIGHT, Tab, TabAgentSession, clear_window_override_values, close_prompt,
|
||||
document_column_px, inherited_start_dir, join_shell_args, leaf_shares_the_window_daemon,
|
||||
mru_order, native_ssh_pane_alive, one_slot_move, pane_free_for, parse_ssh_connect_input,
|
||||
parse_ssh_option_words, rename_outcome, side_panel_max, split_shell_args, step_in_order,
|
||||
strip_band, wd_path_saveable,
|
||||
};
|
||||
use gpui::{Edges, point, px, size};
|
||||
|
||||
@@ -11676,6 +11749,48 @@ mod tests {
|
||||
assert!(leaf_shares_the_window_daemon(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_host_opened_from_a_local_pane_does_not_start_in_its_local_directory() {
|
||||
let local = Some(std::path::PathBuf::from("/Users/alice/dev/tty7"));
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::FreshSshHost, local.clone(), false),
|
||||
None
|
||||
);
|
||||
let remote = Some(std::path::PathBuf::from("/home/alice/my_service"));
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::FreshSshHost, remote, true),
|
||||
None,
|
||||
"another host's directory means nothing on the one being dialled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redialling_the_same_ssh_host_starts_in_the_far_directory_it_was_in() {
|
||||
let remote = Some(std::path::PathBuf::from("/home/alice/my_service"));
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::SameSshHost, remote.clone(), true),
|
||||
remote
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::SameSshHost, None, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_shell_inherits_only_a_local_directory() {
|
||||
let dir = Some(std::path::PathBuf::from("/work/repo"));
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::LocalShell, dir.clone(), false),
|
||||
dir
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_start_dir(OpenedFrom::LocalShell, dir, true),
|
||||
None,
|
||||
"a remote pane's directory is on the far host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_ssh_pane_the_server_no_longer_has_is_dialled_again() {
|
||||
let mut alive = std::collections::HashMap::new();
|
||||
|
||||
@@ -59,7 +59,7 @@ impl Tty7App {
|
||||
self.bump_ssh_frecency(profile_id, cx);
|
||||
let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx));
|
||||
match at {
|
||||
SpawnWhere::NewTab => self.open_native_ssh_tab(spec, window, cx),
|
||||
SpawnWhere::NewTab => self.open_native_ssh_tab(spec, None, window, cx),
|
||||
SpawnWhere::Split => {
|
||||
self.split_into(gpui::Axis::Horizontal, Some(SpawnAs::Ssh(spec)), window, cx)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ impl Tty7App {
|
||||
cx.global::<Config>().verify_host_keys,
|
||||
&config_alias_resolver,
|
||||
);
|
||||
self.open_native_ssh_tab(Box::new(spec), window, cx);
|
||||
self.open_native_ssh_tab(Box::new(spec), None, window, cx);
|
||||
return;
|
||||
}
|
||||
let port = qc.port_or_default();
|
||||
@@ -98,7 +98,7 @@ impl Tty7App {
|
||||
profile.user = user;
|
||||
}
|
||||
let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx));
|
||||
self.open_native_ssh_tab(spec, window, cx);
|
||||
self.open_native_ssh_tab(spec, None, window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn restart_ssh_session(
|
||||
|
||||
Reference in New Issue
Block a user