diff --git a/.gitignore b/.gitignore index 2a91e011..2e73c3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7bf44e45..27257844 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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 { + 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, cx: &mut Context) { @@ -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(|| { diff --git a/src/ui/app.rs b/src/ui/app.rs index a4fb4435..677dbf46 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2073,11 +2073,14 @@ impl Tty7App { cx: &mut Context, ) { // 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 = 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 { + 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, ) { - 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