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:
l0ng-ai
2026-07-19 19:31:05 +08:00
committed by GitHub
6 changed files with 238 additions and 44 deletions
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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