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:
l0ng-ai
2026-09-30 15:02:22 +08:00
committed by GitHub
parent 0277ca67dc
commit ab029f5ea1
9 changed files with 439 additions and 64 deletions
+44 -3
View File
@@ -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);
+9
View File
@@ -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>,
+8 -2
View File
@@ -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;
+185 -10
View File
@@ -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() {
+26 -5
View File
@@ -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
View File
@@ -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,
}
+8 -8
View File
@@ -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
View File
@@ -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();
+3 -3
View File
@@ -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(