mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #133 from l0ng-ai/fix/remote-cwd-local-ops
fix(cwd): never run local filesystem operations on a remote pane's cwd
This commit is contained in:
+3
-2
@@ -8,8 +8,9 @@
|
||||
.brooks-lint-history.json
|
||||
openwiki/.last-update.json
|
||||
|
||||
# dev-build throwaway config dir (cargo dev)
|
||||
.tty7-dev/
|
||||
# dev-build throwaway config dirs (`cargo dev`, plus per-branch ones passed as
|
||||
# --config-dir so a test run can't disturb a real profile)
|
||||
.tty7-dev*/
|
||||
|
||||
# Claude Code agent worktrees (transient)
|
||||
.claude/worktrees/
|
||||
|
||||
+63
-1
@@ -250,7 +250,17 @@ fn initial_working_directory(cwd: Option<PathBuf>) -> Option<PathBuf> {
|
||||
// client didn't pass an explicit cwd (tab-inherit / session restore still
|
||||
// win). Inherit -> `forced` is `None`, so we keep the fallback as before.
|
||||
let forced = crate::core::config::working_directory_base();
|
||||
cwd.or(forced).or(fallback)
|
||||
// Whatever wins must actually be a directory *here*. A client cwd is only
|
||||
// as good as the OSC 7 that produced it, and a shell that reports a path
|
||||
// this machine cannot resolve — a remote namespace, or an msys path like
|
||||
// `/c/Users/x` that Windows reads as drive-relative — would otherwise turn
|
||||
// a new tab or split into a hard spawn failure ("The directory name is
|
||||
// invalid") instead of quietly falling back. Cheap to check, and it bounds
|
||||
// the whole class rather than one shell's spelling at a time.
|
||||
[cwd, forced, fallback]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|d| d.is_dir())
|
||||
}
|
||||
|
||||
fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<PathBuf>) {
|
||||
@@ -1774,6 +1784,16 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) {
|
||||
if st.remote == remote {
|
||||
return;
|
||||
}
|
||||
// The cwd belonged to whichever side we are leaving, and it does not
|
||||
// survive the crossing: a remote path is meaningless locally, and a local
|
||||
// one is meaningless on the remote. Drop it so the pane reports no cwd
|
||||
// until the new shell's OSC 7 lands, rather than attributing the old
|
||||
// namespace's directory to the new one — otherwise a local shell without
|
||||
// shell integration keeps serving the remote's last path to the local
|
||||
// `git` probe for the rest of its life.
|
||||
// `DaemonMsg::Cwd` carries a bare path with no "cleared" form, so the
|
||||
// client mirrors this on its own when it sees the `RemoteContext` below.
|
||||
st.cwd = None;
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::RemoteContext(remote.clone()));
|
||||
}
|
||||
@@ -2142,6 +2162,48 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
/// A cwd the client reports is only as trustworthy as the OSC 7 behind it.
|
||||
/// Passing one this machine cannot resolve straight to `cmd.cwd()` turns a
|
||||
/// new tab or split into a hard spawn failure, so anything that isn't a
|
||||
/// real directory here must fall through to the next candidate instead.
|
||||
#[test]
|
||||
fn initial_working_directory_skips_paths_that_are_not_directories() {
|
||||
let real = std::env::temp_dir();
|
||||
assert!(real.is_dir(), "temp dir should exist");
|
||||
|
||||
// A usable client cwd still wins outright.
|
||||
assert_eq!(
|
||||
initial_working_directory(Some(real.clone())),
|
||||
Some(real.clone())
|
||||
);
|
||||
|
||||
// A remote-namespace path, and the msys shape Windows reads as
|
||||
// drive-relative: neither resolves here, so neither may be used.
|
||||
for bogus in [
|
||||
"/home/someone/definitely-not-here",
|
||||
"/c/Users/definitely-not-here",
|
||||
] {
|
||||
let got = initial_working_directory(Some(PathBuf::from(bogus)));
|
||||
assert_ne!(
|
||||
got.as_deref(),
|
||||
Some(Path::new(bogus)),
|
||||
"{bogus} is not a directory here and must not be handed to spawn"
|
||||
);
|
||||
// Whatever we fall back to must itself be usable.
|
||||
if let Some(d) = got {
|
||||
assert!(d.is_dir(), "fallback {d:?} must be a real directory");
|
||||
}
|
||||
}
|
||||
|
||||
// A file is not a directory either.
|
||||
let file = real.join("tty7-iwd-probe");
|
||||
std::fs::write(&file, b"x").expect("write probe file");
|
||||
let got = initial_working_directory(Some(file.clone()));
|
||||
assert_ne!(got.as_deref(), Some(file.as_path()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// End-to-end check of the *live* agent-detection chain this feature rides
|
||||
/// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding
|
||||
|
||||
+63
-20
@@ -121,7 +121,11 @@ const MAX_CANDIDATES: usize = 400;
|
||||
/// Compute completions for `line` at char position `cursor`, resolving relative
|
||||
/// paths against `cwd`: command names in command position, filesystem paths
|
||||
/// elsewhere. Returns `None` when there's nothing to offer.
|
||||
pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
|
||||
///
|
||||
/// `cwd` is `None` when the pane has no directory on *this* machine — a remote
|
||||
/// pane. Command completion still runs; everything that would touch the local
|
||||
/// filesystem is skipped rather than answered from the wrong machine.
|
||||
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let cursor = cursor.min(chars.len());
|
||||
|
||||
@@ -141,9 +145,18 @@ pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
|
||||
// back to filesystem paths. A signature slot that declares suggestions
|
||||
// or generators owns the position: it returns `Some` (possibly with no
|
||||
// sync candidates but pending scripts) rather than ceding to paths.
|
||||
match complete_signature(&chars, word_start, &word, cwd) {
|
||||
Some(sig) => (sig.cands, sig.pending),
|
||||
None => (complete_path(&word, cwd), Vec::new()),
|
||||
match cwd {
|
||||
// No local cwd — a remote pane. The filesystem here is not the one
|
||||
// the command will run against, so offer nothing rather than this
|
||||
// machine's names, which would be inserted into a remote command
|
||||
// line where they do not exist. Command completion above still
|
||||
// works; real remote-aware path completion would have to source
|
||||
// the listing from the remote and is out of scope.
|
||||
None => (Vec::new(), Vec::new()),
|
||||
Some(cwd) => match complete_signature(&chars, word_start, &word, cwd) {
|
||||
Some(sig) => (sig.cands, sig.pending),
|
||||
None => (complete_path(&word, cwd), Vec::new()),
|
||||
},
|
||||
}
|
||||
};
|
||||
let candidates: Vec<Candidate> = word_cands
|
||||
@@ -688,7 +701,7 @@ mod tests {
|
||||
/// The candidate texts `complete` returns for `line` with the cursor at the
|
||||
/// end, or an empty vec when it offers nothing.
|
||||
fn texts(line: &str) -> Vec<String> {
|
||||
complete(line, line.chars().count(), Path::new("/"))
|
||||
complete(line, line.chars().count(), Some(Path::new("/")))
|
||||
.map(|c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -699,7 +712,7 @@ mod tests {
|
||||
assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}");
|
||||
assert!(t.iter().any(|s| s == "status"));
|
||||
// Descriptions ride along for the menu's second column.
|
||||
let c = complete("git ", 4, Path::new("/")).unwrap();
|
||||
let c = complete("git ", 4, Some(Path::new("/"))).unwrap();
|
||||
let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap();
|
||||
assert_eq!(commit.kind, CandidateKind::Value);
|
||||
assert!(commit.description.is_some());
|
||||
@@ -714,7 +727,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn signature_offers_flags_for_the_active_subcommand() {
|
||||
let c = complete("git commit --", 13, Path::new("/")).unwrap();
|
||||
let c = complete("git commit --", 13, Some(Path::new("/"))).unwrap();
|
||||
let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap();
|
||||
assert_eq!(msg.kind, CandidateKind::Flag);
|
||||
assert_eq!(
|
||||
@@ -741,7 +754,8 @@ mod tests {
|
||||
// position — it returns the generator scripts and no path candidates.
|
||||
let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]);
|
||||
let line = "git checkout ";
|
||||
let c = complete(line, line.chars().count(), &dir).expect("generator slot is a completion");
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path()))
|
||||
.expect("generator slot is a completion");
|
||||
assert!(
|
||||
!c.pending.is_empty(),
|
||||
"the branch/tag generators ride along as pending scripts"
|
||||
@@ -767,7 +781,7 @@ mod tests {
|
||||
fn generator_script_tokens_join_with_single_spaces() {
|
||||
// The converter word-split original string scripts; joining restores a
|
||||
// single `/bin/sh -c` command.
|
||||
let c = complete("git checkout ", 13, Path::new("/")).unwrap();
|
||||
let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
|
||||
let branch = c
|
||||
.pending
|
||||
.iter()
|
||||
@@ -825,7 +839,12 @@ mod tests {
|
||||
fn unknown_command_falls_back_to_paths() {
|
||||
// A command with no signature still path-completes (no panic, no menu here).
|
||||
let dir = temp_tree("fallback", &[("readme.md", false)]);
|
||||
let c = complete("frobnicate read", "frobnicate read".chars().count(), &dir).unwrap();
|
||||
let c = complete(
|
||||
"frobnicate read",
|
||||
"frobnicate read".chars().count(),
|
||||
Some(dir.as_path()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.candidates[0].text, "readme.md");
|
||||
}
|
||||
|
||||
@@ -925,7 +944,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_position_offers_builtins_with_word_range() {
|
||||
let c = complete("ech", 3, Path::new("/")).unwrap();
|
||||
let c = complete("ech", 3, Some(Path::new("/"))).unwrap();
|
||||
let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap();
|
||||
assert_eq!(echo.kind, CandidateKind::Command);
|
||||
assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech"
|
||||
@@ -938,7 +957,7 @@ mod tests {
|
||||
&[("apple.txt", false), ("apply.sh", false), ("assets", true)],
|
||||
);
|
||||
let line = "cat a";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
// Closeness order: assets(6) < apply.sh(8) < apple.txt(9).
|
||||
assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]);
|
||||
@@ -952,7 +971,7 @@ mod tests {
|
||||
let dir = temp_tree("nested", &[("sub", true)]);
|
||||
std::fs::write(dir.join("sub/file.rs"), b"").unwrap();
|
||||
let line = "cat sub/f";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
assert_eq!(c.candidates[0].text, "sub/file.rs");
|
||||
assert_eq!(c.candidates[0].start, 4);
|
||||
}
|
||||
@@ -960,9 +979,9 @@ mod tests {
|
||||
#[test]
|
||||
fn hidden_files_only_with_dot_prefix() {
|
||||
let dir = temp_tree("hidden", &[(".secret", false), ("visible", false)]);
|
||||
let c = complete("ls v", 4, &dir).unwrap();
|
||||
let c = complete("ls v", 4, Some(dir.as_path())).unwrap();
|
||||
assert!(c.candidates.iter().all(|c| !c.text.starts_with('.')));
|
||||
let c = complete("ls .", 4, &dir).unwrap();
|
||||
let c = complete("ls .", 4, Some(dir.as_path())).unwrap();
|
||||
assert!(c.candidates.iter().any(|c| c.text == ".secret"));
|
||||
}
|
||||
|
||||
@@ -978,18 +997,42 @@ mod tests {
|
||||
],
|
||||
);
|
||||
let line = "cat x";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]);
|
||||
}
|
||||
|
||||
/// A remote pane has no local cwd. Path candidates must come back empty
|
||||
/// rather than from tty7's own directory — inserting a local filename into
|
||||
/// a remote command line names a file that isn't there. Command completion
|
||||
/// is unaffected: it reads `$PATH`, not the cwd.
|
||||
#[test]
|
||||
fn a_remote_pane_completes_commands_but_never_local_paths() {
|
||||
let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]);
|
||||
|
||||
// With a local cwd the file is offered...
|
||||
let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths");
|
||||
assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here")));
|
||||
|
||||
// ...and with none it is not, from the same line.
|
||||
assert!(complete("cat only", 8, None).is_none());
|
||||
// Nor does a bare argument position dump anything.
|
||||
assert!(complete("cat ", 4, None).is_none());
|
||||
// A signature-owned slot must not shell out to a local generator either.
|
||||
assert!(complete("git checkout ", 13, None).is_none());
|
||||
|
||||
// Command position still works — that source never touches the cwd.
|
||||
let c = complete("ech", 3, None).expect("command completion needs no cwd");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_returns_none() {
|
||||
let dir = temp_tree("empty", &[("zzz", false)]);
|
||||
assert!(complete("cat q", 5, &dir).is_none());
|
||||
assert!(complete("cat q", 5, Some(dir.as_path())).is_none());
|
||||
// A blank line offers nothing (no dump of every command on bare Tab).
|
||||
assert!(complete("", 0, &dir).is_none());
|
||||
assert!(complete(" ", 3, &dir).is_none());
|
||||
assert!(complete("", 0, Some(dir.as_path())).is_none());
|
||||
assert!(complete(" ", 3, Some(dir.as_path())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -997,7 +1040,7 @@ mod tests {
|
||||
// Caret sits right after "ap" with more text following; the candidate
|
||||
// replaces only `word_start..cursor`, leaving the tail untouched.
|
||||
let dir = temp_tree("midline", &[("apple.txt", false)]);
|
||||
let c = complete("cat ap x.log", 6, &dir).unwrap();
|
||||
let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap();
|
||||
let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap();
|
||||
assert_eq!((apple.start, apple.end), (4, 6));
|
||||
// Applying it splices over just that range.
|
||||
|
||||
@@ -668,6 +668,17 @@ impl RemoteTerminal {
|
||||
}
|
||||
DaemonMsg::RemoteContext(ctx) => {
|
||||
flush_batch!();
|
||||
// Crossing the local/remote boundary invalidates
|
||||
// the cwd: it names a directory in the namespace
|
||||
// we just left. Drop it so the pane reports none
|
||||
// until the new shell's OSC 7 lands — otherwise
|
||||
// an `exit` from `ssh` leaves the remote's last
|
||||
// path in place, and a local shell without shell
|
||||
// integration never overwrites it, so the local
|
||||
// `git` probe keeps running against it.
|
||||
if let Ok(mut guard) = cwd.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = remote.lock() {
|
||||
*guard = ctx;
|
||||
}
|
||||
|
||||
+60
-12
@@ -1065,6 +1065,26 @@ impl TerminalView {
|
||||
self.terminal.remote_context()
|
||||
}
|
||||
|
||||
/// The pane's cwd *only when it names a directory on this machine* — the
|
||||
/// accessor every local filesystem or `Command` use must go through.
|
||||
///
|
||||
/// A remote pane's OSC 7 reports a path in the remote's namespace
|
||||
/// (`/home/me/proj` from an SSH host). Feeding that to a local `git` or
|
||||
/// `read_dir` is meaningless, and on Windows it is worse than meaningless:
|
||||
/// `/home/me/proj` is not an absolute path there but a *drive-relative*
|
||||
/// one, so it silently resolves to `C:\home\me\proj`. That usually just
|
||||
/// fails an `exists()` check — but if such a directory happens to exist,
|
||||
/// the pane reports an unrelated local repo's branch and diff as its own.
|
||||
/// Correctness must not rest on that collision never happening.
|
||||
///
|
||||
/// Note this gates on the pane being remote, not on the shape of the path:
|
||||
/// a local shell may legitimately sit in a directory whose name looks
|
||||
/// remote, and Git Bash reports genuinely local paths (via `pwd -W`) that
|
||||
/// merely originate from a POSIX-looking shell.
|
||||
pub fn local_cwd(&self) -> Option<std::path::PathBuf> {
|
||||
self.remote_context().is_none().then(|| self.cwd())?
|
||||
}
|
||||
|
||||
/// The coding agent running in this pane's foreground, or `None` when none
|
||||
/// is. Identity comes from the daemon's foreground-`argv` detection (plus
|
||||
/// the sentinel event channel, which can brand wrappers argv can't see
|
||||
@@ -2600,11 +2620,22 @@ impl TerminalView {
|
||||
// doesn't (Windows). The claim dies with the session (`session-end`
|
||||
// clears it, and the agent leaving the foreground drops the whole
|
||||
// state), so an exited agent falls back to the pane's real directory.
|
||||
// The agent's report goes through the same remote gate as the pane's own
|
||||
// cwd. A native-SSH pane keeps sentinel-sourced agent state on purpose
|
||||
// (`spawn_native_ssh`), so an agent running *on the remote host* reports
|
||||
// a remote path — and being first in the chain it would win over
|
||||
// `local_cwd` unconditionally and hand that path straight to the local
|
||||
// `git`, which is the collision `local_cwd` exists to prevent.
|
||||
let cwd_now = self
|
||||
.terminal
|
||||
.agent_session()
|
||||
.and_then(|s| s.cwd)
|
||||
.or_else(|| self.cwd());
|
||||
.remote_context()
|
||||
.is_none()
|
||||
.then(|| {
|
||||
self.terminal
|
||||
.agent_session()
|
||||
.and_then(|s| s.cwd)
|
||||
.or_else(|| self.cwd())
|
||||
})
|
||||
.flatten();
|
||||
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
|
||||
self.refresh_git_status(cwd_now, cx);
|
||||
}
|
||||
@@ -2615,8 +2646,11 @@ impl TerminalView {
|
||||
/// brackets the flight (`begin_probe`/`finish_probe`): a probe already in
|
||||
/// flight for the same cwd absorbs this trigger instead of spawning a
|
||||
/// duplicate `git` shell-out, and reruns once when it lands. With no cwd
|
||||
/// (e.g. a native-SSH pane pre-OSC-7, where a local `git` would be
|
||||
/// meaningless) the pane simply stops reading a status.
|
||||
/// (e.g. a remote pane, where a local `git` would be meaningless) the pane
|
||||
/// simply stops reading a status. Callers must source the cwd from
|
||||
/// [`local_cwd`](Self::local_cwd): a remote pane *does* get a cwd once its
|
||||
/// OSC 7 lands, so "remote panes have no cwd" holds only before that and
|
||||
/// cannot be what keeps the local probe away from a remote path.
|
||||
///
|
||||
/// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache
|
||||
fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
|
||||
@@ -3344,13 +3378,18 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh completion.
|
||||
let Some(cwd) = self.cwd().or_else(|| std::env::current_dir().ok()) else {
|
||||
return;
|
||||
// Fresh completion. Path candidates come off the local filesystem, so
|
||||
// they need a local cwd — a remote pane passes `None` and gets command
|
||||
// completion only. Falling back to tty7's own directory there would
|
||||
// offer *this* machine's filenames for insertion into a remote command
|
||||
// line, where they don't exist.
|
||||
let cwd = match self.remote_context() {
|
||||
Some(_) => None,
|
||||
None => self.local_cwd().or_else(|| std::env::current_dir().ok()),
|
||||
};
|
||||
let line = self.cmd.text();
|
||||
let cursor = self.cmd.cursor();
|
||||
let Some(comp) = super::completion::complete(&line, cursor, &cwd) else {
|
||||
let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3400,6 +3439,9 @@ impl TerminalView {
|
||||
|
||||
// Kick off each generator on the background executor and merge results
|
||||
// back on the main thread, tagged with this session's generation.
|
||||
// Generators are local shell-outs and only ever come from `complete`'s
|
||||
// `Some(cwd)` branch, so a remote pane has none to run.
|
||||
let Some(cwd) = cwd else { return };
|
||||
for pending in comp.pending {
|
||||
let script = pending.script;
|
||||
let cwd = cwd.clone();
|
||||
@@ -3925,7 +3967,11 @@ impl TerminalView {
|
||||
text.push(term.grid()[line][Column(c)].c);
|
||||
}
|
||||
drop(term);
|
||||
let cwd = self.cwd();
|
||||
// A relative path in the output is resolved against the cwd and
|
||||
// stat-checked, then handed to the local file opener — so a remote
|
||||
// pane's cwd must not be used. There, only absolute-looking local hits
|
||||
// and URLs remain clickable.
|
||||
let cwd = self.local_cwd();
|
||||
if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) {
|
||||
match link.target {
|
||||
LinkTarget::Url(url) => self.open_url(&url, cx),
|
||||
@@ -4077,7 +4123,9 @@ impl TerminalView {
|
||||
text.push(term.grid()[line][Column(c)].c);
|
||||
}
|
||||
drop(term);
|
||||
let cwd = self.cwd();
|
||||
// Same gate as the click path above — hover must not underline a link
|
||||
// the click cannot open.
|
||||
let cwd = self.local_cwd();
|
||||
let link =
|
||||
super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| {
|
||||
include_loopback.then(|| {
|
||||
|
||||
+38
-9
@@ -2077,11 +2077,14 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Inherit the cwd of the active tab's focused terminal so the new tab
|
||||
// opens in the same directory the user is currently working in.
|
||||
// opens in the same directory the user is currently working in. Local
|
||||
// cwds only: the new tab is a local shell, and an inherited cwd wins
|
||||
// over every fallback in `pane::initial_working_directory`, so a remote
|
||||
// path would be handed straight to the spawn as a working directory.
|
||||
let cwd = self.tabs.get(self.active).and_then(|t| {
|
||||
t.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
.and_then(|leaf| leaf.read(cx).local_cwd())
|
||||
});
|
||||
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
|
||||
// Leaving the current tab for the new one; snapshot its focused pane
|
||||
@@ -2176,8 +2179,10 @@ impl Tty7App {
|
||||
};
|
||||
// The new pane inherits the cwd — and the shell, when the pane being
|
||||
// split was opened with an explicit pick (a WSL/fish tab splits into
|
||||
// more WSL/fish, not back to the default).
|
||||
let cwd = target.read(cx).cwd();
|
||||
// more WSL/fish, not back to the default). Local cwds only: the
|
||||
// native-SSH branch below has the daemon discard it regardless, and the
|
||||
// local branch would otherwise spawn against a remote path.
|
||||
let cwd = target.read(cx).local_cwd();
|
||||
// Splitting a native-SSH pane opens another SSH pane on the same
|
||||
// connection rather than dropping back to a local shell. Re-resolve the
|
||||
// persisted (secret-free) spec from its saved profile so keychain
|
||||
@@ -2474,7 +2479,7 @@ impl Tty7App {
|
||||
// Capture the tab's cwd *before* its panes are killed (the daemon can't
|
||||
// report it afterwards): if it sat in a tty7-managed worktree, the
|
||||
// cleanup offer below needs it.
|
||||
let worktree_cwd = self.tab_cwd(index, window, cx);
|
||||
let worktree_cwd = self.tab_local_cwd(index, window, cx);
|
||||
// Snapshot the tab (layout + each pane's current cwd + name) onto the
|
||||
// recently-closed stack so Cmd+Shift+T can bring it back.
|
||||
let snapshot = tab_to_session(&self.tabs[index], cx);
|
||||
@@ -2521,11 +2526,14 @@ impl Tty7App {
|
||||
let Some(cwd) = cwd else { return };
|
||||
// Every leaf of every surviving tab, not just focused panes — a shell
|
||||
// tucked away in a split occupies the worktree all the same.
|
||||
// Local paths only: this list is what stops a worktree being removed
|
||||
// out from under a live shell, and a remote cwd can neither occupy a
|
||||
// local worktree nor be compared against one meaningfully.
|
||||
let open_cwds: Vec<std::path::PathBuf> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.pane.leaves())
|
||||
.filter_map(|leaf| leaf.read(cx).cwd())
|
||||
.filter_map(|leaf| leaf.read(cx).local_cwd())
|
||||
.collect();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Some(wt) = cx
|
||||
@@ -2659,6 +2667,18 @@ impl Tty7App {
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
}
|
||||
|
||||
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
|
||||
/// for the worktree operations, which shell out to a local `git`. "Copy
|
||||
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
|
||||
/// pane's remote path is exactly what the user wants there.
|
||||
fn tab_local_cwd(&self, index: usize, window: &Window, cx: &App) -> Option<std::path::PathBuf> {
|
||||
self.tabs
|
||||
.get(index)?
|
||||
.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).local_cwd())
|
||||
}
|
||||
|
||||
/// "New Worktree Tab": probe the repository containing the tab's cwd for
|
||||
/// defaults (a fresh generated name, the current branch as start point) on
|
||||
/// the background executor, then open the confirmation sheet
|
||||
@@ -2670,7 +2690,7 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(cwd) = self.tab_cwd(index, window, cx) else {
|
||||
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
|
||||
window.push_notification("This tab has no working directory yet", cx);
|
||||
return;
|
||||
};
|
||||
@@ -3043,7 +3063,10 @@ impl Tty7App {
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.and_then(|view| view.read(cx).cwd());
|
||||
// Local `git` shell-out, so a remote pane's cwd is not usable —
|
||||
// it reports "no known directory" rather than silently diffing
|
||||
// whatever the path collides with locally.
|
||||
.and_then(|view| view.read(cx).local_cwd());
|
||||
let Some(cwd) = cwd else {
|
||||
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
|
||||
return;
|
||||
@@ -4523,7 +4546,13 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
Pane::Leaf(view) => {
|
||||
let view = view.read(cx);
|
||||
SessionPane::Leaf {
|
||||
cwd: view.cwd(),
|
||||
// Local cwd only. A restored pane whose daemon pane is gone
|
||||
// respawns on the *default local shell* (a shell pick isn't
|
||||
// persisted), so a remote cwd would come back paired with a
|
||||
// local shell that cannot chdir into it. Native-SSH panes
|
||||
// reconnect from `ssh_spec` and the daemon discards the cwd
|
||||
// for them anyway (`server::SpawnNativeSsh`).
|
||||
cwd: view.local_cwd(),
|
||||
pane_id: Some(view.pane_id),
|
||||
// Persist the secret-free native-SSH spec so a *dead* pane can be
|
||||
// reconnected on restore (FR-E4/C2); `None` for local panes. A
|
||||
|
||||
Reference in New Issue
Block a user