mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(cwd): never run local filesystem operations on a remote pane's cwd
A remote pane's OSC 7 reports a path in the remote's namespace. The git-status probe, path completion, link resolution, the git-diff and worktree shell-outs, session persistence and cwd inheritance all took it as a local path. Nothing gated this. `refresh_git_status`'s doc claimed remote panes have no cwd, but that holds only *before* the remote shell's OSC 7 arrives — after it, a native-SSH pane's remote path was fed to a local `git`, with only `Path::exists()` incidentally saving it. That guard is weakest exactly where it matters: on Windows `/home/me/proj` is not an invalid path but a drive-relative one resolving to `C:\home\me\proj`, so a machine that has such a directory would report an unrelated repo's branch and diff as the pane's own. Correctness must not rest on that collision never happening. Add `TerminalView::local_cwd()` — the pane's cwd only when the pane is not remote — and route every local filesystem/Command consumer through it. Cwd inheritance for new tabs and splits is gated too: an inherited cwd wins over every fallback in `initial_working_directory`, so a remote path reached the spawn as a Win32 working directory. Deliberately unchanged: `tab_cwd`, so "Copy Working Directory" still copies a remote pane's remote path, which is what it is for. Not fixed here: on a remote pane, path completion falls back to tty7's own directory and link resolution stops matching relative paths. Both degrade rather than mislead now; sourcing either from the remote is a separate piece of work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2e4cc77a47
commit
4e97253b53
+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/
|
||||
|
||||
+41
-7
@@ -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
|
||||
@@ -2584,7 +2604,7 @@ impl TerminalView {
|
||||
.terminal
|
||||
.agent_session()
|
||||
.and_then(|s| s.cwd)
|
||||
.or_else(|| self.cwd());
|
||||
.or_else(|| self.local_cwd());
|
||||
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
|
||||
self.refresh_git_status(cwd_now, cx);
|
||||
}
|
||||
@@ -2595,8 +2615,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>) {
|
||||
@@ -3320,8 +3343,13 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh completion.
|
||||
let Some(cwd) = self.cwd().or_else(|| std::env::current_dir().ok()) else {
|
||||
// Fresh completion. Path candidates come off the local filesystem, so
|
||||
// the cwd must be a local one; on a remote pane this falls back to
|
||||
// tty7's own dir, which is what already happened there before OSC 7
|
||||
// landed. Command/history completion is unaffected either way. Real
|
||||
// remote-aware path completion would need the listing to come from the
|
||||
// remote and is out of scope here.
|
||||
let Some(cwd) = self.local_cwd().or_else(|| std::env::current_dir().ok()) else {
|
||||
return;
|
||||
};
|
||||
let line = self.cmd.text();
|
||||
@@ -3868,7 +3896,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),
|
||||
@@ -4020,7 +4052,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(|| {
|
||||
|
||||
+43
-9
@@ -2073,11 +2073,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
|
||||
@@ -2172,8 +2175,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
|
||||
@@ -2470,7 +2475,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);
|
||||
@@ -2517,11 +2522,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
|
||||
@@ -2655,6 +2663,23 @@ 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
|
||||
@@ -2666,7 +2691,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;
|
||||
};
|
||||
@@ -3039,7 +3064,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;
|
||||
@@ -4519,7 +4547,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