diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6231c64..ea729d45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
ls --json` carry a `hibernated` flag. Offered only where the machine's server
can do it (this build or newer).
+- **A port forward can be switched off without losing its rule** (#439). A
+ forward could only be removed, so pointing one local port at a different
+ remote target meant deleting the rule and typing the other one in again. Each
+ forward in the Ports section, and each rule under Settings → SSH → Port
+ forwarding, now has a switch: off releases the listener and keeps the rule in
+ the list, on binds it again from the rule it was made from. Switching one on
+ while another switched-on forward holds its port is refused with a notice
+ naming that forward, rather than failing at bind time. A switch flipped in the
+ panel on a rule that came from a saved host is written back to that host, so
+ the next connection opens the same set; a rule saved switched off is listed
+ but not opened. Profiles saved before this load with every rule on.
+
+- **SSH tabs can be named after the host instead of whatever the remote shell
+ titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is
+ *Dynamic* (the default, and what tty7 always did), *Profile name* — the saved
+ host's name, the alias for a `~/.ssh/config` host, the address typed for a
+ quick connect — or *Hostname*, the address dialled. Only the tab's name is
+ pinned: OSC 0/2 titles are still tracked and come back the moment it is
+ *Dynamic* again, a tab you renamed keeps its name, a split tab follows the
+ pane in front as before, and an ended session still says so. The key is
+ `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`).
+
+### Changed
+
+- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so
+ `config.json` can be synced between machines for its colours and keys
+ without carrying a list of servers. `ssh_profiles` and
+ `ssh_profile_frecency` move across by themselves the first time a new build
+ reads an older `config.json`: `servers.json` is written first (mode `0600`),
+ and only once it has landed are the two keys taken out of `config.json` —
+ nothing else in that file is touched, including keys this build does not
+ know. When both files hold hosts, `servers.json` wins and the stale copy in
+ `config.json` is dropped at its next save. A `servers.json` that cannot be
+ parsed is kept aside as `servers.json.corrupt` and saving is refused until it
+ is repaired, the same rule `config.json` has. Hand edits to `servers.json`
+ hot-reload. Passwords and passphrases stay in the OS keychain.
+
+- **`tty7 exec` runs a command in a pane that already exists and hands back its
+ result** (#839). `run` makes a new pane and `send` does not wait, so every
+ script that wanted a command's exit code from a shell it already had ended up
+ as `send`, a sleep, a `capture` and a guess, or an `echo $? > /tmp/rc` side
+ channel. `tty7 exec %3 -- cargo test` types the line at the pane's prompt,
+ follows the shell integration's marks to the command's end, prints what it
+ printed — as text, the way `capture --plain` reads it, or escapes intact with
+ `--raw` — and exits with its exit code. `--timeout` gives up with 124 and
+ leaves the command running; a pane with no prompt marks, or one that is not
+ at a prompt, is refused before anything is typed instead of waited on.
+
+- **`tty7 send` takes its text from stdin or a file, and can paste it**
+ (#838). A token passed as `send %1 "$TOKEN"` sits in the CLI's command line,
+ where any local user reads it in `ps`, and in the caller's shell history;
+ `--stdin` and `--from-file` send the bytes exactly as read and never echo
+ them in `--json`. `--paste` sends the text the way a paste into the window
+ does — in bracketed paste when the pane has switched it on, so a multi-line
+ text arrives as text instead of running line by line, with any ESC stripped
+ so the text cannot close the paste itself. A pane without the mode gets the
+ same unframed paste the GUI would send it, and `--json` says which one went.
+ The server now reports each pane's bracketed-paste mode in `tty7 procs`.
+
+- **The source control panel's changed files can be filtered and shown as a
+ tree** (#473). A filter field sits above the list: every word typed has to
+ appear somewhere in a file's path, in any order and any case, and the group
+ headers' counts and their stage/unstage/discard-all buttons follow what the
+ filter leaves on screen. The tile at its end switches the list to a
+ directory tree — directories before files, a chain of directories that hold
+ nothing but the next one compacted into a single row (`crates/core/src`),
+ each directory row foldable and counting the files beneath it. A filter
+ opens every folded directory while it is set. The choice between list and
+ tree is remembered in the config. Both are built from the status the host
+ already sends, so a remote repository gets them unchanged. Searching the
+ diff text itself is not part of this.
+
+### Fixed
+
+- **Nerd Font icons from a fallback font come out at the text's size** (#866).
+ With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary
+ that lacks the icons, an icon followed by a space on the same background —
+ every icon in a coloured Powerline or p10k segment — was held to one cell
+ and shrunk to about two thirds of the text's height. Icons with a plain
+ space after them were already drawn full size, so one prompt had icons in
+ two sizes. A lone Private Use Area glyph supplied by a fallback face is now
+ fitted, aspect ratio kept, to its cells and one em of height, taking the
+ blank after it when that blank paints no background or the icon's own and
+ when doing so makes it bigger, and centred in the row; a face whose icons
+ ink less than a cell grows (at most 2×). The Powerline separators, CJK,
+ emoji, other text and the primary font's own icons are drawn as before.
+ Not on Linux, where the text system reports a glyph's advance box rather
+ than its ink.
+
## [26.9.3] - 2026-09-23
### Added
diff --git a/assets/icons/list-flat.svg b/assets/icons/list-flat.svg
new file mode 100644
index 00000000..b6249b13
--- /dev/null
+++ b/assets/icons/list-flat.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/list-tree.svg b/assets/icons/list-tree.svg
new file mode 100644
index 00000000..04086a1a
--- /dev/null
+++ b/assets/icons/list-tree.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/power.svg b/assets/icons/power.svg
new file mode 100644
index 00000000..16fc83f7
--- /dev/null
+++ b/assets/icons/power.svg
@@ -0,0 +1 @@
+
diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs
index 448cfc66..7807524d 100644
--- a/crates/tty7-cli/src/backend.rs
+++ b/crates/tty7-cli/src/backend.rs
@@ -61,6 +61,17 @@ pub trait Backend {
fn run_wait(&mut self) -> Result>;
+ /// Type `line` at the pane's shell prompt and follow the pane until its
+ /// prompt comes back, the deadline passes, or it exits. Refuses — sending
+ /// nothing — a pane with no shell integration or one that is not at a
+ /// prompt; see [`ExecEnd`](crate::exec::ExecEnd).
+ fn exec(
+ &mut self,
+ pane: u64,
+ line: Vec,
+ timeout: Option,
+ ) -> Result;
+
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
}
@@ -78,6 +89,7 @@ pub mod mock {
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
use super::{Backend, CaptureSegment, RunSpec};
+ use crate::exec::{ExecEnd, ExecRun};
pub struct MockBackend {
pub machine: Machine,
@@ -103,6 +115,8 @@ pub mod mock {
pub events: Vec,
/// Set to make `hello` fail — doctor's unreachable-server branch.
pub unreachable: bool,
+ pub execs: Vec<(u64, Vec, Option)>,
+ pub exec_run: ExecRun,
}
impl Default for MockBackend {
@@ -127,6 +141,21 @@ pub mod mock {
run_exit: Some(0),
events: Vec::new(),
unreachable: false,
+ execs: Vec::new(),
+ exec_run: ExecRun {
+ end: ExecEnd::Finished {
+ exit: Some(0),
+ ran: true,
+ },
+ output: Vec::new(),
+ dropped: 0,
+ size: tty7_core::daemon::protocol::WinSize {
+ cols: 80,
+ rows: 24,
+ cell_w: 8,
+ cell_h: 16,
+ },
+ },
}
}
}
@@ -219,6 +248,16 @@ pub mod mock {
Ok(self.run_exit)
}
+ fn exec(
+ &mut self,
+ pane: u64,
+ line: Vec,
+ timeout: Option,
+ ) -> Result {
+ self.execs.push((pane, line, timeout));
+ Ok(self.exec_run.clone())
+ }
+
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
for event in self.events.drain(..) {
on_event(event)?;
diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs
index 9447eac6..8723d2ea 100644
--- a/crates/tty7-cli/src/backend/real.rs
+++ b/crates/tty7-cli/src/backend/real.rs
@@ -13,6 +13,7 @@ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, Win
use tty7_core::daemon::router::RouteTarget;
use super::{Backend, CaptureSegment, RunSpec};
+use crate::exec::{ExecEnd, ExecRun, Transcript};
const SESSION_SIZE: WinSize = WinSize {
cols: 120,
@@ -23,6 +24,9 @@ const SESSION_SIZE: WinSize = WinSize {
const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10);
const REPLAY_SETTLE: Duration = Duration::from_millis(300);
+/// A backstop on reading the replay: it ends on the first live output or a
+/// quiet spell, and this only bounds a daemon that keeps sending status.
+const EXEC_REPLAY_MAX: Duration = Duration::from_secs(10);
const NOT_RUNNING: &str =
"could not reach the tty7 server on this machine — `tty7 server start` brings one up";
@@ -275,6 +279,113 @@ impl Backend for RealBackend {
Ok(code)
}
+ fn exec(&mut self, pane: u64, line: Vec, timeout: Option) -> Result {
+ let deadline = timeout.and_then(|t| std::time::Instant::now().checked_add(t));
+ // Watching starts before anything is typed, so no byte the command
+ // prints can go past unseen. The replay that comes first is only read
+ // for the pane's state — the daemon sends its prompt report last.
+ let mut session = self
+ .pane_client()?
+ .observe(pane, SESSION_SIZE)
+ .with_context(|| format!("observing pane %{pane}"))?;
+ let mut size = SESSION_SIZE;
+ // (integration active, at a prompt), as the daemon last reported them.
+ let mut prompt: Option<(bool, bool)> = None;
+ let until = std::time::Instant::now() + EXEC_REPLAY_MAX;
+ let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT));
+ let finish = |session: PaneSession, end, size| -> Result {
+ let _ = session.detach();
+ Ok(ExecRun {
+ end,
+ output: Vec::new(),
+ dropped: 0,
+ size,
+ })
+ };
+ loop {
+ match session.recv() {
+ Ok(DaemonMsg::Size(seen)) => size = seen,
+ Ok(DaemonMsg::Prompt {
+ active, at_prompt, ..
+ }) => prompt = Some((active, at_prompt)),
+ Ok(DaemonMsg::Exited { code }) => {
+ return finish(session, ExecEnd::PaneExited(code), size);
+ }
+ // Live output: the replay is over, and so is `capture`'s
+ // reading of it. A pane still printing is not at a prompt,
+ // and its report has already said so.
+ Ok(DaemonMsg::Output(_)) => break,
+ Ok(_) => {}
+ Err(e) if timed_out(&e) => break,
+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
+ return finish(session, ExecEnd::PaneExited(None), size);
+ }
+ Err(e) => return Err(anyhow!(e).context("reading the pane replay")),
+ }
+ if std::time::Instant::now() >= until {
+ break;
+ }
+ let _ = session.set_recv_timeout(Some(REPLAY_SETTLE));
+ }
+ match prompt {
+ Some((true, true)) => {}
+ Some((true, false)) => return finish(session, ExecEnd::Busy, size),
+ _ => return finish(session, ExecEnd::NoIntegration, size),
+ }
+
+ self.send_input(pane, line)?;
+ let mut transcript = Transcript::new();
+ let end = loop {
+ let wait = match deadline {
+ Some(d) => {
+ let left = d.saturating_duration_since(std::time::Instant::now());
+ if left.is_zero() {
+ break ExecEnd::TimedOut {
+ started: transcript.started(),
+ };
+ }
+ Some(left)
+ }
+ None => None,
+ };
+ let _ = session.set_recv_timeout(wait);
+ match session.recv() {
+ Ok(DaemonMsg::Output(bytes)) => {
+ transcript.output(&bytes);
+ transcript.compact();
+ }
+ Ok(DaemonMsg::Size(seen)) => size = seen,
+ Ok(DaemonMsg::Prompt {
+ at_prompt,
+ last_exit,
+ ..
+ }) => {
+ if let Some(done) = transcript.prompt(at_prompt, last_exit) {
+ break ExecEnd::Finished {
+ exit: done.exit,
+ ran: done.ran,
+ };
+ }
+ }
+ Ok(DaemonMsg::Exited { code }) => break ExecEnd::PaneExited(code),
+ Ok(_) => {}
+ Err(e) if timed_out(&e) => continue,
+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
+ break ExecEnd::PaneExited(None);
+ }
+ Err(e) => return Err(anyhow!(e).context("following the command's output")),
+ }
+ };
+ let _ = session.detach();
+ let (output, dropped) = transcript.take();
+ Ok(ExecRun {
+ end,
+ output,
+ dropped,
+ size,
+ })
+ }
+
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
let client = self.control_client()?;
for event in client.events() {
diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs
index 7cfd0b73..368d3be2 100644
--- a/crates/tty7-cli/src/cli.rs
+++ b/crates/tty7-cli/src/cli.rs
@@ -72,11 +72,35 @@ pub enum Command {
// written out here: a hand-copied vocabulary drifts the first time a key
// is added, and this is the text a caller reaches for to learn the names.
#[command(
- about = "Type text into a pane, or send it keystrokes with --key",
+ about = "Type text into a pane — from an argument, --stdin or --from-file — or send \
+ it keystrokes with --key",
long_about = crate::keys::send_long_help()
)]
Send(SendArgs),
+ // `run` is the other half of this pair, and the split is docker's: `run`
+ // makes a new place for a command, `exec` uses one that already exists.
+ // Not a flag on `run` — that verb execs an argv in a fresh pty, while this
+ // types a line into a shell that is already sitting at its prompt, and
+ // none of `run`'s flags (`--keep`, `--cwd`, `--ws`) mean anything here.
+ #[command(
+ about = "Run a command line in an existing pane's shell, wait for its prompt to \
+ come back, print what the command printed, and exit with its exit code",
+ long_about = "Run a command line in an existing pane's shell, wait for its prompt to \
+ come back, print what the command printed, and exit with its exit code.\n\n\
+ The line is typed at the shell's prompt, exactly as `send … --enter` \
+ would, so it runs with that shell's cwd, environment and history. The \
+ pane's shell integration is what says where the command's output starts \
+ and ends and what its exit code was: a pane with none (no OSC 133 prompt \
+ marks yet) is refused rather than waited on, and so is a pane that is not \
+ at a prompt. The output is what the pane printed between the command \
+ starting and the prompt coming back, replayed through a terminal to \
+ text the way `capture --plain` does — or as raw bytes with `--raw`.\n\n\
+ A line the shell never runs — an unclosed quote waiting on a continuation \
+ prompt — has no end to wait for; pass --timeout in anything unattended."
+ )]
+ Exec(ExecArgs),
+
#[command(
about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \
escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \
@@ -158,6 +182,47 @@ pub struct RunArgs {
pub cmd: Vec,
}
+#[derive(Debug, Args)]
+pub struct ExecArgs {
+ #[arg(
+ value_name = "%PANE",
+ help = "Pane whose shell runs the command; defaults to $TTY7_PANE inside a tty7 shell"
+ )]
+ pub target: Option,
+
+ #[arg(
+ long,
+ value_name = "SECS",
+ help = "Give up after this many seconds, with exit code 124; the command is left \
+ running in the pane"
+ )]
+ pub timeout: Option,
+
+ // Text is the default here, unlike `capture`: what a caller of this verb
+ // wants is the answer, and the raw bytes between the marks are not only
+ // the command's — zsh draws its PROMPT_SP (the `%` that marks a missing
+ // newline, then a row of spaces and a CR) *before* its precmd hooks run,
+ // so before the `D` mark. Replayed through a grid that erases itself, as
+ // it does on screen; left raw it is a line of noise after every command.
+ #[arg(
+ long,
+ help = "Print the output as the pane received it, escapes intact, instead of \
+ replaying it through a terminal to text (the `capture` default)"
+ )]
+ pub raw: bool,
+
+ // Joined with spaces and handed to the pane's shell as one line, which is
+ // what separates this from `run`: `tty7 exec %3 -- 'make && ls'` means the
+ // shell's `&&`, where `run` would look for a program called `make && ls`.
+ #[arg(
+ last = true,
+ required = true,
+ value_name = "CMD",
+ help = "The command line, after `--`: tty7 exec %3 -- cargo test"
+ )]
+ pub cmd: Vec,
+}
+
#[derive(Debug, Args)]
#[command(group = ArgGroup::new("axis").required(true))]
pub struct SplitArgs {
@@ -221,6 +286,34 @@ pub struct SendArgs {
(C-c, escape, up, enter, …). See `tty7 send --help`"
)]
pub keys: Vec,
+
+ // The other place the text can come from. A secret in an argument is in
+ // this process's command line, where every local user can read it in
+ // `ps`, and in the caller's own shell history; reading it from a pipe or a
+ // file keeps it out of both. The bytes go to the pty exactly as read.
+ #[arg(
+ long,
+ conflicts_with_all = ["from_file", "second"],
+ help = "Send what stdin holds, byte for byte, instead of TEXT — keeps a secret \
+ out of `ps` and your shell history"
+ )]
+ pub stdin: bool,
+
+ #[arg(
+ long,
+ value_name = "PATH",
+ conflicts_with = "second",
+ help = "Send the file's contents, byte for byte, instead of TEXT"
+ )]
+ pub from_file: Option,
+
+ #[arg(
+ long,
+ help = "Send the text as a paste, the way the GUI pastes: framed in bracketed \
+ paste when the pane has switched it on, so the lines of a multi-line text \
+ arrive as text rather than each running on its own"
+ )]
+ pub paste: bool,
}
/// One resting place a `wait` can end on. Three ontologies meet here, which is
@@ -945,4 +1038,54 @@ mod tests {
let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err();
assert_eq!(err.exit_code(), 2);
}
+
+ #[test]
+ fn exec_takes_an_address_then_the_command_after_the_separator() {
+ let Some(Command::Exec(args)) = parse(&[
+ "tty7",
+ "exec",
+ "%3",
+ "--timeout",
+ "5",
+ "--",
+ "make",
+ "--raw",
+ ])
+ .command
+ else {
+ panic!("exec did not parse");
+ };
+ assert_eq!(args.target.as_deref(), Some("%3"));
+ assert_eq!(args.timeout, Some(5));
+ assert!(!args.raw, "after `--` a flag belongs to the command");
+ assert_eq!(args.cmd, ["make", "--raw"]);
+
+ let Some(Command::Exec(args)) = parse(&["tty7", "exec", "--", "ls"]).command else {
+ panic!("exec without an address did not parse");
+ };
+ assert_eq!(args.target, None, "the pane then comes from $TTY7_PANE");
+
+ let err = Cli::try_parse_from(["tty7", "exec", "%3"]).unwrap_err();
+ assert_eq!(err.exit_code(), 2, "a command is required");
+ }
+
+ #[test]
+ fn send_reads_its_text_from_one_place() {
+ let Some(Command::Send(args)) =
+ parse(&["tty7", "send", "%3", "--stdin", "--paste", "--enter"]).command
+ else {
+ panic!("send --stdin did not parse");
+ };
+ assert!(args.stdin && args.paste && args.enter);
+ assert_eq!(args.first.as_deref(), Some("%3"));
+
+ for bad in [
+ vec!["tty7", "send", "%3", "text", "--stdin"],
+ vec!["tty7", "send", "%3", "text", "--from-file", "f"],
+ vec!["tty7", "send", "%3", "--stdin", "--from-file", "f"],
+ ] {
+ let err = Cli::try_parse_from(&bad).unwrap_err();
+ assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error");
+ }
+ }
}
diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs
index bfa8c3af..d7bea737 100644
--- a/crates/tty7-cli/src/commands.rs
+++ b/crates/tty7-cli/src/commands.rs
@@ -11,9 +11,10 @@ use tty7_core::daemon::protocol::PROTOCOL_VERSION;
use crate::address::{self, Context, WorkspaceAddress};
use crate::backend::{Backend, RunSpec};
use crate::cli::{
- CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs,
- TabCmd, WaitArgs, WaitState, WsCmd,
+ CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd,
+ SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd,
};
+use crate::exec::ExecEnd;
use crate::output;
use crate::resolve;
use crate::screen;
@@ -78,6 +79,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result send(args, ctx, backend),
+ Some(Command::Exec(args)) => exec(args, ctx, backend),
Some(Command::Capture(args)) => capture(args, ctx, backend),
Some(Command::Procs { target }) => procs(target.as_deref(), ctx, backend),
Some(Command::Tab(TabCmd::Ls { ws })) => tab_ls(ws.as_deref(), ctx, backend),
@@ -486,8 +488,6 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu
}
fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result {
- const KEY_GAP: Duration = Duration::from_millis(200);
-
// `--enter` is the same thing as `--key enter`, and predates it. Keeping it
// as sugar rather than deprecating it: it reads better for the overwhelming
// case, which is typing one command and running it. Going through the same
@@ -500,6 +500,43 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result {
+ std::fs::read(path).with_context(|| format!("reading {}", path.display()))?
+ }
+ None => {
+ let mut bytes = Vec::new();
+ std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes)
+ .context("reading stdin")?;
+ bytes
+ }
+ };
+ return deliver(
+ pane,
+ Payload::Piped {
+ source: if args.stdin { "stdin" } else { "file" },
+ bytes: payload,
+ },
+ &args,
+ pressed,
+ backend,
+ );
+ }
+
// Three shapes reach here, and only the address is ever ambiguous:
// `send %3 "text"`, `send "text"` (this pane), and — new with --key —
// `send %3 --key C-c`, where there is no text at all and the lone
@@ -555,9 +592,75 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Payload::Typed(text.to_string()),
+ None => Payload::None,
+ };
+ deliver(pane, payload, &args, pressed, backend)
+}
+
+/// What `send` has to put in front of the keys.
+enum Payload {
+ None,
+ /// From the command line — echoed back in `--json`, as it always was.
+ Typed(String),
+ /// From stdin or a file. Never echoed: the reason to use these is to keep
+ /// the bytes out of places other people can read, and a JSON line in a
+ /// log is one of them.
+ Piped {
+ source: &'static str,
+ bytes: Vec,
+ },
+}
+
+fn deliver(
+ pane: u64,
+ payload: Payload,
+ args: &SendArgs,
+ pressed: Vec,
+ backend: &mut dyn Backend,
+) -> Result {
+ const KEY_GAP: Duration = Duration::from_millis(200);
+
+ let (source, bytes) = match &payload {
+ Payload::None => ("none", &[][..]),
+ Payload::Typed(text) => ("argument", text.as_bytes()),
+ Payload::Piped { source, bytes } => (*source, &bytes[..]),
+ };
+ if args.paste && bytes.is_empty() {
+ bail!("--paste needs text to paste — TEXT, --stdin or --from-file");
+ }
+ // `--paste` frames the way the GUI's paste does, which is to ask the pane
+ // first: brackets for a pane that switched mode 2004 on, and for one that
+ // did not, the line breaks a keyboard would send. Forcing the brackets on
+ // a pane that never asked for them would put `ESC[200~` in front of a
+ // program that reads it as keystrokes. `None` is a server that cannot say.
+ let mode = if args.paste {
+ backend.procs(pane)?.context.and_then(|c| c.bracketed_paste)
+ } else {
+ None
+ };
+ let bracketed = mode == Some(true);
+ if args.paste && !bracketed {
+ eprintln!(
+ "tty7: pane %{pane} {} bracketed paste, so the text went unframed — each line \
+ break was sent as Enter",
+ if mode.is_none() {
+ "is on a server too old to say whether it has"
+ } else {
+ "has not switched on"
+ }
+ );
+ }
+ let wire = if args.paste {
+ tty7_core::core::paste::paste_bytes(bytes, bracketed)
+ } else {
+ bytes.to_vec()
+ };
+
let mut already_wrote = false;
- if let Some(text) = text {
- backend.send_input(pane, text.as_bytes().to_vec())?;
+ if !wire.is_empty() {
+ backend.send_input(pane, wire)?;
already_wrote = true;
}
for key in &pressed {
@@ -576,7 +679,18 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Some(text.as_str()),
+ Payload::None => Some(""),
+ Payload::Piped { .. } => None,
+ },
+ "source": source,
+ "bytes": bytes.len(),
+ "paste": args.paste,
+ // Whether the paste went framed, and what the pane said about the
+ // mode — `null` when nothing was asked or the server cannot say.
+ "bracketed": bracketed,
+ "bracketed_paste_mode": mode,
"enter": args.enter,
"keys": pressed.iter().map(|k| k.name.as_str()).collect::>(),
}),
@@ -594,6 +708,132 @@ fn tried_to_write_an_address(s: &str) -> bool {
.is_some_and(|c| c.is_ascii_digit())
}
+fn exec(args: ExecArgs, ctx: &Context, backend: &mut dyn Backend) -> Result {
+ let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
+ let line = args.cmd.join(" ");
+ if line.trim().is_empty() {
+ bail!("exec needs a command after `--`");
+ }
+ // The line is typed, so every byte of it goes through the shell's line
+ // editor: a newline would submit half of it and a Tab would complete. One
+ // line of printable text is the only thing that runs as written — the same
+ // test the GUI applies before it types a line rather than pasting it.
+ if line.chars().any(char::is_control) {
+ bail!(
+ "exec types one line at the shell's prompt, and this one holds a control \
+ character (a newline, a tab, an escape) the line editor would act on — join \
+ the commands with `;` or `&&`, or put them in a script and exec that"
+ );
+ }
+ let mut wire = line.clone().into_bytes();
+ wire.push(b'\r');
+ let started = std::time::Instant::now();
+ let run = backend.exec(pane, wire, args.timeout.map(Duration::from_secs))?;
+ let elapsed_ms = started.elapsed().as_millis() as u64;
+
+ // The caller's own pane is the one pane that can never be at a prompt
+ // while this runs: its shell is busy running `tty7 exec`.
+ let own = ctx
+ .pane
+ .as_deref()
+ .and_then(|p| address::parse_pane(p).ok())
+ == Some(pane);
+ match run.end {
+ ExecEnd::NoIntegration => bail!(
+ "pane %{pane} has sent no shell-integration prompt marks, so nothing would say \
+ when a command there ends — exec needs tty7's shell integration loaded in the \
+ pane's shell (`tty7 doctor`); without it, `send … --enter` and \
+ `wait --until free` are the way"
+ ),
+ ExecEnd::Busy if own => bail!(
+ "pane %{pane} is this shell, and it is busy running this very command — exec \
+ into another pane"
+ ),
+ ExecEnd::Busy => bail!(
+ "pane %{pane} is not at a shell prompt — something is already running there. \
+ `tty7 wait %{pane} --until free` waits for it"
+ ),
+ _ => {}
+ }
+
+ let text = if args.raw {
+ String::from_utf8_lossy(&run.output).into_owned()
+ } else {
+ screen::render(&[crate::backend::CaptureSegment {
+ size: run.size,
+ bytes: run.output.clone(),
+ }])
+ };
+ if run.dropped > 0 {
+ eprintln!(
+ "tty7: exec %{pane}: the first {} bytes of output were dropped to keep the rest \
+ in memory — the pane's own scrollback may still hold them",
+ run.dropped
+ );
+ }
+ let (code, known, timed_out) = match run.end {
+ ExecEnd::Finished {
+ exit: Some(code),
+ ran: true,
+ } => (code, true, false),
+ ExecEnd::Finished {
+ exit: None,
+ ran: true,
+ } => {
+ eprintln!("tty7: {EXIT_CODE_UNKNOWN}");
+ (1, false, false)
+ }
+ ExecEnd::Finished { ran: false, .. } => {
+ eprintln!(
+ "tty7: pane %{pane}: the shell came back to its prompt without running the \
+ line — a syntax error, most likely; what it printed is the output"
+ );
+ (1, false, false)
+ }
+ ExecEnd::TimedOut { started: true } => {
+ eprintln!(
+ "tty7: pane %{pane}: still running after {}s — timed out, and left running \
+ (`tty7 send %{pane} --key C-c` interrupts it)",
+ args.timeout.unwrap_or_default()
+ );
+ (124, false, true)
+ }
+ ExecEnd::TimedOut { started: false } => {
+ eprintln!(
+ "tty7: pane %{pane}: timed out after {}s, and the shell never started the \
+ line — it is most likely waiting on a continuation prompt (an unclosed \
+ quote or bracket); `tty7 send %{pane} --key C-c` abandons it",
+ args.timeout.unwrap_or_default()
+ );
+ (124, false, true)
+ }
+ ExecEnd::PaneExited(_) => {
+ eprintln!("tty7: pane %{pane} exited before its shell came back to a prompt");
+ (1, false, false)
+ }
+ ExecEnd::NoIntegration | ExecEnd::Busy => unreachable!("refused above"),
+ };
+ // `-q` keeps the exit code and drops the output, which is what the global
+ // flag already does to any report.
+ Ok(Outcome::Exit(
+ code,
+ Report {
+ human: text.clone(),
+ json: json!({
+ "pane": pane,
+ "command": line,
+ "exit": code,
+ "exit_code_known": known,
+ "timed_out": timed_out,
+ "pane_exited": matches!(run.end, ExecEnd::PaneExited(_)),
+ "output": text,
+ "bytes": run.output.len(),
+ "elapsed_ms": elapsed_ms,
+ }),
+ },
+ ))
+}
+
fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let segments = backend.capture(pane, args.scrollback)?;
@@ -3448,6 +3688,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen: false,
+ bracketed_paste: None,
}
}
@@ -3474,6 +3715,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen,
+ bracketed_paste: None,
}),
}
}
@@ -3495,6 +3737,7 @@ mod tests {
local_pty: false,
at_prompt,
remote_prompt_seen: at_prompt == Some(true),
+ bracketed_paste: None,
}),
}
}
@@ -4506,4 +4749,343 @@ mod tests {
.collect();
assert_eq!(asleep, vec![2, 3]);
}
+
+ fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun {
+ crate::exec::ExecRun {
+ end,
+ output: output.to_vec(),
+ dropped: 0,
+ size: segment(b"").size,
+ }
+ }
+
+ fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) {
+ match execute(cli(args), ctx, backend) {
+ Ok(Outcome::Exit(code, report)) => (code, report),
+ other => panic!("exec stands in for the command, so it exits: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn exec_types_one_line_and_passes_the_exit_code_through() {
+ let mut backend = mock();
+ backend.exec_run = exec_run(
+ ExecEnd::Finished {
+ exit: Some(3),
+ ran: true,
+ },
+ b"built\r\n",
+ );
+ let (code, report) = exec_outcome(
+ &[
+ "tty7",
+ "exec",
+ "%1",
+ "--raw",
+ "--timeout",
+ "30",
+ "--",
+ "make",
+ "&&",
+ "ls",
+ ],
+ &Context::default(),
+ &mut backend,
+ );
+ assert_eq!(code, 3, "the command's own code, not ours");
+ assert_eq!(
+ backend.execs,
+ vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))],
+ "the words are one shell line, typed and submitted"
+ );
+ assert_eq!(report.human, "built\r\n");
+ assert_eq!(report.json["exit"], 3);
+ assert_eq!(report.json["exit_code_known"], true);
+ assert_eq!(report.json["command"], "make && ls");
+ assert_eq!(report.json["output"], "built\r\n");
+ assert_eq!(report.json["timed_out"], false);
+ // Nothing was sent anywhere but through the exec seam.
+ assert!(backend.sent.is_empty());
+ }
+
+ #[test]
+ fn exec_renders_the_output_the_way_capture_plain_does() {
+ let mut backend = mock();
+ backend.exec_run = exec_run(
+ ExecEnd::Finished {
+ exit: Some(0),
+ ran: true,
+ },
+ b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n",
+ );
+ let (code, report) = exec_outcome(
+ &["tty7", "exec", "%1", "--", "x"],
+ &Context::default(),
+ &mut backend,
+ );
+ assert_eq!(code, 0);
+ assert!(!report.human.contains('\u{1b}'), "{:?}", report.human);
+ assert!(report.human.contains("red"), "{:?}", report.human);
+ assert!(report.human.contains("progress 100%"), "{:?}", report.human);
+ assert!(
+ !report.human.contains("progress 10%\n"),
+ "{:?}",
+ report.human
+ );
+ }
+
+ #[test]
+ fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() {
+ // The backend sends nothing on these; the verb has to turn them into
+ // an error that says what to do instead of an exit code that says
+ // nothing.
+ let mut backend = mock();
+ backend.exec_run = exec_run(ExecEnd::NoIntegration, b"");
+ let err = execute(
+ cli(&["tty7", "exec", "%1", "--", "ls"]),
+ &Context::default(),
+ &mut backend,
+ )
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("shell-integration"), "{err}");
+ assert!(err.contains("wait --until free"), "{err}");
+
+ let mut backend = mock();
+ backend.exec_run = exec_run(ExecEnd::Busy, b"");
+ let err = execute(
+ cli(&["tty7", "exec", "%1", "--", "ls"]),
+ &Context::default(),
+ &mut backend,
+ )
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("not at a shell prompt"), "{err}");
+
+ // The caller's own pane can only ever be busy — running this.
+ let ctx = Context {
+ pane: Some("%1".into()),
+ ..Context::default()
+ };
+ let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("this shell"), "{err}");
+ }
+
+ #[test]
+ fn exec_refuses_a_line_the_editor_would_act_on() {
+ for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] {
+ let mut backend = mock();
+ let err = execute(
+ cli(&["tty7", "exec", "%1", "--", line]),
+ &Context::default(),
+ &mut backend,
+ )
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("control"), "{line:?}: {err}");
+ assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}");
+ }
+ }
+
+ #[test]
+ fn exec_reports_every_way_it_can_end_without_an_exit_code() {
+ let cases = [
+ (ExecEnd::TimedOut { started: true }, 124, true, false),
+ (ExecEnd::TimedOut { started: false }, 124, true, false),
+ (ExecEnd::PaneExited(Some(0)), 1, false, true),
+ (
+ ExecEnd::Finished {
+ exit: None,
+ ran: true,
+ },
+ 1,
+ false,
+ false,
+ ),
+ (
+ ExecEnd::Finished {
+ exit: None,
+ ran: false,
+ },
+ 1,
+ false,
+ false,
+ ),
+ ];
+ for (end, want, timed_out, pane_exited) in cases {
+ let mut backend = mock();
+ backend.exec_run = exec_run(end.clone(), b"partial\r\n");
+ let (code, report) = exec_outcome(
+ &["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"],
+ &Context::default(),
+ &mut backend,
+ );
+ assert_eq!(code, want, "{end:?}");
+ assert_eq!(report.json["exit_code_known"], false, "{end:?}");
+ assert_eq!(report.json["timed_out"], timed_out, "{end:?}");
+ assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}");
+ assert_eq!(
+ report.human, "partial\r\n",
+ "what did arrive is still the answer: {end:?}"
+ );
+ }
+ }
+
+ fn procs_with_paste_mode(mode: Option) -> tty7_core::daemon::protocol::PaneProcs {
+ tty7_core::daemon::protocol::PaneProcs {
+ context: Some(tty7_core::daemon::protocol::PaneContext {
+ local_pty: true,
+ bracketed_paste: mode,
+ ..Default::default()
+ }),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn send_from_file_sends_the_bytes_verbatim_and_never_echoes_them() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("token");
+ std::fs::write(&path, b"s3cr\\et\n\xff").unwrap();
+ let mut backend = mock();
+ let json = json_of(run_cli(
+ &["tty7", "send", "%1", "--from-file", path.to_str().unwrap()],
+ &Context::default(),
+ &mut backend,
+ ));
+ assert_eq!(backend.sent, vec![(1, b"s3cr\\et\n\xff".to_vec())]);
+ assert!(
+ json["sent"].is_null(),
+ "the secret must not be echoed: {json}"
+ );
+ assert!(!json.to_string().contains("s3cr"), "{json}");
+ assert_eq!(json["source"], "file");
+ assert_eq!(json["bytes"], 9);
+ assert!(
+ backend.procs_calls.is_empty(),
+ "without --paste the pane is not asked anything"
+ );
+
+ // Keys still follow the payload, the way they follow typed text.
+ let mut backend = mock();
+ run_cli(
+ &[
+ "tty7",
+ "send",
+ "%1",
+ "--from-file",
+ path.to_str().unwrap(),
+ "--enter",
+ ],
+ &Context::default(),
+ &mut backend,
+ );
+ assert_eq!(
+ backend.sent,
+ vec![(1, b"s3cr\\et\n\xff".to_vec()), (1, b"\r".to_vec())]
+ );
+ }
+
+ #[test]
+ fn send_from_file_takes_its_positional_as_an_address_only() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("payload");
+ std::fs::write(&path, b"x").unwrap();
+ let mut backend = mock();
+ let err = execute(
+ cli(&[
+ "tty7",
+ "send",
+ "echo",
+ "--from-file",
+ path.to_str().unwrap(),
+ ]),
+ &Context::default(),
+ &mut backend,
+ )
+ .unwrap_err();
+ let err = format!("{err:#}");
+ assert!(err.contains("has to be the pane"), "{err}");
+ assert!(backend.sent.is_empty(), "nothing may be typed");
+
+ // With no address it is this pane, as it is for text.
+ let ctx = Context {
+ pane: Some("%3".into()),
+ ..Context::default()
+ };
+ run_cli(
+ &["tty7", "send", "--from-file", path.to_str().unwrap()],
+ &ctx,
+ &mut backend,
+ );
+ assert_eq!(backend.sent, vec![(3, b"x".to_vec())]);
+ }
+
+ #[test]
+ fn send_paste_frames_only_for_a_pane_that_switched_bracketed_paste_on() {
+ let mut backend = mock();
+ backend.procs_reply = procs_with_paste_mode(Some(true));
+ let json = json_of(run_cli(
+ &[
+ "tty7",
+ "send",
+ "%1",
+ "a\r\nb\x1b[201~c",
+ "--paste",
+ "--enter",
+ ],
+ &Context::default(),
+ &mut backend,
+ ));
+ assert_eq!(
+ backend.sent,
+ vec![
+ (1, b"\x1b[200~a\nb[201~c\x1b[201~".to_vec()),
+ (1, b"\r".to_vec())
+ ],
+ "the GUI's framing: CRLF folded, ESC stripped so the text cannot close the paste"
+ );
+ assert_eq!(json["bracketed"], true);
+ assert_eq!(json["bracketed_paste_mode"], true);
+
+ // Off: brackets would reach the program as keystrokes, so the text goes
+ // the way a paste into such a pane always has — a CR per line break.
+ let mut backend = mock();
+ backend.procs_reply = procs_with_paste_mode(Some(false));
+ let json = json_of(run_cli(
+ &["tty7", "send", "%1", "a\nb", "--paste"],
+ &Context::default(),
+ &mut backend,
+ ));
+ assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
+ assert_eq!(json["bracketed"], false);
+ assert_eq!(json["bracketed_paste_mode"], false);
+
+ // A server that cannot say is not taken as a yes.
+ let mut backend = mock();
+ backend.procs_reply = procs_with_paste_mode(None);
+ let json = json_of(run_cli(
+ &["tty7", "send", "%1", "a\nb", "--paste"],
+ &Context::default(),
+ &mut backend,
+ ));
+ assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]);
+ assert!(json["bracketed_paste_mode"].is_null(), "{json}");
+ }
+
+ #[test]
+ fn send_paste_needs_something_to_paste() {
+ let mut backend = mock();
+ let err = execute(
+ cli(&["tty7", "send", "%1", "--paste", "--enter"]),
+ &Context::default(),
+ &mut backend,
+ )
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("--paste needs text"), "{err}");
+ assert!(backend.sent.is_empty());
+ }
}
diff --git a/crates/tty7-cli/src/exec.rs b/crates/tty7-cli/src/exec.rs
new file mode 100644
index 00000000..623ab544
--- /dev/null
+++ b/crates/tty7-cli/src/exec.rs
@@ -0,0 +1,305 @@
+//! Finding one command's output and exit code in a pane's byte stream.
+//!
+//! `tty7 exec` types a line at a shell's prompt and has to know two things the
+//! pty alone never says: where the command's output starts and ends, and how it
+//! exited. The shell integration says both, in OSC 133 marks: `C` right before
+//! the command runs, `D;` the moment it is over, `A` when the next prompt
+//! opens. The daemon already reads those marks — it is what sends `Prompt` —
+//! but only as a state, not as a position in the bytes, so the positions are
+//! found here, in the same stream the daemon read them from.
+//!
+//! Which of the two gets the last word is deliberate. The byte marks say
+//! *where*; the daemon's `Prompt` report says *when*, because it is the one
+//! that has already set aside marks relayed by a program running in the
+//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end
+//! once the daemon agrees the shell is back at its prompt.
+
+use tty7_core::core::osc::OscTokenizer;
+use tty7_core::daemon::protocol::WinSize;
+
+/// Output kept past this is dropped from the front: the end of a long build is
+/// the part worth reading, and a command that prints without bound must not
+/// take the CLI's memory with it. The pane's own scrollback is the same size.
+pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024;
+
+const MARK_D: &[u8] = b"\x1b]133;D";
+const MARK_A: &[u8] = b"\x1b]133;A";
+
+/// How an `exec` ended.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ExecEnd {
+ /// The pane's shell has never sent a prompt mark, so nothing would ever
+ /// say the command is over. Nothing was sent.
+ NoIntegration,
+ /// The pane is not at a prompt — something already runs there. Nothing
+ /// was sent.
+ Busy,
+ /// The shell is back at its prompt. `ran` is false when it came back
+ /// without running anything — a syntax error, most likely — in which case
+ /// there is no exit code to report and the output is what the shell said
+ /// about the line.
+ Finished { exit: Option, ran: bool },
+ /// The deadline passed first. `started` says whether the shell ever
+ /// began the command: when it did not, the line is most likely sitting on
+ /// a continuation prompt (an unclosed quote) rather than running.
+ TimedOut { started: bool },
+ /// The pane went away before its shell came back to a prompt.
+ PaneExited(Option),
+}
+
+/// What an `exec` saw: how it ended, and the output that belongs to it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ExecRun {
+ pub end: ExecEnd,
+ pub output: Vec,
+ /// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`].
+ pub dropped: usize,
+ /// The pane's size, which is what `--plain` replays the output at.
+ pub size: WinSize,
+}
+
+/// The pane's output from the moment the line was sent, and where the marks
+/// that bound the command fell in it.
+pub struct Transcript {
+ tok: OscTokenizer,
+ bytes: Vec,
+ /// Absolute offset of `bytes[0]` — everything before it was dropped.
+ base: usize,
+ /// One past the command's `C` mark: where its output starts.
+ started: Option,
+ /// Where the newest `D` after `started` begins: where the output ends.
+ ended: Option,
+ /// Where an `A` that arrived with no `C` before it begins — the shell drew
+ /// a fresh prompt without running the line.
+ reprompted: Option,
+ exit_mark: Option,
+}
+
+/// What the daemon's `Prompt` report made of the pane.
+pub struct Finished {
+ pub exit: Option,
+ pub ran: bool,
+}
+
+impl Default for Transcript {
+ fn default() -> Transcript {
+ Transcript::new()
+ }
+}
+
+impl Transcript {
+ pub fn new() -> Transcript {
+ Transcript {
+ tok: OscTokenizer::new(&[b"133"]),
+ bytes: Vec::new(),
+ base: 0,
+ started: None,
+ ended: None,
+ reprompted: None,
+ exit_mark: None,
+ }
+ }
+
+ /// Take the next chunk of the pane's output.
+ pub fn output(&mut self, chunk: &[u8]) {
+ let at = self.base + self.bytes.len();
+ self.bytes.extend_from_slice(chunk);
+ let (bytes, base) = (&self.bytes, self.base);
+ let (mut started, mut ended, mut reprompted, mut exit_mark) =
+ (self.started, self.ended, self.reprompted, self.exit_mark);
+ self.tok.feed_at(chunk, |end, payload| {
+ let end = at + end;
+ // Where the sequence that just ended began: the newest copy of its
+ // introducer before its terminator. Payloads carry no ESC, so the
+ // search cannot land inside a different sequence.
+ let begin = |intro: &[u8]| {
+ let upto = end - base;
+ bytes[..upto]
+ .windows(intro.len())
+ .rposition(|w| w == intro)
+ .map_or(end, |at| base + at)
+ };
+ match payload.get(4) {
+ // Only the first: a command that prints a `C` of its own must
+ // not move the start of its own output.
+ Some(b'C') if started.is_none() => started = Some(end),
+ Some(b'D') if started.is_some() => {
+ ended = Some(begin(MARK_D));
+ exit_mark = payload
+ .strip_prefix(b"133;D;")
+ .and_then(|c| std::str::from_utf8(c).ok())
+ .and_then(|c| c.trim().parse().ok());
+ }
+ Some(b'A') if started.is_none() && reprompted.is_none() => {
+ reprompted = Some(begin(MARK_A));
+ }
+ _ => {}
+ }
+ });
+ (self.started, self.ended, self.reprompted, self.exit_mark) =
+ (started, ended, reprompted, exit_mark);
+ }
+
+ /// The daemon's report that the pane's shell is (or is not) at a prompt.
+ /// `Some` once the command is over.
+ pub fn prompt(&self, at_prompt: bool, last_exit: Option) -> Option {
+ if !at_prompt {
+ return None;
+ }
+ if self.started.is_some() {
+ // The byte mark and the report are the same `D`, read twice; the
+ // report is preferred only because the daemon parsed it first.
+ return self.ended.map(|_| Finished {
+ exit: last_exit.or(self.exit_mark),
+ ran: true,
+ });
+ }
+ self.reprompted.map(|_| Finished {
+ exit: None,
+ ran: false,
+ })
+ }
+
+ /// Whether the shell has begun running the command.
+ pub fn started(&self) -> bool {
+ self.started.is_some()
+ }
+
+ /// The output that belongs to the command, as far as it has got, and how
+ /// much of it was dropped from the front to keep it bounded.
+ pub fn take(self) -> (Vec, usize) {
+ let all = self.base + self.bytes.len();
+ let (from, to) = match (self.started, self.ended, self.reprompted) {
+ (Some(start), Some(end), _) => (start, end),
+ (Some(start), None, _) => (start, all),
+ // Never ran: what the shell printed about the line, up to the new
+ // prompt — the echo of the line itself and whatever error it drew.
+ (None, _, Some(end)) => (0, end),
+ (None, _, None) => (0, all),
+ };
+ let to = to.clamp(from, all);
+ let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT));
+ (
+ self.bytes[kept - self.base..to - self.base].to_vec(),
+ kept - from,
+ )
+ }
+
+ /// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop
+ /// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take)
+ /// would not return anyway, which keeps the newest that much.
+ pub fn compact(&mut self) {
+ let len = self.bytes.len();
+ if len <= KEEP_OUTPUT * 2 {
+ return;
+ }
+ let drop = len - KEEP_OUTPUT;
+ self.bytes.drain(..drop);
+ self.base += drop;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// What zsh with tty7's integration prints for one `echo hi` typed at its
+ /// prompt: the echo of the line, `C` with the line, the output, `D` with
+ /// the exit code, then the next prompt.
+ const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\
+ \x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07";
+
+ #[test]
+ fn the_output_is_what_lies_between_c_and_d() {
+ let mut t = Transcript::new();
+ t.output(ONE_COMMAND);
+ let done = t.prompt(true, Some(0)).expect("D then a prompt is the end");
+ assert_eq!((done.exit, done.ran), (Some(0), true));
+ assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0));
+ }
+
+ #[test]
+ fn marks_split_across_reads_still_bound_the_output() {
+ // The pty hands the stream over in whatever chunks it likes, including
+ // through the middle of a mark.
+ for split in 1..ONE_COMMAND.len() {
+ let mut t = Transcript::new();
+ t.output(&ONE_COMMAND[..split]);
+ t.output(&ONE_COMMAND[split..]);
+ assert!(t.prompt(true, Some(0)).is_some(), "split at {split}");
+ assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}");
+ }
+ }
+
+ #[test]
+ fn a_prompt_report_before_the_command_has_ended_is_not_the_end() {
+ let mut t = Transcript::new();
+ // The shell has only echoed the line; a prompt redraw (a `B` in PS1
+ // repainted by a transient-prompt theme) reports "at a prompt" too.
+ t.output(b"make\r\n\x1b]133;B\x07");
+ assert!(t.prompt(true, None).is_none());
+ // Running: the daemon reports "not at a prompt".
+ t.output(b"\x1b]133;C;make\x07building\r\n");
+ assert!(t.prompt(false, None).is_none());
+ // A `D` the daemon did not believe — relayed by something running in
+ // the foreground, reported as "not at a prompt" — ends nothing.
+ t.output(b"\x1b]133;D;7\x07more\r\n");
+ assert!(t.prompt(false, Some(7)).is_none());
+ t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ ");
+ let done = t.prompt(true, Some(2)).expect("the real end");
+ assert_eq!(done.exit, Some(2));
+ assert_eq!(
+ t.take().0,
+ b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(),
+ "the output runs to the D the shell's own prompt followed"
+ );
+ }
+
+ #[test]
+ fn a_prompt_with_no_command_before_it_means_the_line_never_ran() {
+ let mut t = Transcript::new();
+ t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% ");
+ let done = t.prompt(true, None).expect("the shell is back");
+ assert_eq!((done.exit, done.ran), (None, false));
+ assert_eq!(
+ t.take().0,
+ b"echo (\r\nzsh: parse error near `('\r\n".to_vec(),
+ "what the shell said about the line is the only output there is"
+ );
+ }
+
+ #[test]
+ fn the_exit_code_falls_back_to_the_mark_itself() {
+ let mut t = Transcript::new();
+ t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\");
+ let done = t
+ .prompt(true, None)
+ .expect("ST terminates a mark as well as BEL");
+ assert_eq!(done.exit, Some(5));
+ assert!(t.take().0.is_empty());
+ }
+
+ #[test]
+ fn a_timeout_keeps_what_the_command_has_printed_so_far() {
+ let mut t = Transcript::new();
+ t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n");
+ assert_eq!(t.take().0, b"tick\r\n".to_vec());
+ }
+
+ #[test]
+ fn output_past_the_bound_is_dropped_from_the_front() {
+ let mut t = Transcript::new();
+ t.output(b"\x1b]133;C\x07");
+ let chunk = vec![b'x'; KEEP_OUTPUT];
+ for _ in 0..3 {
+ t.output(&chunk);
+ t.compact();
+ }
+ t.output(b"end\x1b]133;D;0\x07");
+ assert!(t.prompt(true, Some(0)).is_some());
+ let (out, dropped) = t.take();
+ assert_eq!(out.len(), KEEP_OUTPUT);
+ assert!(out.ends_with(b"end"));
+ assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3);
+ }
+}
diff --git a/crates/tty7-cli/src/keys.rs b/crates/tty7-cli/src/keys.rs
index 6762cf05..545bded9 100644
--- a/crates/tty7-cli/src/keys.rs
+++ b/crates/tty7-cli/src/keys.rs
@@ -90,6 +90,12 @@ pub fn send_long_help() -> String {
when there is none, so `send %42 --enter` runs whatever is already typed in pane 42. \
An unmarked id is not a target for it — `send 83 --enter` is refused, because it \
reads just as much like typing 83 into your own pane; write %83 to mean the pane.\n\n\
+ --stdin and --from-file take the text from there instead, byte for byte — the way \
+ to send a secret, which as TEXT is visible to every local user in `ps` and lands in \
+ your shell history. The one positional is then the pane.\n\n\
+ --paste sends the text the way the GUI pastes: framed in bracketed paste when the \
+ pane has switched it on, so a multi-line text arrives as one block of text instead \
+ of running line by line. --json reports whether it was framed.\n\n\
Keys: {}. Aliases: {}.",
vocabulary(),
aliases.join(", ")
diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs
index b4ef3f1b..b9f8a9c9 100644
--- a/crates/tty7-cli/src/main.rs
+++ b/crates/tty7-cli/src/main.rs
@@ -2,6 +2,7 @@ mod address;
mod backend;
mod cli;
mod commands;
+mod exec;
mod gui;
mod keys;
mod output;
diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs
index d06c050e..89f5e70e 100644
--- a/crates/tty7-cli/tests/cli_e2e.rs
+++ b/crates/tty7-cli/tests/cli_e2e.rs
@@ -87,6 +87,22 @@ fn main() {
"procs_says_where_the_panes_session_lives",
procs_says_where_the_panes_session_lives,
),
+ (
+ "exec_returns_the_commands_output_and_exit_code",
+ exec_returns_the_commands_output_and_exit_code,
+ ),
+ (
+ "exec_refuses_a_pane_with_no_prompt_marks",
+ exec_refuses_a_pane_with_no_prompt_marks,
+ ),
+ (
+ "send_stdin_delivers_bytes_it_never_echoes",
+ send_stdin_delivers_bytes_it_never_echoes,
+ ),
+ (
+ "send_paste_asks_the_pane_for_its_bracketed_paste_mode",
+ send_paste_asks_the_pane_for_its_bracketed_paste_mode,
+ ),
];
let mut failed = 0;
@@ -1052,3 +1068,210 @@ fn capture_tail_trims_a_real_panes_answer(daemon: &Daemon) {
std::thread::sleep(Duration::from_millis(200));
}
}
+
+/// A pane running `program` with no shell in front of it — so no prompt marks
+/// and no line editor, just the pty's own echo. What the tests below type into
+/// it comes back exactly as the pane received it.
+fn bare_pane(daemon: &Daemon, program: &str) -> u64 {
+ let shell = ShellSpec {
+ program: program.into(),
+ args: Vec::new(),
+ args_are_tty7_defaults: false,
+ };
+ let session = PaneClient::at(daemon.pane_endpoint())
+ .spawn(
+ None,
+ WinSize {
+ cols: 80,
+ rows: 24,
+ cell_w: 8,
+ cell_h: 16,
+ },
+ Some(shell),
+ Some("bare-pane-e2e".into()),
+ None,
+ )
+ .unwrap_or_else(|e| panic!("spawn {program}: {e}"));
+ let pane = session.pane_id();
+ session.detach().expect("leave the bare pane running");
+ pane
+}
+
+/// Block until the pane's shell has drawn its first prompt: before that there
+/// are no marks for `exec` to follow, and it refuses rather than guesses.
+fn await_prompt(daemon: &Daemon, address: &str) {
+ let deadline = Instant::now() + SETTLE_WITHIN;
+ loop {
+ let procs = daemon.run_json(&["procs", address]);
+ if procs["context"]["at_prompt"].as_bool() == Some(true) {
+ return;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "the shell never reported a prompt; last procs: {procs}"
+ );
+ std::thread::sleep(Duration::from_millis(200));
+ }
+}
+
+fn exec_returns_the_commands_output_and_exit_code(daemon: &Daemon) {
+ // The marks come from tty7's integration for zsh/bash/fish/nushell/pwsh;
+ // the shell a Windows runner defaults to is not one this can count on.
+ if cfg!(windows) {
+ return;
+ }
+ let created = daemon.run_json(&["new", &workdir()]);
+ let pane = created["pane"].as_u64().expect("new prints the pane id");
+ let address = format!("%{pane}");
+ await_prompt(daemon, &address);
+
+ // `sh -c` so the exit code does not depend on which shell the pane runs.
+ let out = daemon.run(&[
+ "exec",
+ &address,
+ "--timeout",
+ "60",
+ "--",
+ "sh",
+ "-c",
+ "'echo tty7_e2e_exec_$((6*7)); exit 3'",
+ ]);
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert_eq!(
+ out.status.code(),
+ Some(3),
+ "exec exits with the command's code; stdout {stdout:?}, stderr {:?}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ assert!(stdout.contains("tty7_e2e_exec_42"), "{stdout:?}");
+ assert!(
+ !stdout.contains("6*7") && !stdout.contains("exit 3"),
+ "the typed line is not the command's output: {stdout:?}"
+ );
+
+ // The pane is back at its prompt, so it takes the next one straight away.
+ // `--json` ahead of `--`: after it, it would be an argument to `true`.
+ let out = daemon.run_ok(&["exec", &address, "--json", "--timeout", "60", "--", "true"]);
+ let json: serde_json::Value = serde_json::from_str(&out).expect("--json prints JSON");
+ assert_eq!(json["exit"], 0, "{json}");
+ assert_eq!(json["exit_code_known"], true, "{json}");
+ assert_eq!(json["pane"], pane, "{json}");
+ assert_eq!(
+ json["output"], "",
+ "a command that prints nothing has nothing to show — not the shell's \
+ prompt preparation: {json}"
+ );
+}
+
+fn exec_refuses_a_pane_with_no_prompt_marks(daemon: &Daemon) {
+ if cfg!(windows) {
+ return;
+ }
+ let pane = bare_pane(daemon, "/bin/cat");
+ let out = daemon.run(&["exec", &format!("%{pane}"), "--", "echo", "never"]);
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert_eq!(out.status.code(), Some(1), "{stderr}");
+ assert!(stderr.contains("shell-integration"), "{stderr}");
+ // Refused before typing: nothing reached the pane.
+ let seen = daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]);
+ assert!(!seen.contains("never"), "{seen:?}");
+}
+
+fn send_via_stdin(daemon: &Daemon, args: &[&str], input: &[u8]) -> Output {
+ let mut child = daemon
+ .cli(args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}"));
+ child
+ .stdin
+ .take()
+ .expect("stdin was piped")
+ .write_all(input)
+ .expect("feed tty7's stdin");
+ child.wait_with_output().expect("tty7 ran to completion")
+}
+
+fn await_capture(daemon: &Daemon, address: &str, marker: &str) -> String {
+ let deadline = Instant::now() + SETTLE_WITHIN;
+ loop {
+ let seen = daemon.run_ok(&["capture", address, "--scrollback"]);
+ if seen.contains(marker) {
+ return seen;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "{marker:?} never showed up; last capture:\n{seen}"
+ );
+ std::thread::sleep(Duration::from_millis(200));
+ }
+}
+
+fn send_stdin_delivers_bytes_it_never_echoes(daemon: &Daemon) {
+ if cfg!(windows) {
+ return;
+ }
+ let pane = bare_pane(daemon, "/bin/cat");
+ let address = format!("%{pane}");
+ let out = send_via_stdin(
+ daemon,
+ &["send", &address, "--stdin", "--json"],
+ b"tty7_e2e_stdin_secret\n",
+ );
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(
+ out.status.success(),
+ "{stdout}{}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ assert!(
+ !stdout.contains("tty7_e2e_stdin_secret"),
+ "the payload must not come back in the report: {stdout}"
+ );
+ let json: serde_json::Value = serde_json::from_str(&stdout).expect("--json prints JSON");
+ assert_eq!(json["source"], "stdin", "{json}");
+ assert_eq!(json["bytes"], 22, "{json}");
+ await_capture(daemon, &address, "tty7_e2e_stdin_secret");
+}
+
+fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) {
+ if cfg!(windows) {
+ return;
+ }
+ let pane = bare_pane(daemon, "/bin/cat");
+ let address = format!("%{pane}");
+
+ // Off to begin with: nothing in the pane has asked for it.
+ let json = daemon.run_json(&["send", &address, "tty7_e2e_one\ntty7_e2e_two", "--paste"]);
+ assert_eq!(json["bracketed_paste_mode"], false, "{json}");
+ assert_eq!(json["bracketed"], false, "{json}");
+ await_capture(daemon, &address, "tty7_e2e_two");
+
+ // `cat` hands back what it reads, so the program in the pane is now the
+ // one that switched mode 2004 on — the daemon sees it in the output.
+ let out = send_via_stdin(daemon, &["send", &address, "--stdin"], b"\x1b[?2004h\n");
+ assert!(
+ out.status.success(),
+ "{}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ let deadline = Instant::now() + SETTLE_WITHIN;
+ loop {
+ let procs = daemon.run_json(&["procs", &address]);
+ if procs["context"]["bracketed_paste"].as_bool() == Some(true) {
+ break;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "the pane never reported bracketed paste on: {procs}"
+ );
+ std::thread::sleep(Duration::from_millis(200));
+ }
+ let json = daemon.run_json(&["send", &address, "tty7_e2e_framed", "--paste"]);
+ assert_eq!(json["bracketed"], true, "{json}");
+ // The frame reached the pty: cat's echo of it carries the opener's tail.
+ let seen = await_capture(daemon, &address, "tty7_e2e_framed");
+ assert!(seen.contains("[200~"), "{seen:?}");
+}
diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs
index 01fb7bfc..456c7700 100644
--- a/crates/tty7-core/src/core/config.rs
+++ b/crates/tty7-core/src/core/config.rs
@@ -213,6 +213,11 @@ pub struct Config {
pub new_tab_position: NewTabPosition,
#[serde(default, deserialize_with = "de_lenient")]
pub tab_bar_position: TabBarPosition,
+ /// What an SSH pane's tab is called (#726). Only the name the tab shows:
+ /// the titles the remote side sets are still read and kept, and win again
+ /// the moment this is back on [`SshTabTitle::Dynamic`].
+ #[serde(default, deserialize_with = "de_lenient")]
+ pub ssh_tab_title: SshTabTitle,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
#[serde(default)]
@@ -246,6 +251,12 @@ pub struct Config {
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
+ /// Whether the source control panel lists changed files as a directory
+ /// tree rather than one flat run per group. Off by default: the flat list
+ /// is what a handful of changes reads best as, and the tree is for the
+ /// agent run that touched sixty files across a dozen modules.
+ #[serde(default)]
+ pub scm_changes_tree: bool,
/// Whether a file opens in the code panel with soft wrap on. Not a
/// setting anyone picks up front: it is whatever the status bar's Wrap
/// toggle (or `ToggleDocumentWrap`) was last left at, so the next file
@@ -377,13 +388,21 @@ pub struct Config {
#[serde(default)]
pub env: HashMap,
- #[serde(default)]
+ /// The saved SSH hosts. They live in [`SERVERS_FILE`], not in
+ /// `config.json` (#911): people sync `config.json` between machines for its
+ /// colours and keys, and a list of servers is not something every machine
+ /// should carry. Still read from `config.json` so [`Config::load`] can
+ /// move an older file's hosts across, never written back to it.
+ #[serde(default, skip_serializing)]
pub ssh_profiles: Vec,
#[serde(default = "default_true")]
pub verify_host_keys: bool,
#[serde(default)]
pub ssh_warn_on_close: bool,
- #[serde(default)]
+ /// How often each saved host is used, keyed by its id — which means
+ /// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`]
+ /// beside them.
+ #[serde(default, skip_serializing)]
pub ssh_profile_frecency: HashMap,
#[serde(default)]
@@ -412,6 +431,45 @@ pub struct Config {
/// permanent data loss (#537). Cleared only by a load that parses.
#[serde(skip)]
pub quarantined: bool,
+ /// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts
+ /// in hand are not the ones in it, so [`Config::save`] refuses to run
+ /// rather than write them over it — the same rule, for the same reason,
+ /// as [`Self::quarantined`].
+ #[serde(skip)]
+ pub servers_unreadable: bool,
+}
+
+/// Where the saved SSH hosts live, beside `config.json` (#911).
+pub const SERVERS_FILE: &str = "servers.json";
+
+/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to
+/// carry, so a hand-moved block pastes straight across.
+#[derive(Debug, Default, PartialEq, Deserialize)]
+#[serde(default)]
+struct ServersFile {
+ ssh_profiles: Vec,
+ ssh_profile_frecency: HashMap,
+}
+
+#[derive(Serialize)]
+struct ServersFileRef<'a> {
+ ssh_profiles: &'a [crate::core::ssh_profile::SshProfile],
+ ssh_profile_frecency: &'a HashMap,
+}
+
+/// Whether a `config.json` still carries the keys that moved to
+/// [`SERVERS_FILE`] — the cue for the one-time move.
+#[derive(Deserialize)]
+struct LegacyServerKeys {
+ #[serde(default)]
+ ssh_profiles: Option,
+ #[serde(default)]
+ ssh_profile_frecency: Option,
+}
+
+fn has_legacy_server_keys(text: &str) -> bool {
+ serde_json::from_str::(strip_bom(text))
+ .is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -489,6 +547,21 @@ pub enum TabBarPosition {
Left,
}
+/// Where an SSH pane's tab takes its name from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum SshTabTitle {
+ /// Whatever the remote shell or program titles itself, falling back to
+ /// the host's name until it says anything — what tty7 always did.
+ #[default]
+ Dynamic,
+ /// The saved host's name, the alias for a `~/.ssh/config` host, the
+ /// address typed for a quick connect.
+ ProfileName,
+ /// The address the connection dialled.
+ Hostname,
+}
+
/// Native window backdrop material for the Windows GUI. Other platforms retain
/// the value for config synchronization but do not use it for rendering.
/// `Auto` keeps the legacy behavior where theme blur decides between blurred
@@ -667,6 +740,7 @@ impl Default for Config {
scrollback_limit: 10_000,
new_tab_position: NewTabPosition::AfterCurrent,
tab_bar_position: TabBarPosition::Left,
+ ssh_tab_title: SshTabTitle::Dynamic,
sidebar_width: default_sidebar_width(),
sidebar_collapsed: false,
right_panel_visible: false,
@@ -676,6 +750,7 @@ impl Default for Config {
document_layout: DocumentLayout::default(),
document_ratio: default_document_ratio(),
scm_graph_expanded: false,
+ scm_changes_tree: false,
editor_soft_wrap: false,
editor_markdown_preview: false,
sidebar_grouping: SidebarGrouping::Repo,
@@ -720,6 +795,7 @@ impl Default for Config {
restore_agent_sessions: true,
per_pane_history: false,
quarantined: false,
+ servers_unreadable: false,
}
}
}
@@ -766,13 +842,42 @@ impl Config {
/// app onto the result needs the outcome to keep a broken file from
/// evicting the settings the app is running on.
pub fn load_with_outcome() -> (Self, LoadOutcome) {
- let Some(path) = Self::path() else {
- return (Config::default(), LoadOutcome::Absent);
- };
- let text = match std::fs::read_to_string(&path) {
+ match Self::path() {
+ Some(path) => Self::load_from(&path),
+ None => (Config::default(), LoadOutcome::Absent),
+ }
+ }
+
+ /// [`Config::load_with_outcome`] for the `config.json` at `path`, with the
+ /// saved hosts read from the [`SERVERS_FILE`] beside it.
+ ///
+ /// A `config.json` from before the split still holds the hosts itself.
+ /// When there is no [`SERVERS_FILE`] yet they are moved into one, once:
+ /// the new file is written first and `config.json` rewritten without them
+ /// only after that has landed, so a failure anywhere leaves them where
+ /// they were. When both files are there the new one wins and the copy in
+ /// `config.json` is not read — it is what an older tty7 wrote after the
+ /// move, or a synced file brought in, and it falls out of `config.json`
+ /// at its next save.
+ pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) {
+ let (mut cfg, outcome, text) = Self::load_config_file(path);
+ let servers = path.with_file_name(SERVERS_FILE);
+ let found = cfg.load_servers(&servers);
+ if !found
+ && let Some(text) = text
+ && has_legacy_server_keys(&text)
+ {
+ cfg.move_servers_out(&text, path, &servers);
+ }
+ (cfg, outcome)
+ }
+
+ /// `config.json` alone, with the text it parsed from.
+ fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option) {
+ let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
- return (Config::default(), LoadOutcome::Absent);
+ return (Config::default(), LoadOutcome::Absent, None);
}
Err(e) => {
// Unreadable is not unparseable, but the rule is the same:
@@ -785,13 +890,13 @@ impl Config {
);
let mut cfg = Config::default();
cfg.quarantined = true;
- return (cfg, LoadOutcome::Unreadable);
+ return (cfg, LoadOutcome::Unreadable, None);
}
};
match serde_json::from_str::(strip_bom(&text)) {
Ok(mut cfg) => {
cfg.sanitize();
- (cfg, LoadOutcome::Parsed)
+ (cfg, LoadOutcome::Parsed, Some(text))
}
Err(e) => {
// The next `save` overwrites this file wholesale, so handing
@@ -803,14 +908,83 @@ impl Config {
"failed to parse config at {}: {e}; keeping it aside and using defaults",
path.display()
);
- quarantine(&path);
+ quarantine(path);
let mut cfg = Config::default();
cfg.quarantined = true;
- (cfg, LoadOutcome::Quarantined)
+ (cfg, LoadOutcome::Quarantined, None)
}
}
}
+ /// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one.
+ /// Returns whether there was — a file that is there but broken counts, so
+ /// that nothing is moved over the top of it.
+ fn load_servers(&mut self, path: &Path) -> bool {
+ let text = match std::fs::read_to_string(path) {
+ Ok(text) => text,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false,
+ Err(e) => {
+ log::warn!(
+ "failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed",
+ path.display()
+ );
+ self.servers_unreadable = true;
+ return true;
+ }
+ };
+ match serde_json::from_str::(strip_bom(&text)) {
+ Ok(servers) => {
+ self.ssh_profiles = servers.ssh_profiles;
+ self.ssh_profile_frecency = servers.ssh_profile_frecency;
+ }
+ Err(e) => {
+ log::warn!(
+ "failed to parse {}: {e}; keeping it aside, writes suppressed",
+ path.display()
+ );
+ quarantine(path);
+ self.servers_unreadable = true;
+ }
+ }
+ true
+ }
+
+ /// The one-time move of the hosts out of `config.json` (#911). New file
+ /// first: until it has landed, `config.json` is the only copy.
+ ///
+ /// `config.json` is then rewritten from its own `text` with just those
+ /// two keys taken out, not from `self` the way a save writes it. Nobody
+ /// asked for this write, so it should change nothing else: a hand-kept
+ /// file stays the handful of keys it was rather than growing every
+ /// default, and a key this build does not know — one a newer tty7 on a
+ /// synced machine wrote — survives.
+ fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) {
+ if let Err(e) = self.write_servers(servers) {
+ log::warn!(
+ "could not move SSH hosts to {}: {e}; leaving them in config.json",
+ servers.display()
+ );
+ return;
+ }
+ let rewrite = || -> std::io::Result<()> {
+ let mut value: serde_json::Value =
+ serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?;
+ if let Some(object) = value.as_object_mut() {
+ object.remove("ssh_profiles");
+ object.remove("ssh_profile_frecency");
+ }
+ let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
+ write_atomic(config, text.as_bytes())
+ };
+ match rewrite() {
+ Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()),
+ Err(e) => log::warn!(
+ "moved SSH hosts to {}, but could not rewrite config.json: {e}",
+ servers.display()
+ ),
+ }
+ }
+
/// What a clicked file link should open in, with the pre-setting default
/// filled in for a `Config` that never went through [`Self::sanitize`].
pub fn file_open_mode(&self) -> LinkFileOpen {
@@ -898,18 +1072,62 @@ impl Config {
/// Persist without hiding a failure from an interactive settings editor.
pub fn try_save(&self) -> std::io::Result<()> {
+ let path = Self::path()
+ .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
+ self.try_save_to(&path)
+ }
+
+ /// [`Config::try_save`] for the `config.json` at `path`, and the
+ /// [`SERVERS_FILE`] beside it.
+ pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> {
if self.quarantined {
return Err(std::io::Error::other(
"the existing configuration could not be read; repair it before saving",
));
}
- let path = Self::path()
- .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?;
+ if self.servers_unreadable {
+ return Err(std::io::Error::other(format!(
+ "{SERVERS_FILE} could not be read; repair it before saving"
+ )));
+ }
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
+ // Hosts first, for the same reason the move out of `config.json`
+ // writes them first: a `config.json` that lands without them while
+ // their own file failed to would have dropped them.
+ self.write_servers(&path.with_file_name(SERVERS_FILE))?;
+ self.write_config(path)
+ }
+
+ fn write_config(&self, path: &Path) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
- write_atomic(&path, text.as_bytes())
+ write_atomic(path, text.as_bytes())
+ }
+
+ /// Writes the hosts to their own file — unless it already says exactly
+ /// this, which is nearly every save: a dragged divider has nothing to do
+ /// with the hosts, and rewriting the file anyway would wake the watcher a
+ /// second time. No hosts and no file stays no file.
+ fn write_servers(&self, path: &Path) -> std::io::Result<()> {
+ let on_disk = match std::fs::read_to_string(path) {
+ Ok(text) => serde_json::from_str::(strip_bom(&text)).ok(),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()),
+ Err(_) => None,
+ };
+ if on_disk.as_ref().is_some_and(|held| {
+ held.ssh_profiles == self.ssh_profiles
+ && held.ssh_profile_frecency == self.ssh_profile_frecency
+ }) {
+ return Ok(());
+ }
+ let text = serde_json::to_string_pretty(&ServersFileRef {
+ ssh_profiles: &self.ssh_profiles,
+ ssh_profile_frecency: &self.ssh_profile_frecency,
+ })
+ .map_err(std::io::Error::other)?;
+ // Host names, users and key paths: nobody else's business.
+ write_atomic_private(path, text.as_bytes())
}
fn path() -> Option {
@@ -1429,8 +1647,12 @@ mod tests {
last_used: 42,
},
);
- let json = serde_json::to_string(&cfg).unwrap();
- let back: Config = serde_json::from_str(&json).unwrap();
+ // The count travels in the servers file now (#911), so the round
+ // trip is through disk rather than through `config.json`'s serde.
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ cfg.try_save_to(&config).unwrap();
+ let (back, _) = Config::load_from(&config);
assert!(back.ssh_warn_on_close);
assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4);
}
@@ -2552,4 +2774,246 @@ mod tests {
std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap();
assert_eq!(portable_data_dir(exe_dir.path()), None);
}
+
+ fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile {
+ let mut profile = crate::core::ssh_profile::SshProfile::new(name);
+ profile.host = address.to_string();
+ profile.user = "deploy".to_string();
+ profile
+ }
+
+ /// A `config.json` from before #911, holding hosts, a usage count, a key
+ /// this build has never heard of, and the handful of settings the user
+ /// actually wrote.
+ fn legacy_config(profile: &crate::core::ssh_profile::SshProfile) -> String {
+ let usage = serde_json::json!({ profile.id.to_string(): { "count": 3, "last_used": 5 } });
+ serde_json::to_string_pretty(&serde_json::json!({
+ "font_size": 18.0,
+ "a_key_from_a_newer_build": { "x": 1 },
+ "ssh_profiles": [profile],
+ "ssh_profile_frecency": usage,
+ "keybindings": { "SplitRight": "cmd-d" },
+ }))
+ .unwrap()
+ }
+
+ #[test]
+ fn the_hosts_move_out_of_config_json_once_and_nothing_else_moves() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ let servers = dir.path().join(SERVERS_FILE);
+ let profile = host("prod-web", "10.0.0.5");
+ std::fs::write(&config, legacy_config(&profile)).unwrap();
+
+ let (cfg, outcome) = Config::load_from(&config);
+ assert_eq!(outcome, LoadOutcome::Parsed);
+ assert_eq!(cfg.ssh_profiles, vec![profile.clone()]);
+ assert_eq!(cfg.ssh_profile_frecency[&profile.id].count, 3);
+ assert_eq!(cfg.font_size, 18.0);
+
+ let moved: ServersFile =
+ serde_json::from_str(&std::fs::read_to_string(&servers).unwrap()).unwrap();
+ assert_eq!(moved.ssh_profiles, vec![profile.clone()]);
+ assert_eq!(moved.ssh_profile_frecency[&profile.id].count, 3);
+
+ let left: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ let left = left.as_object().unwrap();
+ assert!(!left.contains_key("ssh_profiles"));
+ assert!(!left.contains_key("ssh_profile_frecency"));
+ // The rest of the file is exactly what it was: nothing the user did
+ // not write appears, and nothing this build cannot read disappears.
+ assert_eq!(left["font_size"], serde_json::json!(18.0));
+ assert_eq!(
+ left["a_key_from_a_newer_build"],
+ serde_json::json!({ "x": 1 })
+ );
+ assert_eq!(
+ left["keybindings"],
+ serde_json::json!({ "SplitRight": "cmd-d" })
+ );
+ assert_eq!(left.len(), 3, "the move must not expand the file: {left:?}");
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mode = std::fs::metadata(&servers).unwrap().permissions().mode();
+ assert_eq!(mode & 0o777, 0o600);
+ }
+
+ // Once is once: a second load reads the new file and writes nothing.
+ let config_after = std::fs::read(&config).unwrap();
+ let servers_after = std::fs::read(&servers).unwrap();
+ let (again, _) = Config::load_from(&config);
+ assert_eq!(again.ssh_profiles, vec![profile]);
+ assert_eq!(std::fs::read(&config).unwrap(), config_after);
+ assert_eq!(std::fs::read(&servers).unwrap(), servers_after);
+ }
+
+ #[test]
+ fn with_both_files_present_the_servers_file_wins() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ let servers = dir.path().join(SERVERS_FILE);
+ let current = host("current", "10.0.0.1");
+ let stale = host("stale", "10.0.0.2");
+ let cfg = Config {
+ ssh_profiles: vec![current.clone()],
+ ..Config::default()
+ };
+ cfg.try_save_to(&config).unwrap();
+ // An older tty7, or a synced file, puts hosts back in config.json.
+ std::fs::write(&config, legacy_config(&stale)).unwrap();
+ let servers_before = std::fs::read(&servers).unwrap();
+
+ let (loaded, _) = Config::load_from(&config);
+ assert_eq!(loaded.ssh_profiles, vec![current.clone()]);
+ assert!(loaded.ssh_profile_frecency.is_empty());
+ assert_eq!(std::fs::read(&servers).unwrap(), servers_before);
+ assert_eq!(
+ std::fs::read_to_string(&config).unwrap(),
+ legacy_config(&stale),
+ "loading is not the moment to rewrite config.json"
+ );
+
+ // The stale copy falls out at the next ordinary save.
+ loaded.try_save_to(&config).unwrap();
+ let text = std::fs::read_to_string(&config).unwrap();
+ assert!(!text.contains("ssh_profiles"), "{text}");
+ assert_eq!(Config::load_from(&config).0.ssh_profiles, vec![current]);
+ }
+
+ #[test]
+ fn a_config_json_without_hosts_is_left_alone() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ std::fs::write(&config, "{\"font_size\": 18}").unwrap();
+
+ let (cfg, _) = Config::load_from(&config);
+ assert!(cfg.ssh_profiles.is_empty());
+ assert_eq!(
+ std::fs::read_to_string(&config).unwrap(),
+ "{\"font_size\": 18}"
+ );
+ assert!(!dir.path().join(SERVERS_FILE).exists());
+ }
+
+ #[test]
+ fn an_empty_hosts_key_is_dropped_without_making_a_servers_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ std::fs::write(&config, "{\"font_size\": 18, \"ssh_profiles\": []}").unwrap();
+
+ Config::load_from(&config);
+ let text = std::fs::read_to_string(&config).unwrap();
+ assert!(!text.contains("ssh_profiles"), "{text}");
+ assert!(text.contains("font_size"), "{text}");
+ assert!(!dir.path().join(SERVERS_FILE).exists());
+ }
+
+ #[test]
+ fn saving_writes_the_hosts_to_their_own_file_and_nowhere_else() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ let servers = dir.path().join(SERVERS_FILE);
+
+ // No hosts, no file.
+ Config::default().try_save_to(&config).unwrap();
+ assert!(!servers.exists());
+
+ let profile = host("prod-web", "10.0.0.5");
+ let mut cfg = Config {
+ ssh_profiles: vec![profile.clone()],
+ ..Config::default()
+ };
+ cfg.ssh_profile_frecency.insert(
+ profile.id,
+ ProfileUsage {
+ count: 2,
+ last_used: 9,
+ },
+ );
+ cfg.try_save_to(&config).unwrap();
+ let text = std::fs::read_to_string(&config).unwrap();
+ assert!(!text.contains("ssh_profile"), "{text}");
+ assert!(!text.contains("10.0.0.5"), "{text}");
+
+ let (loaded, _) = Config::load_from(&config);
+ assert_eq!(loaded.ssh_profiles, vec![profile]);
+ assert_eq!(loaded.ssh_profile_frecency, cfg.ssh_profile_frecency);
+
+ // Deleting the last host empties the file rather than leaving it be.
+ let mut emptied = loaded.clone();
+ emptied.ssh_profiles.clear();
+ emptied.ssh_profile_frecency.clear();
+ emptied.try_save_to(&config).unwrap();
+ assert!(Config::load_from(&config).0.ssh_profiles.is_empty());
+ }
+
+ #[test]
+ fn a_broken_servers_file_is_kept_aside_and_never_overwritten() {
+ let dir = tempfile::tempdir().unwrap();
+ let config = dir.path().join("config.json");
+ let servers = dir.path().join(SERVERS_FILE);
+ let profile = host("prod-web", "10.0.0.5");
+ std::fs::write(&config, legacy_config(&profile)).unwrap();
+ std::fs::write(&servers, "{ nope").unwrap();
+
+ let (cfg, outcome) = Config::load_from(&config);
+ assert_eq!(outcome, LoadOutcome::Parsed, "config.json itself is fine");
+ assert!(cfg.servers_unreadable);
+ assert_eq!(
+ std::fs::read_to_string(servers.with_extension("json.corrupt")).unwrap(),
+ "{ nope"
+ );
+ // Nothing moved on top of it, and nothing saves over either file.
+ assert_eq!(
+ std::fs::read_to_string(&config).unwrap(),
+ legacy_config(&profile)
+ );
+ assert!(cfg.try_save_to(&config).is_err());
+ assert_eq!(std::fs::read_to_string(&servers).unwrap(), "{ nope");
+ assert_eq!(
+ std::fs::read_to_string(&config).unwrap(),
+ legacy_config(&profile)
+ );
+ }
+
+ #[test]
+ fn the_hosts_never_serialize_into_config_json_but_an_old_file_still_reads() {
+ let mut cfg: Config = serde_json::from_str(
+ r#"{"ssh_profiles":[{"name":"a","host":"h"}],"verify_host_keys":false}"#,
+ )
+ .unwrap();
+ assert_eq!(cfg.ssh_profiles.len(), 1);
+ assert!(!cfg.verify_host_keys);
+ cfg.ssh_profile_frecency
+ .insert(cfg.ssh_profiles[0].id, ProfileUsage::default());
+ let value = serde_json::to_value(&cfg).unwrap();
+ assert!(value.get("ssh_profiles").is_none());
+ assert!(value.get("ssh_profile_frecency").is_none());
+ // The SSH preferences are settings like any other and stay put.
+ assert_eq!(value["verify_host_keys"], serde_json::json!(false));
+ }
+
+ #[test]
+ fn ssh_tab_title_defaults_to_dynamic_and_round_trips_leniently() {
+ let cfg: Config = serde_json::from_str("{}").unwrap();
+ assert_eq!(cfg.ssh_tab_title, SshTabTitle::Dynamic);
+ for (raw, mode) in [
+ ("\"dynamic\"", SshTabTitle::Dynamic),
+ ("\"profile-name\"", SshTabTitle::ProfileName),
+ ("\"hostname\"", SshTabTitle::Hostname),
+ ("\"sideways\"", SshTabTitle::Dynamic),
+ ("7", SshTabTitle::Dynamic),
+ ] {
+ let cfg: Config =
+ serde_json::from_str(&format!("{{\"ssh_tab_title\": {raw}, \"font_size\": 20}}"))
+ .unwrap();
+ assert_eq!(cfg.ssh_tab_title, mode, "{raw}");
+ assert_eq!(cfg.font_size, 20.0, "a bad value must not cost the file");
+ let back: Config = serde_json::from_value(serde_json::to_value(&cfg).unwrap()).unwrap();
+ assert_eq!(back.ssh_tab_title, mode);
+ }
+ }
}
diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs
index 43a72d97..e2ed7f97 100644
--- a/crates/tty7-core/src/core/mod.rs
+++ b/crates/tty7-core/src/core/mod.rs
@@ -13,6 +13,7 @@ pub mod kitty_graphics;
pub mod logfile;
pub mod machine;
pub mod osc;
+pub mod paste;
pub mod path_spelling;
pub mod proc;
pub mod session;
diff --git a/crates/tty7-core/src/core/paste.rs b/crates/tty7-core/src/core/paste.rs
new file mode 100644
index 00000000..692dc32a
--- /dev/null
+++ b/crates/tty7-core/src/core/paste.rs
@@ -0,0 +1,99 @@
+//! What a paste puts on the wire.
+//!
+//! Lives here rather than beside the GUI's clipboard handling because the GUI
+//! is not its only sender: `tty7 send --paste` has to frame a payload exactly
+//! the way a paste into the window does, and a second copy of the framing is
+//! how the two would come to disagree about what sending multi-line text means.
+
+/// Opens a bracketed paste (DEC mode 2004).
+pub const START: &[u8] = b"\x1b[200~";
+/// Closes one.
+pub const END: &[u8] = b"\x1b[201~";
+
+/// `payload` inside paste brackets, with every ESC taken out.
+///
+/// The stripping is the point, not a cleanup: a payload that carries its own
+/// `ESC[201~` would otherwise end the paste early, and whatever follows it
+/// would reach the shell as typed input — which is how pasted text turns into
+/// commands. No escape sequence survives the strip, so none can close the
+/// bracket from inside.
+pub fn bracket(payload: &[u8]) -> Vec {
+ let mut bytes = Vec::with_capacity(START.len() + payload.len() + END.len());
+ bytes.extend_from_slice(START);
+ bytes.extend(payload.iter().copied().filter(|&b| b != 0x1b));
+ bytes.extend_from_slice(END);
+ bytes
+}
+
+/// The bytes a paste of `text` sends, for a pane that has (`bracketed`) or has
+/// not switched bracketed paste on.
+///
+/// CRLF folds to one line break either way, so a Windows clipboard pastes like
+/// any other. Framed, the breaks stay `\n` and the shell inserts them as text.
+/// Unframed there is no way to say "text" to a pty, so each break goes as the
+/// `\r` a keyboard's Enter sends — the lines run one at a time, which is what
+/// pasting into a terminal without the mode has always meant.
+pub fn paste_bytes(text: &[u8], bracketed: bool) -> Vec {
+ let mut folded = Vec::with_capacity(text.len());
+ let mut i = 0;
+ while i < text.len() {
+ if text[i] == b'\r' && text.get(i + 1) == Some(&b'\n') {
+ folded.push(b'\n');
+ i += 2;
+ } else {
+ folded.push(text[i]);
+ i += 1;
+ }
+ }
+ if bracketed {
+ bracket(&folded)
+ } else {
+ for b in &mut folded {
+ if *b == b'\n' {
+ *b = b'\r';
+ }
+ }
+ folded
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_payload_cannot_close_its_own_bracket() {
+ let out = bracket(b"a\x1b[201~; rm -rf /\nb");
+ let inner = &out[START.len()..out.len() - END.len()];
+ assert!(out.starts_with(START) && out.ends_with(END));
+ assert!(!inner.contains(&0x1b), "{inner:?}");
+ assert_eq!(inner, b"a[201~; rm -rf /\nb");
+ }
+
+ #[test]
+ fn crlf_folds_before_either_framing() {
+ assert_eq!(paste_bytes(b"a\r\nb\n", true), b"\x1b[200~a\nb\n\x1b[201~");
+ assert_eq!(paste_bytes(b"a\r\nb\nc", false), b"a\rb\rc");
+ }
+
+ #[test]
+ fn a_lone_cr_is_left_alone() {
+ assert_eq!(paste_bytes(b"a\rb", true), b"\x1b[200~a\rb\x1b[201~");
+ assert_eq!(paste_bytes(b"a\rb", false), b"a\rb");
+ }
+
+ #[test]
+ fn unframed_paste_keeps_escapes() {
+ // Only the frame needs protecting; without one there is nothing to
+ // close, and the GUI has always sent these bytes through untouched.
+ assert_eq!(paste_bytes(b"a\x1b[201~b", false), b"a\x1b[201~b");
+ }
+
+ #[test]
+ fn bytes_that_are_not_utf8_survive() {
+ assert_eq!(
+ paste_bytes(b"\xff\xfe", true),
+ b"\x1b[200~\xff\xfe\x1b[201~"
+ );
+ }
+}
diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs
index 97a43d5c..651aaea0 100644
--- a/crates/tty7-core/src/core/ssh_profile.rs
+++ b/crates/tty7-core/src/core/ssh_profile.rs
@@ -153,6 +153,16 @@ pub struct ForwardRule {
pub bind: HostPort,
pub target: HostPort,
pub description: String,
+ /// Whether the rule is opened with the connection. A switched-off rule
+ /// keeps its whole configuration and simply binds nothing, so one local
+ /// port can be pointed at different targets by switching rules rather
+ /// than retyping them.
+ ///
+ /// Missing from every profile saved before the switch existed, and those
+ /// rules were all live: the container's `#[serde(default)]` fills it from
+ /// `Default`, which says `true`, or upgrading would silently turn every
+ /// saved forward off.
+ pub enabled: bool,
}
impl Default for ForwardRule {
@@ -162,6 +172,7 @@ impl Default for ForwardRule {
bind: HostPort::default(),
target: HostPort::default(),
description: String::new(),
+ enabled: true,
}
}
}
@@ -569,6 +580,7 @@ mod tests {
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new("10.0.0.1", 80),
description: "web".to_string(),
+ enabled: false,
}];
original.socks_proxy = Some(HostPort::new("proxy", 1080));
original.algorithms.kex = vec!["curve25519-sha256".to_string()];
@@ -597,6 +609,25 @@ mod tests {
let m: AuthMode = serde_json::from_str("\"agent\"").unwrap();
assert_eq!(m, AuthMode::Agent);
}
+
+ #[test]
+ fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() {
+ let old = r#"{"name":"box","host":"h","forwards":[
+ {"kind":"local","bind":{"host":"127.0.0.1","port":8080},
+ "target":{"host":"srv","port":80},"description":"web"}
+ ]}"#;
+ let p: SshProfile = serde_json::from_str(old).unwrap();
+ assert_eq!(p.forwards.len(), 1);
+ assert!(p.forwards[0].enabled, "an old rule must stay live");
+ assert!(ForwardRule::default().enabled);
+
+ let mut off = p.forwards[0].clone();
+ off.enabled = false;
+ let back: ForwardRule =
+ serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap();
+ assert!(!back.enabled, "a switched-off rule stays off across a save");
+ assert_eq!(back, off);
+ }
}
fn new_id() -> Uuid {
diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs
index b23763df..74bdf6e9 100644
--- a/crates/tty7-core/src/core/term_modes.rs
+++ b/crates/tty7-core/src/core/term_modes.rs
@@ -41,6 +41,9 @@ const TRACKED: &[u16] = &[
1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004,
];
+/// Bracketed paste: whether a paste may arrive framed as `ESC[200~ … ESC[201~`.
+pub const BRACKETED_PASTE: u16 = 2004;
+
/// A CSI longer than this is not a mode set; keep the buffer bounded.
const MAX_PARAMS: usize = 64;
@@ -85,6 +88,11 @@ impl TerminalModes {
&self.on
}
+ /// Whether `mode` is on. Only ever true for a mode in [`TRACKED`].
+ pub fn is_on(&self, mode: u16) -> bool {
+ self.on.contains(&mode)
+ }
+
/// The bytes that put a freshly reset terminal back into these modes, or
/// `None` when there is nothing to restore.
pub fn restore_bytes(&self) -> Option> {
@@ -348,4 +356,16 @@ mod tests {
modes.feed(b"\x1bc");
assert!(modes.is_empty());
}
+
+ #[test]
+ fn bracketed_paste_reads_as_the_shell_last_left_it() {
+ // zle switches it on to read a line and off again before running it,
+ // so the answer has to follow both edges rather than latch the first.
+ let mut modes = TerminalModes::new();
+ assert!(!modes.is_on(BRACKETED_PASTE));
+ modes.feed(b"\x1b[?2004h");
+ assert!(modes.is_on(BRACKETED_PASTE));
+ modes.feed(b"\x1b[?2004l");
+ assert!(!modes.is_on(BRACKETED_PASTE));
+ }
}
diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs
index 8c9650a8..5aad6979 100644
--- a/crates/tty7-core/src/daemon/pane.rs
+++ b/crates/tty7-core/src/daemon/pane.rs
@@ -2272,6 +2272,7 @@ impl DaemonPane {
local_pty: matches!(self.backend, PaneBackend::Pty(_)),
at_prompt: st.shell.active.then_some(st.shell.mark_at_prompt),
remote_prompt_seen: st.remote_prompt_seen,
+ bracketed_paste: Some(st.modes.is_on(crate::core::term_modes::BRACKETED_PASTE)),
}
}
@@ -3057,6 +3058,14 @@ fn apply_agent_signals(
let before = st.agent_session.clone();
for event in &events {
+ // A hook names the agent that emitted it. Once this pane has a
+ // foreground agent, a different one's session id must not land here:
+ // omp forking with a Claude UUID is "session not found".
+ if let (Some(current), Some(from)) = (st.agent, event.agent)
+ && current != from
+ {
+ continue;
+ }
if st.agent.is_none() && event.agent.is_some() {
st.agent = event.agent;
notify(st, DaemonMsg::Agent(st.agent));
@@ -3183,7 +3192,9 @@ fn apply_agent(
stamp_launch_argv(st, argv);
return;
}
- if agent.is_none() && st.agent_session.is_some() {
+ // The session belongs to whoever was in the foreground. Switching from
+ // Claude to omp (or back to the shell) must not keep the previous id.
+ if st.agent_session.is_some() {
st.agent_session = None;
notify(st, DaemonMsg::AgentStatus(None));
}
@@ -5378,6 +5389,39 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
}
+ #[test]
+ fn a_foreign_agent_hook_does_not_replace_the_foreground_session() {
+ use crate::core::cli_agent::CLIAgent;
+
+ let mut st = test_state(true);
+ let mut sniffer = OscSniffer::new();
+ let omp = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(omp.as_bytes()));
+ assert_eq!(st.agent, Some(CLIAgent::OhMyPi));
+
+ let claude = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(claude.as_bytes()));
+ assert_eq!(
+ st.agent_session.as_ref().unwrap().session_id.as_deref(),
+ Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"),
+ "a Claude hook on an omp pane must not steal the session id"
+ );
+
+ apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()])));
+ assert!(
+ st.agent_session.is_none(),
+ "switching the foreground agent drops the previous session"
+ );
+ }
+
#[test]
fn opaque_notifications_only_fall_back_when_no_rich_state() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs
index 217a530b..e55581fb 100644
--- a/crates/tty7-core/src/daemon/procinfo.rs
+++ b/crates/tty7-core/src/daemon/procinfo.rs
@@ -225,23 +225,15 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap {
let mut table = HashMap::new();
- let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
- if bytes <= 0 {
- return table;
- }
- let cap = (bytes as usize / std::mem::size_of::()) + 64;
- let mut pids = vec![0 as libc::c_int; cap];
- let written = unsafe {
- libc::proc_listallpids(
- pids.as_mut_ptr() as *mut libc::c_void,
- (cap * std::mem::size_of::()) as libc::c_int,
- )
- };
- if written <= 0 {
- return table;
- }
- let n = written as usize / std::mem::size_of::();
- for &pid in pids.iter().take(n.min(cap)) {
+ let pids = list_all_pids(|buf| {
+ let ptr = match buf.is_empty() {
+ true => std::ptr::null_mut(),
+ false => buf.as_mut_ptr() as *mut libc::c_void,
+ };
+ let bytes = std::mem::size_of_val(buf) as libc::c_int;
+ unsafe { libc::proc_listallpids(ptr, bytes) }
+ });
+ for &pid in &pids {
if pid <= 0 {
continue;
}
@@ -276,6 +268,37 @@ fn process_table() -> HashMap {
table
}
+/// Every pid on the machine, by way of `list`, which has the calling
+/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as
+/// much of it as it can, and it answers with a *count of pids* — not a byte
+/// count, although the size it is given is in bytes. An empty buffer asks
+/// only for the count, padded by the kernel for processes born in between.
+///
+/// Reading that count as bytes was #731. It divided the answer by four twice
+/// over, once sizing the buffer and once reading back how much was filled, so
+/// only about one pid in sixteen made it into the table — and since the kernel
+/// lists its newest processes first, the ones kept were whatever had started
+/// most recently. A `go run` server just launched was in; the pane's shell,
+/// started long before, was not, and a walk from a root the table has never
+/// heard of comes back empty: no processes, no ports, and nothing to say why.
+/// A process tree built moments earlier, which is what a quick check of the
+/// probe builds, is exactly the case that happened to work.
+#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
+fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec {
+ let count = list(&mut []);
+ if count <= 0 {
+ return Vec::new();
+ }
+ let mut pids = vec![0; count as usize + 64];
+ let written = list(&mut pids);
+ if written <= 0 {
+ return Vec::new();
+ }
+ pids.truncate(written as usize);
+ pids.retain(|&pid| pid > 0);
+ pids
+}
+
#[cfg(target_os = "macos")]
fn cstr_field(buf: &[libc::c_char]) -> String {
let bytes: Vec = buf
@@ -968,6 +991,40 @@ mod tests {
);
}
+ /// `proc_listallpids` as libproc implements it: the kernel's newest
+ /// processes first, a padded estimate when asked with no buffer, and
+ /// always a count of pids back, never bytes.
+ fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ {
+ move |buf: &mut [i32]| {
+ if buf.is_empty() {
+ return live.len() as i32 + 20;
+ }
+ let n = live.len().min(buf.len());
+ buf[..n].copy_from_slice(&live[..n]);
+ n as i32
+ }
+ }
+
+ /// #731 on macOS: a busy Mac has hundreds of processes, and the pane's
+ /// shell is an old one. Every pid has to reach the table, or the walk
+ /// starts from a root it cannot find and the pane shows no processes and
+ /// no ports at all.
+ #[test]
+ fn every_pid_is_listed_not_the_newest_sixteenth() {
+ // Newest first, the way the kernel lists them: the `go run` server
+ // just started, and the shell it runs under from an hour ago.
+ let live: Vec = (1..=700).rev().collect();
+ let got = list_all_pids(fake_listallpids(&live));
+ assert_eq!(got.len(), 700, "the whole machine, not the newest few");
+ assert!(got.contains(&1), "the oldest process is still there");
+ }
+
+ #[test]
+ fn a_pid_list_that_cannot_be_read_is_empty() {
+ assert!(list_all_pids(|_| -1).is_empty());
+ assert!(list_all_pids(|_| 0).is_empty());
+ }
+
#[test]
fn a_pathological_tree_still_stops() {
let mut table: HashMap = [(100, row(1, "zsh"))].into_iter().collect();
diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs
index 3f9632f4..fad170f6 100644
--- a/crates/tty7-core/src/daemon/protocol.rs
+++ b/crates/tty7-core/src/daemon/protocol.rs
@@ -181,6 +181,10 @@ pub enum WorkspaceOp {
RemoveForward {
forward_id: u64,
},
+ SetForwardEnabled {
+ forward_id: u64,
+ enabled: bool,
+ },
ListForwards,
TeardownForwards,
SftpList {
@@ -261,6 +265,13 @@ pub struct SshForwardRule {
pub target_port: u16,
#[serde(default)]
pub description: Option,
+ /// A switched-off rule is registered but binds nothing. Defaults to on,
+ /// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never
+ /// sends it and every rule it knows is live, while one that receives it
+ /// ignores it — the worst case is an older far end opening a rule the
+ /// user switched off, which is what it always did.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -420,6 +431,13 @@ pub struct ManagedForward {
#[serde(default)]
pub description: Option,
pub status: ForwardStatus,
+ /// Whether the forward is switched on. A field rather than a
+ /// `ForwardStatus` variant for the reason `loop_exit_status` gives: an
+ /// older peer fails the whole frame on a variant it has never heard of,
+ /// but skips a field it does not know. A switched-off forward's status is
+ /// an `Error` saying so, which is what such a peer then draws.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -551,6 +569,15 @@ pub struct PaneContext {
/// prompt" on a remote pane means nothing: the newest mark is then the
/// near shell's own "I started `ssh`", and it will never be replaced.
pub remote_prompt_seen: bool,
+ /// Whether the pane's output has switched bracketed paste (mode 2004) on,
+ /// from the same fold the replay restores modes from. `None` from a daemon
+ /// built before the field existed — "cannot say", not "off".
+ ///
+ /// It is here so a client with no terminal of its own — `tty7 send
+ /// --paste` — can frame a paste the way the GUI does, by asking the pane
+ /// rather than assuming.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub bracketed_paste: Option,
}
fn default_term() -> String {
@@ -892,6 +919,11 @@ pub enum ClientMsg {
pane_id: u64,
forward_id: u64,
},
+ SetForwardEnabled {
+ pane_id: u64,
+ forward_id: u64,
+ enabled: bool,
+ },
ListForwards {
pane_id: u64,
},
@@ -987,6 +1019,7 @@ mod kind {
pub const ADD_FORWARD: u8 = 20;
pub const REMOVE_FORWARD: u8 = 21;
pub const LIST_FORWARDS: u8 = 22;
+ pub const SET_FORWARD_ENABLED: u8 = 23;
pub const VERSION: u8 = 40;
pub const QUERY_PROCS: u8 = 50;
pub const ON_WORKSPACE: u8 = 52;
@@ -1260,6 +1293,15 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ } => write_frame(
+ w,
+ kind::SET_FORWARD_ENABLED,
+ &to_json(&(pane_id, forward_id, enabled))?,
+ ),
ClientMsg::QueryProcs { pane_id } => {
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
}
@@ -1390,6 +1432,14 @@ impl ClientMsg {
forward_id,
}
}
+ kind::SET_FORWARD_ENABLED => {
+ let (pane_id, forward_id, enabled) = from_json(&payload)?;
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ }
+ }
kind::LIST_FORWARDS => ClientMsg::ListForwards {
pane_id: from_json(&payload)?,
},
@@ -1758,6 +1808,7 @@ mod tests {
target_host: "10.0.0.5".into(),
target_port: 80,
description: Some("web".into()),
+ enabled: true,
},
},
ClientMsg::AddForward {
@@ -1769,12 +1820,18 @@ mod tests {
target_host: String::new(),
target_port: 0,
description: None,
+ enabled: false,
},
},
ClientMsg::RemoveForward {
pane_id: 7,
forward_id: 3,
},
+ ClientMsg::SetForwardEnabled {
+ pane_id: 7,
+ forward_id: 3,
+ enabled: false,
+ },
ClientMsg::ListForwards { pane_id: 7 },
ClientMsg::Version,
];
@@ -1934,6 +1991,7 @@ mod tests {
target_port: 80,
description: Some("web".into()),
status: ForwardStatus::Listening,
+ enabled: true,
},
ManagedForward {
id: 2,
@@ -1945,6 +2003,7 @@ mod tests {
target_port: 3000,
description: None,
status: ForwardStatus::Error("bind refused".into()),
+ enabled: false,
},
]),
DaemonMsg::Version(DaemonVersion {
@@ -2385,6 +2444,7 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 80,
description: Some("web".into()),
+ enabled: true,
}],
keepalive_interval_s: Some(30),
keepalive_count_max: Some(3),
@@ -2471,9 +2531,14 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 5432,
description: Some("db".into()),
+ enabled: true,
},
},
WorkspaceOp::RemoveForward { forward_id: 4 },
+ WorkspaceOp::SetForwardEnabled {
+ forward_id: 4,
+ enabled: true,
+ },
WorkspaceOp::ListForwards,
WorkspaceOp::TeardownForwards,
WorkspaceOp::SftpList {
@@ -2702,4 +2767,39 @@ mod tests {
"the session daemon must not advertise a dialect it cannot serve"
);
}
+
+ /// `enabled` crosses the same peers as `rejected` above and gets the same
+ /// treatment: a rule or a forward from a peer that predates the switch is
+ /// a live one, and a peer that predates it reads a frame carrying it.
+ #[test]
+ fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() {
+ let rule: SshForwardRule = serde_json::from_str(
+ r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080,
+ "target_host":"srv","target_port":80}"#,
+ )
+ .unwrap();
+ assert!(rule.enabled);
+
+ let managed: ManagedForward = serde_json::from_str(
+ r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1",
+ "bind_port":8080,"status":"listening"}"#,
+ )
+ .unwrap();
+ assert!(managed.enabled);
+
+ #[derive(Deserialize)]
+ struct LegacyManagedForward {
+ id: u64,
+ status: ForwardStatus,
+ }
+ let off = serde_json::to_string(&ManagedForward {
+ enabled: false,
+ status: ForwardStatus::Error("disabled".into()),
+ ..managed
+ })
+ .unwrap();
+ let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap();
+ assert_eq!(legacy.id, 1);
+ assert_eq!(legacy.status, ForwardStatus::Error("disabled".into()));
+ }
}
diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs
index bab4c29a..de4c4f5f 100644
--- a/crates/tty7-core/src/daemon/server.rs
+++ b/crates/tty7-core/src/daemon/server.rs
@@ -1055,6 +1055,31 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> {
Ok(())
}
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ } => {
+ let mut w = write_stream;
+ // Only switching on needs the connection; switching off has to
+ // work on a pane whose connection is already gone.
+ let conn = match forward_pane_connection(®istry, pane_id) {
+ Ok(conn) => Some(conn),
+ Err(e) if enabled => {
+ DaemonMsg::Error(e).encode(&mut w)?;
+ return Ok(());
+ }
+ Err(_) => None,
+ };
+ match crate::daemon::ssh::SshManager::global()
+ .set_forward_enabled(pane_id, conn, forward_id, enabled)
+ {
+ Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?,
+ Err(e) => DaemonMsg::Error(e).encode(&mut w)?,
+ }
+ Ok(())
+ }
+
ClientMsg::QueryProcs { pane_id } => {
let mut w = write_stream;
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs
index 726ff9f1..e6d055ef 100644
--- a/crates/tty7-core/src/daemon/ssh/forward.rs
+++ b/crates/tty7-core/src/daemon/ssh/forward.rs
@@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
))))
}
+/// What a switched-off forward reports. An `Error` for the same reason as
+/// `loop_exit_status`, so an older peer reading it draws a stopped forward
+/// with a word of explanation; a current one reads `enabled` instead.
+fn disabled_status() -> SharedStatus {
+ Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string())))
+}
+
+/// Whether two forwards would want the same listening socket: the same port,
+/// on the same side of the connection. Local and dynamic forwards both listen
+/// here; a remote forward listens on the far host.
+fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool {
+ let far = |k| k == SshForwardKind::Remote;
+ a_port != 0 && a_port == b_port && far(a) == far(b)
+}
+
struct ForwardEntry {
id: u64,
kind: SshForwardKind,
bind_host: String,
bind_port: u16,
+ /// The port the rule asked for, which `bind_port` is not when that was 0
+ /// and the OS picked one. Switching the forward back on asks again for
+ /// this, not for whatever it happened to get last time.
+ requested_port: u16,
target_host: String,
target_port: u16,
description: Option,
status: SharedStatus,
cancel: ForwardCancel,
auto_local: bool,
+ /// Switched off: the entry, and the rule in it, stay listed, but nothing
+ /// is bound and `cancel` is `None`.
+ enabled: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
@@ -288,6 +310,19 @@ impl ForwardEntry {
target_port: self.target_port,
description: self.description.clone(),
status: self.status.lock().unwrap().clone(),
+ enabled: self.enabled,
+ }
+ }
+
+ fn rule(&self) -> SshForwardRule {
+ SshForwardRule {
+ kind: self.kind,
+ bind_host: self.bind_host.clone(),
+ bind_port: self.requested_port,
+ target_host: self.target_host.clone(),
+ target_port: self.target_port,
+ description: self.description.clone(),
+ enabled: self.enabled,
}
}
}
@@ -318,6 +353,23 @@ impl SshForwardRegistry {
.await
}
+ pub async fn set_enabled(
+ &self,
+ pane_id: u64,
+ conn: Option>,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.set_enabled_owned(
+ &ForwardOwner::Pane(pane_id),
+ pane_id,
+ conn,
+ forward_id,
+ enabled,
+ )
+ .await
+ }
+
pub async fn teardown_pane(&self, pane_id: u64) {
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
}
@@ -347,6 +399,24 @@ impl SshForwardRegistry {
.await
}
+ pub async fn set_enabled_workspace(
+ &self,
+ workspace: WorkspaceId,
+ view_pane: u64,
+ conn: Arc,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.set_enabled_owned(
+ &ForwardOwner::Workspace(workspace),
+ view_pane,
+ Some(conn),
+ forward_id,
+ enabled,
+ )
+ .await
+ }
+
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
self.teardown_owned(&ForwardOwner::Workspace(workspace))
.await;
@@ -360,22 +430,24 @@ impl SshForwardRegistry {
rule: &SshForwardRule,
) -> ManagedForward {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
- let (bind_port, status, cancel) = match rule.kind {
- SshForwardKind::Local => self.start_local(&conn, rule).await,
- SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await,
- SshForwardKind::Remote => self.start_remote(&conn, rule).await,
+ let (bind_port, status, cancel) = if rule.enabled {
+ self.start(&conn, rule).await
+ } else {
+ (rule.bind_port, disabled_status(), ForwardCancel::None)
};
let entry = ForwardEntry {
id,
kind: rule.kind,
bind_host: rule.bind_host.clone(),
bind_port,
+ requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: false,
+ enabled: rule.enabled,
};
let managed = entry.to_managed(view_pane);
self.owners
@@ -413,20 +485,145 @@ impl SshForwardRegistry {
})
};
if let Some(entry) = removed {
- Self::cancel_entry(entry).await;
+ Self::cancel(entry.cancel).await;
}
self.list_owned(owner, view_pane)
}
+ /// Switch one forward off or back on, keeping its entry — and so its
+ /// place in the list and the rule it was made from — either way.
+ ///
+ /// Switching on refuses, rather than fails at bind time with whatever the
+ /// OS says, when another switched-on forward of the same owner already
+ /// holds the port: the answer then names that forward, which is the one
+ /// the user has to switch off first. A forward that cannot be started for
+ /// any other reason stays off, with the reason as the error.
+ async fn set_enabled_owned(
+ &self,
+ owner: &ForwardOwner,
+ view_pane: u64,
+ conn: Option>,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ if !enabled {
+ let cancel = {
+ let mut owners = self.owners.lock().unwrap();
+ let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
+ entry.enabled = false;
+ entry.status = disabled_status();
+ std::mem::replace(&mut entry.cancel, ForwardCancel::None)
+ };
+ Self::cancel(cancel).await;
+ return Ok(self.list_owned(owner, view_pane));
+ }
+
+ let Some(rule) = self.enable_plan(owner, forward_id)? else {
+ return Ok(self.list_owned(owner, view_pane));
+ };
+ let Some(conn) = conn else {
+ return Err("the SSH connection is gone — reconnect and try again".to_string());
+ };
+ let (bound, status, cancel) = self.start(&conn, &rule).await;
+ if let Some(e) = bind_error(&status) {
+ Self::cancel(cancel).await;
+ return Err(e);
+ }
+ if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) {
+ // Removed while it was being started: nobody wants what was bound.
+ Self::cancel(cancel).await;
+ }
+ Ok(self.list_owned(owner, view_pane))
+ }
+
+ fn entry_mut<'a>(
+ owners: &'a mut HashMap>,
+ owner: &ForwardOwner,
+ forward_id: u64,
+ ) -> Result<&'a mut ForwardEntry, String> {
+ owners
+ .get_mut(owner)
+ .and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id))
+ .ok_or_else(|| format!("no forward {forward_id}"))
+ }
+
+ /// The rule to start for switching `forward_id` on, `None` when it is on
+ /// already, or why it may not be switched on.
+ fn enable_plan(
+ &self,
+ owner: &ForwardOwner,
+ forward_id: u64,
+ ) -> Result, String> {
+ let mut owners = self.owners.lock().unwrap();
+ let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
+ if entry.enabled {
+ return Ok(None);
+ }
+ let rule = SshForwardRule {
+ enabled: true,
+ ..entry.rule()
+ };
+ let holder = owners.get(owner).into_iter().flatten().find(|e| {
+ e.id != forward_id
+ && e.enabled
+ && same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port)
+ });
+ if let Some(h) = holder {
+ let what = match h.kind {
+ SshForwardKind::Dynamic => "a SOCKS forward".to_string(),
+ _ => format!("the forward to {}:{}", h.target_host, h.target_port),
+ };
+ return Err(format!(
+ "port {} is already used by {what}; switch that one off first",
+ rule.bind_port
+ ));
+ }
+ Ok(Some(rule))
+ }
+
+ /// Record a started forward as switched on. Hands `cancel` back when the
+ /// entry is no longer there to take it.
+ fn finish_enable(
+ &self,
+ owner: &ForwardOwner,
+ forward_id: u64,
+ bound: u16,
+ status: SharedStatus,
+ cancel: ForwardCancel,
+ ) -> Option {
+ let mut owners = self.owners.lock().unwrap();
+ let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else {
+ return Some(cancel);
+ };
+ entry.enabled = true;
+ entry.bind_port = bound;
+ entry.status = status;
+ // Anything already in the slot is a second switch-on that raced this
+ // one; it is replaced, and has to be stopped rather than leaked.
+ Some(std::mem::replace(&mut entry.cancel, cancel))
+ }
+
+ async fn start(
+ &self,
+ conn: &Arc,
+ rule: &SshForwardRule,
+ ) -> (u16, SharedStatus, ForwardCancel) {
+ match rule.kind {
+ SshForwardKind::Local => self.start_local(conn, rule).await,
+ SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await,
+ SshForwardKind::Remote => self.start_remote(conn, rule).await,
+ }
+ }
+
async fn teardown_owned(&self, owner: &ForwardOwner) {
let entries = self.owners.lock().unwrap().remove(owner);
for entry in entries.into_iter().flatten() {
- Self::cancel_entry(entry).await;
+ Self::cancel(entry.cancel).await;
}
}
- async fn cancel_entry(entry: ForwardEntry) {
- match entry.cancel {
+ async fn cancel(cancel: ForwardCancel) {
+ match cancel {
ForwardCancel::Task(handle) => {
handle.abort();
let _ = handle.await;
@@ -634,6 +831,7 @@ impl SshForwardRegistry {
target_host: remote_host.to_string(),
target_port: remote_port,
description: Some(format!("localhost link → :{remote_port}")),
+ enabled: true,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await;
@@ -658,12 +856,14 @@ impl SshForwardRegistry {
kind: SshForwardKind::Local,
bind_host: rule.bind_host.clone(),
bind_port,
+ requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: true,
+ enabled: true,
};
self.owners
.lock()
@@ -735,6 +935,20 @@ impl SshManager {
)
}
+ pub fn set_workspace_forward_enabled(
+ &self,
+ workspace: WorkspaceId,
+ view_pane: u64,
+ conn: Arc,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.runtime.block_on(
+ self.forwards
+ .set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled),
+ )
+ }
+
pub fn list_workspace_forwards(
&self,
workspace: WorkspaceId,
@@ -941,12 +1155,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
+ requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(task),
auto_local: false,
+ enabled: true,
}
};
{
@@ -986,12 +1202,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
+ requested_port: port,
target_host: "h".into(),
target_port: 80,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: false,
+ enabled: true,
}
}
@@ -1047,12 +1265,14 @@ mod tests {
kind: SshForwardKind::Local,
bind_host: "127.0.0.1".into(),
bind_port: port,
+ requested_port: port,
target_host: "127.0.0.1".into(),
target_port: 3000,
description: None,
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
cancel: ForwardCancel::Task(handle),
auto_local: true,
+ enabled: true,
};
reg.owners
.lock()
@@ -1143,4 +1363,170 @@ mod tests {
);
assert_eq!(table.lookup("", 0), None);
}
+
+ fn push_disabled(
+ reg: &SshForwardRegistry,
+ owner: ForwardOwner,
+ id: u64,
+ kind: SshForwardKind,
+ port: u16,
+ ) {
+ reg.owners
+ .lock()
+ .unwrap()
+ .entry(owner)
+ .or_default()
+ .push(ForwardEntry {
+ id,
+ kind,
+ bind_host: "127.0.0.1".into(),
+ bind_port: port,
+ requested_port: port,
+ target_host: "srv-b".into(),
+ target_port: 80,
+ description: None,
+ status: disabled_status(),
+ cancel: ForwardCancel::None,
+ auto_local: false,
+ enabled: false,
+ });
+ }
+
+ /// Switching a forward off has to release what it bound — that is the
+ /// point, so the port can be taken by another rule — while leaving the
+ /// entry, and the rule in it, exactly where it was.
+ #[tokio::test]
+ async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() {
+ let reg = SshForwardRegistry::default();
+ let guard = push_listener(®, ForwardOwner::Pane(7), 3).await;
+ let port = reg.list(7)[0].bind_port;
+
+ let list = reg.set_enabled(7, None, 3, false).await.unwrap();
+ assert_eq!(list.len(), 1, "the forward is still listed");
+ assert_eq!(list[0].id, 3);
+ assert!(!list[0].enabled);
+ assert_eq!(list[0].bind_port, port);
+ assert_eq!(
+ (list[0].target_host.as_str(), list[0].target_port),
+ ("127.0.0.1", 3000)
+ );
+ assert!(matches!(list[0].status, ForwardStatus::Error(_)));
+ assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone");
+ TcpListener::bind(("127.0.0.1", port))
+ .await
+ .expect("the port is free for someone else");
+
+ // Twice is the same as once, and a forward nobody has is an error.
+ assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list);
+ assert!(reg.set_enabled(7, None, 99, false).await.is_err());
+
+ reg.teardown_pane(7).await;
+ assert!(reg.list(7).is_empty());
+ }
+
+ /// Two rules on one local port, pointed at different targets, is the
+ /// whole use of the switch. Turning the second on while the first still
+ /// holds the port is refused by name, not left to fail at bind time.
+ #[tokio::test]
+ async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() {
+ let reg = SshForwardRegistry::default();
+ push_listener(®, ForwardOwner::Pane(7), 0).await;
+ let port = reg.list(7)[0].bind_port;
+ push_disabled(®, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port);
+
+ let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
+ assert!(err.contains(&port.to_string()), "{err}");
+ assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}");
+ assert!(!reg.list(7)[1].enabled, "the refused forward stays off");
+
+ // A SOCKS forward listens here too, so it collides the same way.
+ push_disabled(
+ ®,
+ ForwardOwner::Pane(7),
+ 2,
+ SshForwardKind::Dynamic,
+ port,
+ );
+ assert!(reg.set_enabled(7, None, 2, true).await.is_err());
+
+ // Once the holder is off, the only thing left in the way is the
+ // missing connection — which is said, and changes nothing.
+ reg.set_enabled(7, None, 0, false).await.unwrap();
+ let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
+ assert!(err.contains("connection"), "{err}");
+ assert!(reg.list(7).iter().all(|m| !m.enabled));
+
+ // Another owner's forwards are not this one's business.
+ push_disabled(®, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port);
+ assert!(
+ reg.enable_plan(&ForwardOwner::Pane(8), 0)
+ .unwrap()
+ .is_some()
+ );
+ }
+
+ #[test]
+ fn only_the_same_port_on_the_same_side_collides() {
+ use SshForwardKind::{Dynamic, Local, Remote};
+ assert!(same_socket(Local, 8080, Local, 8080));
+ assert!(same_socket(Local, 8080, Dynamic, 8080));
+ assert!(same_socket(Remote, 8080, Remote, 8080));
+ assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts");
+ assert!(!same_socket(Local, 8080, Local, 8081));
+ assert!(
+ !same_socket(Local, 0, Local, 0),
+ "0 asks the OS for a fresh one"
+ );
+ }
+
+ /// Switching back on asks for the port the rule asked for, not the one
+ /// the OS last picked for it, and records what it got this time.
+ #[tokio::test]
+ async fn switching_on_starts_from_the_rule_and_records_the_new_listener() {
+ let reg = SshForwardRegistry::default();
+ push_disabled(®, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0);
+ reg.owners
+ .lock()
+ .unwrap()
+ .get_mut(&ForwardOwner::Pane(7))
+ .unwrap()[0]
+ .bind_port = 41000;
+
+ let rule = reg
+ .enable_plan(&ForwardOwner::Pane(7), 0)
+ .unwrap()
+ .expect("an off forward has something to start");
+ assert_eq!(rule.bind_port, 0);
+ assert!(rule.enabled);
+ assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80));
+
+ let guard = Arc::new(());
+ let held = guard.clone();
+ let task = tokio::spawn(async move {
+ let _held = held;
+ std::future::pending::<()>().await
+ });
+ let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening));
+ let leftover = reg.finish_enable(
+ &ForwardOwner::Pane(7),
+ 0,
+ 42000,
+ status,
+ ForwardCancel::Task(task),
+ );
+ assert!(matches!(leftover, Some(ForwardCancel::None)));
+ let now = ®.list(7)[0];
+ assert!(now.enabled);
+ assert_eq!(now.bind_port, 42000);
+ assert_eq!(now.status, ForwardStatus::Listening);
+ assert!(
+ reg.enable_plan(&ForwardOwner::Pane(7), 0)
+ .unwrap()
+ .is_none(),
+ "an on forward has nothing to start"
+ );
+
+ reg.teardown_pane(7).await;
+ assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned");
+ }
}
diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs
index 6cfddade..937c06ef 100644
--- a/crates/tty7-core/src/daemon/ssh/mod.rs
+++ b/crates/tty7-core/src/daemon/ssh/mod.rs
@@ -115,6 +115,19 @@ impl SshManager {
.block_on(self.forwards.remove(pane_id, forward_id))
}
+ pub fn set_forward_enabled(
+ &self,
+ pane_id: u64,
+ conn: Option>,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.runtime.block_on(
+ self.forwards
+ .set_enabled(pane_id, conn, forward_id, enabled),
+ )
+ }
+
pub fn list_forwards(&self, pane_id: u64) -> Vec {
self.forwards.list(pane_id)
}
diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs
index e022de4e..657faffb 100644
--- a/crates/tty7-core/src/daemon/ssh/workspace.rs
+++ b/crates/tty7-core/src/daemon/ssh/workspace.rs
@@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
WorkspaceOp::RemoveForward { forward_id } => {
DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id))
}
+ WorkspaceOp::SetForwardEnabled {
+ forward_id,
+ enabled,
+ } => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) {
+ Ok(list) => DaemonMsg::ForwardList(list),
+ Err(e) => DaemonMsg::Error(e),
+ },
WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)),
WorkspaceOp::TeardownForwards => {
mgr.teardown_workspace_forwards(ws);
diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx
index 05f96cf9..eb06a642 100644
--- a/docs/customization/settings.mdx
+++ b/docs/customization/settings.mdx
@@ -31,7 +31,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
- Tab position and placement, sidebar grouping, and diff previews.
+ Tab position and placement, sidebar grouping, diff previews, and what SSH
+ tabs are called.
Hosts, connection defaults, security, authentication, proxies, and forwarding.
@@ -69,6 +70,10 @@ Everything the Settings window writes goes to one file:
| macOS / Linux | `~/.config/tty7/config.json` |
| Windows | `%APPDATA%\tty7\config.json` |
+The one exception is your saved SSH hosts, which live beside it in
+`servers.json` — so `config.json` can be synced between machines without
+carrying your servers. [servers.json →](/reference/configuration#ssh)
+
Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state —
somewhere else.
diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx
index aae869b6..33429065 100644
--- a/docs/reference/configuration.mdx
+++ b/docs/reference/configuration.mdx
@@ -92,6 +92,7 @@ their id from the file name. [More about themes →](/customization/themes)
|---|---|---|---|
| `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). |
| `new_tab_position` | enum | `"after-current"` | Or `end`. |
+| `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. |
| `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. |
| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. |
| `sidebar_width` | number | `220` | Pixels (100–2000). |
@@ -186,10 +187,28 @@ angle brackets, and a tab:
| Key | Type | Default | |
|---|---|---|---|
-| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. |
| `verify_host_keys` | bool | `true` | |
| `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. |
+### servers.json
+
+The saved hosts are not in `config.json`: they live in `servers.json` in the
+same directory, so `config.json` can be synced between machines without taking
+your list of servers along. The file is written with mode `0600` and read as
+forgivingly as `config.json`; if it cannot be parsed, tty7 keeps a copy at
+`servers.json.corrupt` and saves nothing until it is repaired.
+
+| Key | Type | Default | |
+|---|---|---|---|
+| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. |
+| `ssh_profile_frecency` | object | `{}` | How often and how recently each host is used, for ranking. Written by the app. |
+
+An older `config.json` that still holds `ssh_profiles` is split the first time
+a newer tty7 reads it: `servers.json` is written first, and only then are the
+two keys removed from `config.json`, leaving the rest of it as it was. If both
+files hold hosts, `servers.json` wins, and the copy in `config.json` is dropped
+the next time settings are saved.
+
Each object in `ssh_profiles` can also set:
| Key | Type | Default | |
@@ -209,9 +228,8 @@ Each object in `ssh_profiles` can also set:
## Keys tty7 manages itself
-`ssh_profile_frecency` and `command_frecency` record how often and how recently
-you use a profile or command, so the pickers can rank them. They are written by
-the app; there is no reason to edit them.
+`command_frecency` records how often and how recently you run a command, so the
+palette can rank it. It is written by the app; there is no reason to edit it.
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx
index 9b405502..c1de9221 100644
--- a/docs/reference/privacy.mdx
+++ b/docs/reference/privacy.mdx
@@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies)
| | |
|---|---|
| Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) |
-| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk |
+| Saved SSH hosts | `/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. |
+| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk |
| Pane scrollback tails | `/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. |
| Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. |
diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx
index 4002d9cb..1b4c424f 100644
--- a/docs/remote/port-forwarding.mdx
+++ b/docs/remote/port-forwarding.mdx
@@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`.
These live only as long as the session unless you save them into the profile.
+## Switching a rule off
+
+Every forward in the Ports section has a switch. Off, the forward lets go of
+its port but keeps its whole rule — kind, ports, target, description — so it
+can be switched back on without retyping anything. That is how one local port
+is pointed at different places: keep one rule per target on the same port, and
+switch on the one you want.
+
+Only one of them can hold the port at a time. Switching one on while another
+still has its port is refused, and the notice names the forward to switch off
+first.
+
+Rules from a profile carry their switch into it, so the next connection opens
+the same set. The same switch sits beside each rule under **Settings → SSH →**
+a profile **→ Port forwarding**; a rule saved switched off is listed but not
+opened. Rules added mid-session are still gone when the session ends, on or
+off.
+
diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx
index 138d5b96..681e8bb2 100644
--- a/docs/remote/ssh.mdx
+++ b/docs/remote/ssh.mdx
@@ -75,8 +75,10 @@ on the tab it was opened on, not on whichever pane is focused. Saving one opens
on the whole live connection — its proxy, keys and forwards as well as its
address — so the host that lands is the one you were already on.
-Passwords and key passphrases go in the **OS keychain**, never in
-`config.json` and never on disk in plain text. **Forget Password** in a
+Saved hosts are kept in `servers.json`, beside `config.json` rather than in
+it, so a `config.json` you sync between machines does not carry them.
+Passwords and key passphrases go in the **OS keychain**, never in either file
+and never on disk in plain text. **Forget Password** in a
profile's menu removes the stored one.
Deleting a profile drops its keychain credentials and forgets the remote
diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx
index dea2382a..7a6ef61f 100644
--- a/docs/window/tabs-and-splits.mdx
+++ b/docs/window/tabs-and-splits.mdx
@@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the
list is **Settings → Window & Tabs → New tab position** — *After current* by
default, or *At end*.
+An SSH tab is named by whatever the remote shell titles itself, like any other.
+To keep it on the host instead, set **Settings → Window & Tabs → SSH tab
+title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or
+the address you typed — or *Hostname*. A tab you renamed keeps its name either
+way, and a split tab still follows the pane in front.
+
Right-click a tab for the rest: rename, split right or down, a new worktree tab,
copy the working directory, copy the session id, close · close others · close to
the right, and — when a coding agent is running there — mark unread and fork its
diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md
index 40ead057..dc818b0d 100644
--- a/skills/tty7/SKILL.md
+++ b/skills/tty7/SKILL.md
@@ -1,7 +1,7 @@
---
name: tty7
description: >-
- Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
+ Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, run a command in a shell pane you already have and get back its output and exit code, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
---
# Driving tty7 from the command line
@@ -22,7 +22,7 @@ status hooks are installed, and whether `TTY7_CONFIG_DIR` / `TTY7_WS` /
`TTY7_PANE` are set — i.e. whether you are running *inside* a tty7 pane.
Being inside a pane matters for two reasons: the address-taking verbs
-(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) default to
+(`split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`) default to
`$TTY7_PANE`, and `run --keep` files its pane into `$TTY7_WS`. Outside a tty7
shell you must name a target explicitly, and the error will say so rather than
guessing.
@@ -75,7 +75,7 @@ back in.
Omitting the address inside a tty7 shell means "this pane" / "this workspace".
An explicit address always wins over the environment.
-## Running a command: two shapes
+## Running a command: three shapes
### Blocking, with a real exit code
@@ -95,6 +95,28 @@ and the JSON object last — the combined stream is *not* parseable as JSON, so
read the last line. And the pane is 120 columns wide with no way to change it,
so output that assumes a wider terminal wraps.
+### Blocking, in a pane you already have
+
+```bash
+tty7 exec "$PANE" --timeout 900 -- cargo test # prints its output, exits with its code
+```
+
+`exec` types the line at the pane's shell prompt — its cwd, its environment,
+its history — waits for the prompt to come back, prints what the command
+printed (as text, like `capture --plain`; `--raw` keeps the escapes) and exits
+with the command's own code. It is how you run the next command in a pane you
+set up earlier (a `cd`, an activated venv, an `ssh` session with tty7's shell
+integration on the far end) without the send / wait / capture / guess dance.
+
+What it needs, and says when it doesn't have: the pane's shell must have tty7's
+shell integration (a pane that has never sent a prompt mark is refused), and
+the pane must be sitting at a prompt (a busy one is refused — `wait --until
+free` first). The words after `--` become one shell line, so `&&`, pipes and
+redirections work if you quote them: `tty7 exec %3 -- 'make && ./run'`. Always
+give `--timeout` when nobody is watching: a line the shell never runs — an
+unclosed quote waiting on `dquote>` — has no end to wait for. On timeout it
+exits 124 and leaves the command running.
+
### Non-blocking: a pane you talk to over time
This is the one that makes tty7 worth reaching for. Get a pane, send it work,
@@ -135,6 +157,18 @@ for Alt. Repeat it for a sequence; text and keys compose, text first. Typing
`^C` as text does nothing — it arrives as two characters; `--key C-c` is the
real interrupt.
+Two things text on the command line cannot do. **Secrets**: `tty7 send %3
+"$TOKEN"` puts the token in `ps` for every local user and in your shell
+history — pipe it instead, `printf %s "$TOKEN" | tty7 send %3 --stdin --enter`
+(or `--from-file PATH`); the bytes go to the pane exactly as read and are never
+echoed in `--json`. Echo on the far side is the program's business: `docker
+login --password-stdin`, `ssh-add` and `gpg` turn it off themselves.
+**Multi-line text**: a newline on a pty *is* Enter, so each line runs as it
+arrives. `--paste` sends it the way a paste into the window does — framed in
+bracketed paste when the pane has switched that on, so a shell or an agent's
+input box takes the whole block as text; `--json` reports `"bracketed"` so you
+know which it got.
+
**A brand-new pane can swallow the Enter.** A shell still working through its
startup files — a prompt framework, `fastfetch`, anything that paints on login —
takes the text you send but loses the carriage return that follows it, and the
@@ -176,8 +210,9 @@ whole ring, which for a pane that was never resized is the same thing.
`--plain` gives you the screen, and a screen is a rectangle: whatever scrolled
past the top of a long build log is gone, and the exit code was never on screen
-at all. So when what you want is the *answer* rather than the view, have the
-shell write it somewhere clean:
+at all. When the pane is a shell sitting at its prompt, `tty7 exec` (above)
+hands back both. Otherwise — the pane is busy, or its shell has no tty7
+integration — have the shell write the answer somewhere clean:
```bash
tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter
@@ -278,7 +313,7 @@ output on success but never suppresses errors.
The panes on this machine are the user's real work, and some of them are other
coding agents mid-task. Treat anything you did not create as read-only:
-- **Never `send` into a pane you didn't open.** Keystrokes into another agent's
+- **Never `send` or `exec` into a pane you didn't open.** Keystrokes into another agent's
pane, or into a shell the user is typing in, land in the middle of whatever
is happening there. Check `tty7 agents` before you touch a pane. This goes
double for `--key`: a stray `C-c` kills somebody's work.
diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md
index 4ed7ac13..9c42c734 100644
--- a/skills/tty7/references/commands.md
+++ b/skills/tty7/references/commands.md
@@ -30,7 +30,7 @@ Set inside every tty7 pane, inherited by anything you launch from one.
| Variable | Meaning |
|---|---|
-| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. |
+| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`. |
| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `tab ls`, `ws tree`. |
| `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. |
@@ -44,13 +44,13 @@ Outside a tty7 shell the address-taking verbs fail with
| 0 | success |
| 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` |
| 2 | usage error (clap) — unknown verb, missing argument, bad type |
-| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
+| 124 | `tty7 wait` or `tty7 exec` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" |
| 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. |
-| *other* | only from `tty7 run`, which passes the child's exit code through |
+| *other* | only from `tty7 run` and `tty7 exec`, which pass the command's exit code through |
-If `run` cannot learn the child's code it prints a note to stderr and exits 1
-with `"exit_code_known": false` in the JSON — that is how you tell a real 1
-from a stand-in.
+If `run` or `exec` cannot learn the command's code it prints a note to stderr
+and exits 1 with `"exit_code_known": false` in the JSON — that is how you tell
+a real 1 from a stand-in.
## Top-level verbs
@@ -85,6 +85,41 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo.
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
output. The combined stream is not valid JSON; read the last line.
+### `tty7 exec [%PANE] [--timeout SECS] [--raw] -- CMD...`
+Runs `CMD` in an existing pane's shell and blocks until its prompt comes back:
+the words after `--` are joined with spaces and typed at the prompt as one line
+followed by Enter, so the shell interprets them — quote `&&`, `|` and `>` to
+keep them from your own shell. Prints what the command printed and exits with
+its exit code. The pane (default `$TTY7_PANE`) keeps its cwd, environment and
+history, which is the difference from `run`: `run` makes a new pane for an
+argv, `exec` uses a shell you already have.
+
+The shell integration's OSC 133 marks bound the command: the output is what the
+pane printed between the command starting (`C`) and finishing (`D`, which also
+carries the exit code). Refused, with nothing typed:
+
+- a pane whose shell has sent no prompt mark — no integration, so nothing would
+ ever say the command ended; `tty7 doctor`, or fall back to `send` + `wait
+ --until free`
+- a pane that is not at a prompt — something already runs there (your own pane
+ always is: it is running `tty7 exec`)
+- a line holding a control character (newline, tab, ESC), which the line editor
+ would act on rather than type — join commands with `;` / `&&`
+
+| Flag | |
+|---|---|
+| `--timeout SECS` | Give up after SECS, exit 124. The command is left running; `send %N --key C-c` stops it. With no timeout it waits for as long as the command runs |
+| `--raw` | The output as the pane received it, escapes intact. By default it is replayed through a terminal to text, the way `capture --plain` reads it — which also erases zsh's PROMPT_SP, drawn before the finish mark |
+
+A line the shell never starts — an unclosed quote waiting on a continuation
+prompt — never finishes; the timeout message says that is the likely cause.
+A line the shell rejects outright (a syntax error) exits 1 with
+`"exit_code_known": false` and the shell's complaint as the output. Output past
+8 MiB is dropped from the front, with a note on stderr.
+
+JSON: `{"pane","command","exit","exit_code_known","timed_out","pane_exited","output","bytes","elapsed_ms"}`
+— `output` is the printed form, `bytes` the raw length.
+
### `tty7 new [PATH] [--open]`
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints
the workspace id. JSON: `{"id","pane","opened"}`.
@@ -100,7 +135,7 @@ pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the
share kept by the *existing* pane, clamped to 0.05–0.95 — a `--ratio 70`
silently becomes 0.95, not an error. Prints `%NN`. JSON: `{"pane"}`.
-### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…`
+### `tty7 send [%PANE] [TEXT | --stdin | --from-file PATH] [--paste] [--enter] [--key KEY]…`
Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key
enter` — it appends CR to the text, or presses Enter on its own when there is
none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With
@@ -115,7 +150,28 @@ that still doesn't parse (`%3x`) is an address error, never text for your own
pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types
as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To
type an address-shaped string, name the pane as well: `tty7 send %42 %3x`.
-JSON: `{"pane","sent","enter","keys"}`.
+JSON: `{"pane","sent","source","bytes","paste","bracketed","bracketed_paste_mode","enter","keys"}`
+— `source` is `argument`, `stdin`, `file` or `none`.
+
+`--stdin` / `--from-file PATH` take the text from there instead of an argument
+— for a secret, which as an argument sits in `ps` for every local user and in
+your shell history. The bytes go to the pane exactly as read (no escape
+interpretation, no newline conversion, not required to be UTF-8), and `sent` is
+`null` in the JSON so they are not echoed into a log either. With either, the
+one positional is the pane and must be a valid address. Use `printf %s`, not
+`echo`, unless you mean the trailing newline — on a pty that newline is Enter.
+Whether the far side echoes the secret is up to the program reading it, not
+tty7: `docker login --password-stdin`, `ssh-add` and `gpg` turn echo off.
+
+`--paste` sends the text as the GUI sends a paste. When the pane has switched on
+bracketed paste (mode 2004 — shells at their prompt and most TUIs do), the text
+is wrapped in `ESC[200~ … ESC[201~` with CRLF folded to LF and every ESC byte
+stripped, so the text cannot end the paste early; multi-line text then lands as
+one block instead of running a line at a time. When the pane has not, the text
+goes unframed with each line break sent as Enter — what a paste into such a pane
+has always done — and a note goes to stderr. `bracketed` says which was sent;
+`bracketed_paste_mode` is what the pane reported (`null` from a server too old
+to say). `--enter` after `--paste` submits the pasted block.
`--key` presses a key instead of typing characters — the arrow keys a
permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a
@@ -196,9 +252,13 @@ The process tree inside the pane, indented by depth, `*` on the foreground
process — then a second table of ports those processes are listening on.
Prints `nothing running in this pane` when both are empty.
-JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}`,
+JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{…}}`,
where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`,
-`[::1]`, or a specific interface).
+`[::1]`, or a specific interface). `context` is what the pane says about itself:
+`local_pty`, `remote` (the host it is pointed at), `at_prompt` (from its shell
+integration's marks; absent until the first one), `remote_prompt_seen`, and
+`bracketed_paste` (whether the pane has mode 2004 on — what `send --paste`
+reads).
Nothing below the depth-0 shell means the foreground command has exited — but
you rarely need to check that by hand, because that is exactly what
diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs
index d1fb5e69..50e4187b 100644
--- a/src/core/agent_prompt.rs
+++ b/src/core/agent_prompt.rs
@@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option
}
pub fn submit_bytes(prompt: &str) -> Vec {
- let mut bytes = b"\x1b[200~".to_vec();
- bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
- bytes.extend_from_slice(b"\x1b[201~\r");
+ let mut bytes = tty7_core::core::paste::bracket(prompt.as_bytes());
+ bytes.push(b'\r');
bytes
}
diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs
index c98b452f..101e5ea2 100644
--- a/src/core/ssh_config.rs
+++ b/src/core/ssh_config.rs
@@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option {
bind,
target,
description: String::new(),
+ enabled: true,
})
}
diff --git a/src/main.rs b/src/main.rs
index 8a9500df..7c9f73a7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) {
if !tty7_core::host::is_content_change(&event.kind) {
return;
}
- let hit = event
- .paths
- .iter()
- .any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p));
+ // The saved SSH hosts live beside config.json in a file of their own
+ // (#911), and a hand edit there has to land the same way.
+ let hit = event.paths.iter().any(|p| {
+ p.file_name() == watched_file.file_name()
+ || p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE))
+ || is_theme_file(p)
+ });
if hit {
let _ = tx.try_send(());
}
@@ -705,6 +708,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
+ crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
diff --git a/src/terminal/element.rs b/src/terminal/element.rs
index 448aedc1..7424b570 100644
--- a/src/terminal/element.rs
+++ b/src/terminal/element.rs
@@ -884,8 +884,8 @@ fn segment_row(row: &[RenderCell]) -> Vec {
thread_local! {
static CHAR_STRINGS: RefCell> = RefCell::new(HashMap::new());
- /// Measured ink extents and the font size they were measured at.
- static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>)> =
+ /// Measured ink bounds and the font size they were measured at.
+ static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>>)> =
RefCell::new((px(0.), HashMap::new()));
}
@@ -1089,6 +1089,9 @@ fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width:
}
/// How much to shrink a segment so its glyph stops inside its budget.
+///
+/// Never more than 1. A fallback icon is not held to this at all:
+/// [`icon_fit`] sizes it, up or down.
fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
if ink <= budget || ink <= px(0.) {
1.
@@ -1097,22 +1100,157 @@ fn fit_scale(ink: Pixels, budget: Pixels) -> f32 {
}
}
-/// Where a segment's ink ends, measured from the left edge of its first cell.
+/// Whether the platform's `typographic_bounds` is the glyph's ink.
+///
+/// CoreText and DirectWrite answer with the ink box. cosmic-text answers with
+/// the advance box pinned to the baseline, which says nothing about where an
+/// icon's ink sits; sizing and centring on that would misplace it, so the
+/// Linux build keeps the shrink-only rule for icons too.
+const INK_BOUNDS_ARE_INK: bool = cfg!(any(target_os = "macos", target_os = "windows"));
+
+/// The most an icon is ever enlarged by.
+///
+/// The cap keeps a glyph that is small by design — a dot, a bullet — from
+/// being blown up to a cell-sized blob.
+const MAX_ICON_GROWTH: f32 = 2.;
+
+/// Whether a character is an icon: a code point in one of Unicode's Private
+/// Use Areas, where Nerd Fonts, Font Awesome, Codicons and Material Design
+/// icons all live.
+///
+/// Nothing in these ranges is text, so sizing one to its cells cannot change
+/// how prose reads — the reason the rule is keyed on the code point and not
+/// on the face: a CJK or emoji fallback keeps its own metrics.
+///
+/// The Powerline separators (`U+E0B0`–`U+E0D7`) are not icons. They are
+/// shapes meant to abut the cells around them, the solid ones are painted
+/// natively, and the thin ones already reach the row's full height, so
+/// fitting them to an em and centring them would only pull them away from
+/// the segments they join.
+fn is_icon(c: char) -> bool {
+ matches!(
+ c as u32,
+ 0xE000..=0xE0AF | 0xE0D8..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x10_0000..=0x10_FFFD
+ )
+}
+
+/// Whether the cell after an icon can lend it room, the way kitty lets a
+/// Private Use Area glyph followed by a space take two cells.
+///
+/// Everything [`has_room_after`] lends, and one thing more: a blank that
+/// paints the *same* background as the icon's own cell. That is the space
+/// after every icon in a coloured prompt segment, and backgrounds are painted
+/// before any glyph, so the icon lands on its own colour. Held back to one
+/// cell there, a Nerd Font icon is shrunk to about two thirds of the text's
+/// height, which is #866.
+fn lends_to_icon(row: &[RenderCell], start: usize, cells: usize) -> bool {
+ if has_room_after(row, start, cells) {
+ return true;
+ }
+ let (Some(icon), Some(next)) = (row.get(start), row.get(start + cells)) else {
+ return false;
+ };
+ is_blank(next)
+ && !next.selected
+ && icon.draw_bg
+ && next.draw_bg
+ && next.bg == icon.bg
+ && next.match_hit == icon.match_hit
+ && next.match_current == icon.match_current
+ && !GlyphStyle::of(next).draws_on_blanks()
+}
+
+/// How to size an icon a fallback face supplied, and how many cells it spans.
+///
+/// A fallback face is drawn at the primary's font size on its own metrics, so
+/// what an icon looks like beside the text depends on how the two faces'
+/// cells compare. Symbols Nerd Font Mono inks a full em per icon, which is
+/// 1.6 cells of a 0.6em primary; a face with a narrower cell than the
+/// primary's inks less than one. Neither is the icon at the text's size.
+///
+/// So fit the ink, aspect ratio kept, to the cells it is given and one em of
+/// height — the size the icon was drawn at in its own face, and never taller
+/// than the text's line. That may shrink or grow it, growth capped at
+/// [`MAX_ICON_GROWTH`] and ignored under 2%, which is not worth a reshape.
+/// When `lend` says the next cell is free the icon may take it too, but only
+/// if that lets it come out bigger: a narrow glyph that already fits one cell
+/// keeps to it and stays beside what follows.
+fn icon_fit(
+ ink: Bounds,
+ cell_width: Pixels,
+ cells: usize,
+ lend: bool,
+ height: Pixels,
+) -> (f32, usize) {
+ let (w, h) = (ink.size.width.as_f32(), ink.size.height.as_f32());
+ if !(w > 0. && h > 0.) {
+ return (1., cells);
+ }
+ let fit = |cells: usize| {
+ let scale = (cell_width.as_f32() * cells as f32 / w)
+ .min(height.as_f32() / h)
+ .min(MAX_ICON_GROWTH);
+ if (1. ..=1.02).contains(&scale) {
+ 1.
+ } else {
+ scale
+ }
+ };
+ let own = fit(cells);
+ if lend && fit(cells + 1) > own {
+ (fit(cells + 1), cells + 1)
+ } else {
+ (own, cells)
+ }
+}
+
+/// Where to paint a fitted icon, relative to its cell's top-left corner, so
+/// its ink sits centred in `room` — vertically always, and horizontally only
+/// when `centre_x`. An icon spilling into a borrowed cell stays where its
+/// face put it instead, as it would unfitted, so the gap before the text
+/// after it is the one its face drew.
+///
+/// `ink` is the glyph's bounds at its native size (y up from the baseline);
+/// `ascent` and `descent` are the line's as shaped at the fitted size, which
+/// is what gpui centres the baseline in the row with.
+fn icon_offset(
+ ink: Bounds,
+ scale: f32,
+ room: gpui::Size,
+ centre_x: bool,
+ ascent: Pixels,
+ descent: Pixels,
+) -> Point {
+ let baseline = (room.height - ascent - descent) / 2. + ascent;
+ let ink_top = baseline - (ink.origin.y + ink.size.height) * scale;
+ let x = if centre_x {
+ (room.width - ink.size.width * scale) / 2. - ink.origin.x * scale
+ } else {
+ // Nudged only as far as it takes to keep the ink inside the room.
+ px(0.)
+ .min(room.width - (ink.origin.x + ink.size.width) * scale)
+ .max(-ink.origin.x * scale)
+ };
+ point(x, (room.height - ink.size.height * scale) / 2. - ink_top)
+}
+
+/// The ink bounds of a segment's first glyph, relative to its pen position,
+/// y up from the baseline.
///
/// Advance is the wrong yardstick: Apple Color Emoji advances 1.31em but only
/// inks 1.25em, so going by advance shrinks glyphs that would have fit. Ask
/// for the glyph's own bounds instead, cached per (face, char) because the
/// lookup is a font query a screenful of CJK would otherwise repeat on every
/// cell of every frame.
-fn ink_extent(
+fn ink_bounds(
cx: &App,
shaped: &gpui::ShapedLine,
text: &str,
font_size: Pixels,
-) -> Option {
+) -> Option> {
let font_id = shaped.runs.first()?.font_id;
let c = text.chars().next()?;
- INK_EXTENTS.with(|cache| {
+ INK_BOUNDS.with(|cache| {
let hit = {
let mut cache = cache.borrow_mut();
if cache.0 != font_size {
@@ -1127,13 +1265,12 @@ fn ink_extent(
if let Some(extent) = hit {
return extent;
}
- let extent = cx
+ let bounds = cx
.text_system()
.typographic_bounds(font_id, font_size, c)
- .ok()
- .map(|bounds| bounds.origin.x + bounds.size.width);
- cache.borrow_mut().1.insert((font_id, c), extent);
- extent
+ .ok();
+ cache.borrow_mut().1.insert((font_id, c), bounds);
+ bounds
})
}
@@ -1234,7 +1371,7 @@ fn shape_piece(
.shape_line(piece, font_size, run_buf, force_width)
}
-/// Whether [`ink_extent`]'s answer speaks for the whole segment.
+/// Whether [`ink_bounds`]'s answer speaks for the whole segment.
///
/// It measures the segment's first character in the run's first face. That is
/// all of a one-character segment and only part of anything longer: a
@@ -1284,6 +1421,9 @@ fn paint_glyphs(
build_font(bold_font.unwrap_or(base_font), true, true),
];
+ // Resolved only once an icon asks, so a pane without one pays nothing.
+ let mut primary_ids: [Option; 4] = [None; 4];
+
let run_buf = &mut [TextRun {
len: 0,
font: base_font.clone(),
@@ -1435,17 +1575,40 @@ fn paint_glyphs(
.shape_line(text.clone(), font_size, run_buf, force_width);
// Measured before the budget is set, because how far a solo glyph
// may reach turns on whether its ink is known at all.
- let ink = ink_extent(cx, &shaped, &text, font_size);
+ let bounds = ink_bounds(cx, &shaped, &text, font_size);
+ let ink = bounds.map(|b| b.origin.x + b.size.width);
+ let measured = ink_covers_segment(ink, &text);
let budget = seg_budget(
solo,
- ink_covers_segment(ink, &text),
+ measured,
cells,
has_room_after(row_cells, start, cells),
geom.cell_width,
);
- if let Some(ink) = ink {
- let scale = fit_scale(ink, budget);
- if scale < 1. {
+ let mut origin = point(x, y);
+ let mut clip_width = budget;
+ if let (Some(ink), Some(bounds)) = (ink, bounds) {
+ // Only a lone icon that a fallback face supplied is fitted:
+ // the primary's own icons were drawn for its cell already.
+ let icon = INK_BOUNDS_ARE_INK
+ && measured
+ && text.chars().next().is_some_and(is_icon)
+ && shaped.runs.first().is_some_and(|r| {
+ r.font_id
+ != *primary_ids[face_ix].get_or_insert_with(|| {
+ window.text_system().resolve_font(&faces[face_ix])
+ })
+ });
+ let (scale, room) = if icon {
+ let lend = lends_to_icon(row_cells, start, cells);
+ let height = font_size.min(geom.line_height);
+ let (scale, spans) = icon_fit(bounds, geom.cell_width, cells, lend, height);
+ let room = size(geom.cell_width * spans as f32, geom.line_height);
+ (scale, Some((room, spans == cells)))
+ } else {
+ (fit_scale(ink, budget), None)
+ };
+ if scale != 1. {
shaped = window.text_system().shape_line(
text.clone(),
font_size * scale,
@@ -1453,18 +1616,16 @@ fn paint_glyphs(
force_width,
);
}
+ if let Some((room, centre_x)) = room {
+ origin +=
+ icon_offset(bounds, scale, room, centre_x, shaped.ascent, shaped.descent);
+ clip_width = room.width;
+ }
}
- let clip = Bounds::new(point(x, y), size(budget, geom.line_height));
+ let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height));
window.with_content_mask(Some(ContentMask { bounds: clip }), |window| {
- _ = shaped.paint(
- point(x, y),
- geom.line_height,
- TextAlign::Left,
- None,
- window,
- cx,
- );
+ _ = shaped.paint(origin, geom.line_height, TextAlign::Left, None, window, cx);
});
}
}
@@ -2675,11 +2836,12 @@ mod tests {
}
#[test]
- fn fit_scale_only_shrinks_glyphs_that_overflow_their_budget() {
+ fn fit_scale_shrinks_what_overflows_and_never_enlarges() {
let budget = px(15.);
assert_eq!(fit_scale(px(19.2), budget), 15. / 19.2);
// Whatever already fits keeps its own size, including a glyph that
- // lands exactly on the edge and a run that measured as empty.
+ // lands exactly on the edge and a run that measured as empty. Making
+ // a small glyph bigger is `icon_fit`'s rule, not this one's.
assert_eq!(fit_scale(px(15.), budget), 1.);
assert_eq!(fit_scale(px(12.), budget), 1.);
assert_eq!(fit_scale(px(0.), budget), 1.);
@@ -4452,4 +4614,176 @@ mod tests {
Some(&['\u{FE0F}'][..]),
);
}
+
+ fn ink(x: f32, y: f32, w: f32, h: f32) -> Bounds {
+ Bounds::new(point(px(x), px(y)), size(px(w), px(h)))
+ }
+
+ /// Menlo at 22px: a 13.2px cell in a 30.8px row (line height 1.4).
+ const CELL: f32 = 13.2;
+ const EM: f32 = 22.;
+
+ fn fit(ink: Bounds, cells: usize, lend: bool) -> (f32, usize) {
+ icon_fit(ink, px(CELL), cells, lend, px(EM))
+ }
+
+ #[test]
+ fn only_private_use_code_points_count_as_icons() {
+ for c in [
+ '\u{e000}', // first of the BMP Private Use Area
+ '\u{e0a0}', // Powerline branch
+ '\u{e0d8}', // first past the Powerline separators
+ '\u{f179}', // Font Awesome apple
+ '\u{f8ff}', // last of the BMP Private Use Area
+ '\u{f0001}', // Material Design icon, Supplementary PUA-A
+ '\u{10fffd}', // end of Supplementary PUA-B
+ ] {
+ assert!(is_icon(c), "U+{:04X}", c as u32);
+ }
+ // Text a fallback face supplies keeps its own metrics: CJK, emoji,
+ // Latin, and the drawing characters painted natively anyway.
+ for c in [
+ '中',
+ 'あ',
+ '한',
+ '\u{1F600}',
+ '\u{2764}',
+ 'M',
+ '─',
+ '█',
+ '\u{f900}',
+ ] {
+ assert!(!is_icon(c), "U+{:04X}", c as u32);
+ }
+ // Nor do the Powerline separators, which are shapes, not icons.
+ for c in ['\u{e0b0}', '\u{e0b1}', '\u{e0b3}', '\u{e0d7}'] {
+ assert!(!is_icon(c), "U+{:04X}", c as u32);
+ }
+ }
+
+ #[test]
+ fn a_nerd_font_icon_before_a_space_takes_two_cells_at_full_size() {
+ // Symbols Nerd Font Mono's apple, as CoreText measures it at 22px: a
+ // full em tall and 1.4 cells wide. Held to one cell it is shrunk to
+ // 0.71 of the text's size, which is what #866 saw; with the space
+ // after it lent, it keeps its whole em.
+ let apple = ink(1.76, -4.43, 18.48, 22.03);
+ let (held, cells) = fit(apple, 1, false);
+ assert_eq!(cells, 1);
+ assert!((apple.size.width.as_f32() * held - CELL).abs() < 1e-3);
+ let (lent, cells) = fit(apple, 1, true);
+ assert_eq!(cells, 2);
+ assert!((apple.size.height.as_f32() * lent - EM).abs() < 1e-3);
+ assert!(apple.size.width.as_f32() * lent <= 2. * CELL);
+ }
+
+ #[test]
+ fn a_narrow_icon_keeps_to_its_own_cell_even_when_the_next_is_free() {
+ // The branch symbol is an em tall and 0.4em wide: one cell already
+ // holds it at full height, so borrowing would only push it off
+ // towards the text after it.
+ let branch = ink(6.52, -4.4, 8.96, 22.);
+ assert_eq!(fit(branch, 1, true), (1., 1));
+ }
+
+ #[test]
+ fn a_small_icon_from_a_narrow_face_grows_to_fill_its_cell() {
+ // A face with a narrower cell than the primary inks well under it.
+ let icon = ink(0.5, -1., 8.8, 9.);
+ let (scale, cells) = fit(icon, 1, false);
+ assert_eq!(cells, 1);
+ assert!(scale > 1.);
+ assert!((icon.size.width.as_f32() * scale - CELL).abs() < 1e-3);
+ // The same icon two cells wide may use both.
+ let (two, cells) = fit(icon, 2, false);
+ assert_eq!(cells, 2);
+ assert!(two > scale);
+ }
+
+ #[test]
+ fn a_fitted_icon_is_centred_in_its_room() {
+ let icon = ink(0.4, -0.6, 5.3, 6.);
+ let room = size(px(CELL), px(30.8));
+ let (scale, _) = fit(icon, 1, false);
+ let (ascent, descent) = (px(20.4 * scale), px(5.2 * scale));
+ let at = icon_offset(icon, scale, room, true, ascent, descent);
+ // Spilling into a borrowed cell, it stays where its face put it,
+ // unless that would take its ink past either edge of the room.
+ assert_eq!(
+ icon_offset(icon, scale, room, false, ascent, descent).x,
+ px(0.)
+ );
+ let lent = size(px(2. * CELL), px(30.8));
+ let overhang = ink(-1., 0., 20., 20.);
+ assert_eq!(
+ icon_offset(overhang, 1., lent, false, ascent, descent).x,
+ px(1.)
+ );
+ let pushed = ink(8., 0., 20., 20.);
+ let x = icon_offset(pushed, 1., lent, false, ascent, descent).x;
+ assert!((x.as_f32() - (2. * CELL - 28.)).abs() < 1e-4, "{x:?}");
+ let left = at.x.as_f32() + icon.origin.x.as_f32() * scale;
+ let right = left + icon.size.width.as_f32() * scale;
+ assert!((left - (CELL - right)).abs() < 1e-3, "{left}..{right}");
+ let baseline = at.y + (room.height - ascent - descent) / 2. + ascent;
+ let top = (baseline - (icon.origin.y + icon.size.height) * scale).as_f32();
+ let bottom = (baseline - icon.origin.y * scale).as_f32();
+ assert!((top - (30.8 - bottom)).abs() < 1e-3, "{top}..{bottom}");
+ }
+
+ #[test]
+ fn icon_fit_leaves_what_is_already_the_right_size_alone() {
+ // Within 2% of the cell: not worth shaping the glyph again.
+ assert_eq!(fit(ink(0., 0., 13., 13.), 1, false), (1., 1));
+ // Nothing measured, nothing to scale by.
+ assert_eq!(fit(ink(0., 0., 0., 0.), 1, true), (1., 1));
+ // A dot is small by design and grows no more than the cap.
+ assert_eq!(fit(ink(1., 2., 2., 2.), 1, false).0, MAX_ICON_GROWTH);
+ }
+
+ fn icon_row(next: RenderCell) -> Vec {
+ let icon = RenderCell {
+ c: '\u{f179}',
+ ..RenderCell::default()
+ };
+ vec![icon, next]
+ }
+
+ #[test]
+ fn a_blank_in_the_same_colour_lends_an_icon_its_cell() {
+ let blank = RenderCell::default();
+ assert!(lends_to_icon(&icon_row(blank.clone()), 0, 1));
+ // Whatever `has_room_after` lends, the end of the row included.
+ assert!(lends_to_icon(&icon_row(blank.clone())[..1], 0, 1));
+ // A prompt segment: icon and the space after it on one background.
+ let bg = Hsla::blue();
+ let mut row = icon_row(RenderCell {
+ bg,
+ draw_bg: true,
+ ..RenderCell::default()
+ });
+ row[0].bg = bg;
+ row[0].draw_bg = true;
+ assert!(lends_to_icon(&row, 0, 1));
+ // The next segment's colour, a glyph or a selection do not.
+ row[1].bg = Hsla::red();
+ assert!(!lends_to_icon(&row, 0, 1));
+ let letter = RenderCell {
+ c: 'x',
+ ..RenderCell::default()
+ };
+ assert!(!lends_to_icon(&icon_row(letter), 0, 1));
+ let selected = RenderCell {
+ selected: true,
+ ..RenderCell::default()
+ };
+ assert!(!lends_to_icon(&icon_row(selected), 0, 1));
+ // Nor does a coloured blank after an icon on the default background.
+ let coloured = RenderCell {
+ bg,
+ draw_bg: true,
+ ..blank
+ };
+ assert!(!lends_to_icon(&icon_row(coloured), 0, 1));
+ }
}
diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs
index b319c5c2..340596d7 100644
--- a/src/terminal/remote.rs
+++ b/src/terminal/remote.rs
@@ -2296,6 +2296,29 @@ impl RemoteTerminal {
.ok()
}
+ /// The list after switching one forward on or off, or why it was not
+ /// switched — the daemon's own refusal, which names what is in the way.
+ pub fn set_forward_enabled(
+ pane_id: u64,
+ forward_id: u64,
+ enabled: bool,
+ ) -> anyhow::Result> {
+ let mut stream = connect()?;
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ }
+ .encode(&mut stream)?;
+ match DaemonMsg::read(&mut stream)? {
+ DaemonMsg::ForwardList(list) => Ok(list),
+ DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
+ other => Err(anyhow::anyhow!(
+ "unexpected reply to SetForwardEnabled: {other:?}"
+ )),
+ }
+ }
+
pub fn list_forwards(pane_id: u64) -> Vec {
fn query(pane_id: u64) -> anyhow::Result> {
let mut stream = connect()?;
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index cca61631..5c810f08 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -850,15 +850,7 @@ fn ring_system_bell() -> bool {
}
fn paste_bytes(text: &str, bracketed: bool) -> Vec {
- if bracketed {
- let text = text.replace("\r\n", "\n");
- let mut bytes = b"\x1b[200~".to_vec();
- bytes.extend(text.bytes().filter(|&b| b != 0x1b));
- bytes.extend_from_slice(b"\x1b[201~");
- bytes
- } else {
- text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
- }
+ tty7_core::core::paste::paste_bytes(text.as_bytes(), bracketed)
}
/// A line the shell can be handed byte for byte, as if it had been typed at its
@@ -1774,6 +1766,35 @@ impl TerminalView {
stated_title(&self.title)
}
+ /// The title this pane gives its tab: [`Self::stated_title`], unless this
+ /// is an SSH pane and Settings pins its tab to the host's name instead
+ /// (#726). The pane's own title is untouched either way — OSC 0/2 keep
+ /// landing in it, and it is back on the tab the moment the setting is.
+ ///
+ /// A pane that has ended still says so: the pinned name takes the same
+ /// suffix the pane's own title would have.
+ pub(crate) fn tab_title(&self, cx: &App) -> Option {
+ let pinned = self.ssh_spec.as_deref().and_then(|spec| {
+ let cfg = cx.try_global::()?;
+ crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles)
+ });
+ match pinned {
+ Some(name) if self.terminal.exited => Some(self.ended_title(&name)),
+ Some(name) => Some(name),
+ None => self.stated_title().map(str::to_string),
+ }
+ }
+
+ /// `name` with the suffix that says how this pane ended.
+ fn ended_title(&self, name: &str) -> String {
+ let key = if self.workspace().is_some() && !self.terminal.child_exited() {
+ L10nKey::PaneTitleDisconnected
+ } else {
+ L10nKey::PaneTitleProcessExited
+ };
+ t_fmt(key, &[("title", name)])
+ }
+
/// Sets how opaque the pane wants this terminal painted; the pane leaf
/// calls this every frame while rendering, and the terminal element
/// blends its colours toward the window background during paint (see
@@ -2257,17 +2278,7 @@ impl TerminalView {
self.pending_title = None;
// The pane keeps answering to its own name (an SSH pane's
// host, #438) — only the state suffix is localized (#602).
- self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
- t_fmt(
- L10nKey::PaneTitleDisconnected,
- &[("title", &self.default_title)],
- )
- } else {
- t_fmt(
- L10nKey::PaneTitleProcessExited,
- &[("title", &self.default_title)],
- )
- };
+ self.title = self.ended_title(&self.default_title);
if self.terminal.child_exited() {
cx.emit(ChildExited);
}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 57da3a53..33d8bde4 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -656,7 +656,10 @@ impl Tab {
// name tty7 chose for it deliberately, and `stated_title` hands
// those up as the title the pane is showing.
title: crate::terminal::view::DEFAULT_TITLE.to_string(),
- osc_title: leaf.stated_title().map(str::to_string),
+ // On the title's rung, not a new one above it: a name given to the
+ // tab still beats an SSH host pinned by Settings (#726), and the
+ // pinned host beats everything the pane could say about itself.
+ osc_title: leaf.tab_title(cx),
cwd: leaf.cwd().map(|p| p.display().to_string()),
agent: leaf.agent(),
status: leaf.agent_session().map(|s| s.status),
@@ -3095,7 +3098,7 @@ impl Tty7App {
cx: &mut Context,
) {
let fields = self.managed_forward_fields(cx);
- let Some(rule) = fields.collect() else {
+ let Some(mut rule) = fields.collect() else {
// Add is disabled while the fields do not make a rule and the form
// already says what is missing, so there is nothing to do here and
// nothing left to explain.
@@ -3103,6 +3106,10 @@ impl Tty7App {
};
let route = self.forward_route(pane_id, cx);
let previous = self.loopback_panel.mf_editing.clone();
+ // Editing a switched-off rule edits it; it does not switch it on.
+ if let Some(old) = &previous {
+ rule.enabled = old.enabled;
+ }
// A saved edit is a replace, and the rule being replaced has to come
// out first: the ordinary edit keeps the bind port, and the far side
// really does bind it, so adding first would collide with the very
@@ -3206,10 +3213,14 @@ impl Tty7App {
let Some(list) = route.add(rule) else {
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
};
- let broken = added_forward(&before, &list).and_then(|added| match &added.status {
- ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
- ForwardStatus::Listening => None,
- });
+ // A switched-off rule reports an error status because it binds
+ // nothing, and that is what it was asked to do.
+ let broken = added_forward(&before, &list)
+ .filter(|added| added.enabled)
+ .and_then(|added| match &added.status {
+ ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
+ ForwardStatus::Listening => None,
+ });
self.loopback_panel.managed = list;
let Some((id, msg)) = broken else {
return PlaceOutcome::Placed;
@@ -3297,6 +3308,84 @@ impl Tty7App {
cx.notify();
}
+ /// Switch one forward on or off, and carry the choice into the saved host
+ /// the rule came from, if it came from one — so the next connection opens
+ /// the same set of rules instead of the ones that were saved before.
+ pub(crate) fn set_managed_forward_enabled(
+ &mut self,
+ pane_id: u64,
+ forward_id: u64,
+ enabled: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let route = self.forward_route(pane_id, cx);
+ let forward = self
+ .loopback_panel
+ .managed
+ .iter()
+ .find(|m| m.id == forward_id)
+ .cloned();
+ match route.set_enabled(forward_id, enabled) {
+ Ok(list) => {
+ self.loopback_panel.managed = list;
+ if let Some(forward) = forward {
+ self.remember_forward_enabled(pane_id, &forward, enabled, cx);
+ }
+ }
+ Err(e) => {
+ window.push_notification(
+ t_fmt(L10nKey::ForwardSwitchFailed, &[("error", &e.to_string())]),
+ cx,
+ );
+ // The refusal changed nothing, but the panel may be behind
+ // whatever did change — ask rather than guess.
+ self.loopback_panel.managed = route.list();
+ }
+ }
+ cx.notify();
+ }
+
+ fn remember_forward_enabled(
+ &mut self,
+ pane_id: u64,
+ forward: &crate::daemon::protocol::ManagedForward,
+ enabled: bool,
+ cx: &mut Context,
+ ) {
+ let profile_id = self
+ .tabs
+ .iter()
+ .flat_map(|tab| tab.pane.terminals())
+ .find_map(|leaf| {
+ let view = leaf.read(cx);
+ (view.pane_id == pane_id).then(|| view.ssh_spec()?.profile_id.clone())?
+ })
+ .and_then(|id| uuid::Uuid::parse_str(&id).ok());
+ let Some(profile_id) = profile_id else {
+ return;
+ };
+ let saved = cx
+ .global::()
+ .ssh_profiles
+ .iter()
+ .find(|p| p.id == profile_id)
+ .and_then(|p| crate::ui::forwards::saved_rule_index(&p.forwards, forward, enabled));
+ let Some(idx) = saved else {
+ return;
+ };
+ self.update_config(cx, |cfg| {
+ if let Some(rule) = cfg
+ .ssh_profiles
+ .iter_mut()
+ .find(|p| p.id == profile_id)
+ .and_then(|p| p.forwards.get_mut(idx))
+ {
+ rule.enabled = enabled;
+ }
+ });
+ }
+
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context) {
// Whatever the panel would let this pane forward, which is a wider set
// than "a connected native-ssh pane": a pane in a remote workspace
@@ -3507,6 +3596,14 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on);
}
+ pub(crate) fn set_ssh_tab_title(
+ &mut self,
+ mode: crate::core::config::SshTabTitle,
+ cx: &mut Context,
+ ) {
+ self.update_config(cx, |cfg| cfg.ssh_tab_title = mode);
+ }
+
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context) {
let next = match cx.global::().tab_bar_position {
TabBarPosition::Top => TabBarPosition::Left,
@@ -6014,6 +6111,7 @@ impl Tty7App {
L10nKey::SettingsDiffPreviewFromCounts => {
self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx)
}
+ L10nKey::SettingsSshTabTitle => self.set_ssh_tab_title(defaults.ssh_tab_title, cx),
L10nKey::SettingsNotifyOnCommandFinish => {
self.set_notify_mode(defaults.notify_on_command_finish, cx)
}
@@ -8416,6 +8514,31 @@ impl ForwardRoute {
}
}
+ pub(crate) fn set_enabled(
+ &self,
+ forward_id: u64,
+ enabled: bool,
+ ) -> anyhow::Result> {
+ let Some(req) =
+ self.workspace_op(crate::daemon::protocol::WorkspaceOp::SetForwardEnabled {
+ forward_id,
+ enabled,
+ })
+ else {
+ return crate::terminal::RemoteTerminal::set_forward_enabled(
+ self.pane_id,
+ forward_id,
+ enabled,
+ );
+ };
+ match crate::terminal::RemoteTerminal::on_workspace(req)? {
+ crate::daemon::protocol::DaemonMsg::ForwardList(list) => Ok(list),
+ other => Err(anyhow::anyhow!(
+ "unexpected reply to SetForwardEnabled: {other:?}"
+ )),
+ }
+ }
+
pub(crate) fn remove(
&self,
forward_id: u64,
@@ -12137,6 +12260,7 @@ mod managed_forward_gpui_tests {
target_port: 80,
description: None,
status: ForwardStatus::Listening,
+ enabled: true,
}
}
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index 2bf9e9ab..1731232b 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -47,6 +47,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
+ "icons/power.svg" => include_bytes!("../../assets/icons/power.svg"),
+ "icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
+ "icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
@@ -114,6 +117,8 @@ mod tests {
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
+ "icons/list-tree.svg",
+ "icons/list-flat.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs
index 3257016d..0b00cb10 100644
--- a/src/ui/forwards.rs
+++ b/src/ui/forwards.rs
@@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
+use crate::core::ssh_profile::ForwardRule;
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule};
use crate::terminal::view::TerminalView;
use crate::ui::app::{CONTENT_INSET, Tty7App};
@@ -70,6 +71,7 @@ impl ForwardFields {
target_host,
target_port,
description: (!description.is_empty()).then(|| description.to_string()),
+ enabled: true,
})
}
@@ -93,6 +95,7 @@ impl ForwardFields {
// left over from a trip through the advanced form is not something
// this rule was given.
description: None,
+ enabled: true,
})
}
@@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule {
target_host: forward.target_host.clone(),
target_port: forward.target_port,
description: forward.description.clone(),
+ enabled: forward.enabled,
}
}
+/// Which of a saved host's rules a live forward was opened from, among
+/// those not already switched to `enabled` — the one a switch in the panel
+/// has to be written back to.
+///
+/// Matched on the whole mapping, since a live forward carries no reference to
+/// the rule it came from. A saved rule always names its port, so the port the
+/// forward bound is the one the rule asked for. A forward added in the panel
+/// matches nothing and stays as temporary as it always was.
+pub(crate) fn saved_rule_index(
+ rules: &[ForwardRule],
+ forward: &ManagedForward,
+ enabled: bool,
+) -> Option {
+ rules.iter().position(|r| {
+ let saved = crate::ui::ssh_connect::map_forward(r);
+ saved.enabled != enabled
+ && saved.kind == forward.kind
+ && saved.bind_host == forward.bind_host
+ && saved.bind_port == forward.bind_port
+ && saved.target_host == forward.target_host
+ && saved.target_port == forward.target_port
+ })
+}
+
impl Tty7App {
pub(crate) fn render_ssh_status_strip(
&self,
@@ -254,18 +282,23 @@ impl Tty7App {
SshForwardKind::Remote => "R",
SshForwardKind::Dynamic => "D",
};
- let errored = matches!(forward.status, ForwardStatus::Error(_));
+ // A switched-off forward has an error status only because it binds
+ // nothing; it is drawn faded, not red.
+ let off = !forward.enabled;
+ let errored = !off && matches!(forward.status, ForwardStatus::Error(_));
let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") {
forward.bind_port.to_string()
} else {
format!("{}:{}", forward.bind_host, forward.bind_port)
};
+ let mapping = match forward.kind {
+ SshForwardKind::Dynamic => "SOCKS".to_string(),
+ _ => format!("→ {}:{}", forward.target_host, forward.target_port),
+ };
let tail = match &forward.status {
+ _ if off => mapping,
ForwardStatus::Error(msg) => msg.clone(),
- ForwardStatus::Listening => match forward.kind {
- SshForwardKind::Dynamic => "SOCKS".to_string(),
- _ => format!("→ {}:{}", forward.target_host, forward.target_port),
- },
+ ForwardStatus::Listening => mapping,
};
let pane_id = forward.pane_id;
let forward_id = forward.id;
@@ -295,6 +328,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.gap(px(1.))
+ .when(off, |col| col.opacity(0.5))
.child(
h_flex()
.items_center()
@@ -326,6 +360,33 @@ impl Tty7App {
)
}),
)
+ // Always shown, unlike Remove beside it: whether a forward is on is
+ // state to read at a glance, not an action to go looking for.
+ .child(
+ div()
+ .flex_shrink_0()
+ .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
+ .child(
+ crate::ui::theme::switch(
+ ("panel-forward-enabled", forward_id as usize),
+ cx,
+ )
+ .checked(forward.enabled)
+ .xsmall()
+ .tooltip(if off {
+ t(L10nKey::ForwardTooltipTurnOn)
+ } else {
+ t(L10nKey::ForwardTooltipTurnOff)
+ })
+ .on_click(cx.listener(
+ move |this, on: &bool, window, cx| {
+ this.set_managed_forward_enabled(
+ pane_id, forward_id, *on, window, cx,
+ )
+ },
+ )),
+ ),
+ )
.child(
div()
.flex_shrink_0()
@@ -586,6 +647,7 @@ mod tests {
target_port: 80,
description: Some("the staging box".to_string()),
status: ForwardStatus::Listening,
+ enabled: true,
}
}
@@ -718,4 +780,43 @@ mod tests {
"nothing was added, so there is nothing to point at"
);
}
+
+ #[test]
+ fn a_switched_off_forward_keeps_its_switch_through_an_edit() {
+ let mut off = managed(3, 8080);
+ off.enabled = false;
+ assert!(!rule_of(&off).enabled);
+ assert!(rule_of(&managed(3, 8080)).enabled);
+ }
+
+ /// The panel's switch is written back to the saved host only for the rule
+ /// the forward was opened from, and a rule added in the panel — which no
+ /// saved rule describes — is left as temporary as it always was.
+ #[test]
+ fn a_switch_is_written_back_to_the_saved_rule_it_came_from() {
+ use crate::core::ssh_profile::{ForwardKind, HostPort};
+ let saved = |target: &str, enabled: bool| ForwardRule {
+ kind: ForwardKind::Local,
+ bind: HostPort::new("127.0.0.1", 8080),
+ target: HostPort::new(target, 80),
+ description: "the staging box".to_string(),
+ enabled,
+ };
+ let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)];
+ let live = managed(3, 8080);
+ assert_eq!(saved_rule_index(&rules, &live, false), Some(1));
+ assert_eq!(
+ saved_rule_index(&rules, &live, true),
+ None,
+ "already on: nothing to write"
+ );
+
+ let mut elsewhere = managed(3, 8081);
+ elsewhere.description = None;
+ assert_eq!(saved_rule_index(&rules, &elsewhere, false), None);
+
+ let mut remote = managed(3, 8080);
+ remote.kind = SshForwardKind::Remote;
+ assert_eq!(saved_rule_index(&rules, &remote, false), None);
+ }
}
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 28b0b5c0..a26c80ee 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -620,6 +620,13 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable."
}
+ L10nKey::SettingsSshTabTitle => "SSH tab title",
+ L10nKey::SettingsSshTabTitleDesc => {
+ "What an SSH tab is called. Dynamic follows the title the remote side sets; the other two pin the tab to the host. A tab you renamed keeps its name."
+ }
+ L10nKey::SettingsSshTabTitleDynamic => "Dynamic",
+ L10nKey::SettingsSshTabTitleProfileName => "Profile name",
+ L10nKey::SettingsSshTabTitleHostname => "Hostname",
L10nKey::DocumentDock => "Dock beside terminal",
L10nKey::DocumentFill => "Fill window",
L10nKey::SettingsNotifications => "Notifications",
@@ -943,6 +950,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"tabs group repo repository git scratch header sidebar flat folder directory cwd"
}
+ L10nKey::SettingsSearchSshTabTitleKeywords => {
+ "ssh tab title name host hostname profile alias pin fixed osc remote"
+ }
L10nKey::SettingsSearchSmartSelectionKeywords => {
"double click word url path select semantic bracket email"
}
@@ -1022,6 +1032,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SshEditProfile => "Edit connection…",
L10nKey::ForwardTooltipAdd => "Add forward",
L10nKey::ForwardTooltipRemove => "Remove",
+ L10nKey::ForwardTooltipTurnOn => "Turn on",
+ L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept",
+ L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}",
+ L10nKey::SettingsFwdEnabled => "Open this rule with the connection",
L10nKey::ForwardLocal => "Local",
L10nKey::ForwardRemote => "Remote",
L10nKey::ForwardDynamic => "Dynamic",
@@ -1216,6 +1230,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
+ L10nKey::ScmFilterChanges => "Filter changed files…",
+ L10nKey::ScmViewAsTree => "View as Tree",
+ L10nKey::ScmViewAsList => "View as List",
+ L10nKey::ScmNoMatchingChanges => "No changed files match the filter.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 44484a65..7d3dc051 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -628,6 +628,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません"
}
+ L10nKey::SettingsSshTabTitle => "SSH タブのタイトル",
+ L10nKey::SettingsSshTabTitleDesc => {
+ "SSH タブに表示する名前。「動的」はリモート側が設定したタイトルに従い、ほかの二つはタブをホストに固定します。名前を変更したタブはその名前のまま"
+ }
+ L10nKey::SettingsSshTabTitleDynamic => "動的",
+ L10nKey::SettingsSshTabTitleProfileName => "プロファイル名",
+ L10nKey::SettingsSshTabTitleHostname => "ホスト名",
L10nKey::DocumentDock => "ターミナルの隣にドック",
L10nKey::DocumentFill => "ウィンドウ全体",
L10nKey::SettingsNotifications => "通知",
@@ -1005,6 +1012,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory"
}
+ L10nKey::SettingsSearchSshTabTitleKeywords => {
+ "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin"
+ }
L10nKey::SettingsSearchSmartSelectionKeywords => {
"ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email"
}
@@ -1088,6 +1098,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "接続を編集…",
L10nKey::ForwardTooltipAdd => "フォワードを追加",
L10nKey::ForwardTooltipRemove => "削除",
+ L10nKey::ForwardTooltipTurnOn => "オンにする",
+ L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります",
+ L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}",
+ L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く",
L10nKey::ForwardLocal => "ローカル",
L10nKey::ForwardRemote => "リモート",
L10nKey::ForwardDynamic => "ダイナミック",
@@ -1290,6 +1304,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
+ L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…",
+ L10nKey::ScmViewAsTree => "ツリーで表示",
+ L10nKey::ScmViewAsList => "リストで表示",
+ L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 0567ee8b..9517a836 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -498,6 +498,11 @@ l10n_keys! {
SettingsSidebarGroupingDesc,
SettingsDiffPreviewFromCounts,
SettingsDiffPreviewFromCountsDesc,
+ SettingsSshTabTitle,
+ SettingsSshTabTitleDesc,
+ SettingsSshTabTitleDynamic,
+ SettingsSshTabTitleProfileName,
+ SettingsSshTabTitleHostname,
DocumentDock,
DocumentFill,
SettingsNotifications,
@@ -685,6 +690,7 @@ l10n_keys! {
SettingsSearchScrollbackKeywords,
SettingsSearchShowTrayIconKeywords,
SettingsSearchSidebarGroupingKeywords,
+ SettingsSearchSshTabTitleKeywords,
SettingsSearchSmartSelectionKeywords,
SettingsSearchStartInKeywords,
SettingsSearchSyncWithSystemKeywords,
@@ -739,6 +745,10 @@ l10n_keys! {
SshEditProfile,
ForwardTooltipAdd,
ForwardTooltipRemove,
+ ForwardTooltipTurnOn,
+ ForwardTooltipTurnOff,
+ ForwardSwitchFailed,
+ SettingsFwdEnabled,
ForwardLocal,
ForwardRemote,
ForwardDynamic,
@@ -913,6 +923,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
+ ScmFilterChanges,
+ ScmViewAsTree,
+ ScmViewAsList,
+ ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index 3c169756..a501716e 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -549,6 +549,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsDiffPreviewFromCountsDesc => {
"点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。"
}
+ L10nKey::SettingsSshTabTitle => "SSH 标签页标题",
+ L10nKey::SettingsSshTabTitleDesc => {
+ "SSH 标签页显示的名字。“动态”跟随远端设置的标题;另外两项把标签页固定为主机。手动重命名过的标签页保留其名字。"
+ }
+ L10nKey::SettingsSshTabTitleDynamic => "动态",
+ L10nKey::SettingsSshTabTitleProfileName => "配置名称",
+ L10nKey::SettingsSshTabTitleHostname => "主机名",
L10nKey::DocumentDock => "停靠在终端旁",
L10nKey::DocumentFill => "铺满窗口",
L10nKey::SettingsNotifications => "通知",
@@ -896,6 +903,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchSidebarGroupingKeywords => {
"侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory"
}
+ L10nKey::SettingsSearchSshTabTitleKeywords => {
+ "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin"
+ }
L10nKey::SettingsSearchSmartSelectionKeywords => {
"智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click"
}
@@ -977,6 +987,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SshEditProfile => "编辑连接…",
L10nKey::ForwardTooltipAdd => "添加转发",
L10nKey::ForwardTooltipRemove => "移除",
+ L10nKey::ForwardTooltipTurnOn => "开启",
+ L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留",
+ L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}",
+ L10nKey::SettingsFwdEnabled => "连接时开启这条规则",
L10nKey::ForwardLocal => "本地",
L10nKey::ForwardRemote => "远程",
L10nKey::ForwardDynamic => "动态",
@@ -1156,6 +1170,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
+ L10nKey::ScmFilterChanges => "筛选变更文件…",
+ L10nKey::ScmViewAsTree => "以树形显示",
+ L10nKey::ScmViewAsList => "以列表显示",
+ L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => {
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 4ec0d0af..df167e08 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -27,6 +27,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
+pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs
new file mode 100644
index 00000000..80c6459e
--- /dev/null
+++ b/src/ui/prompt.rs
@@ -0,0 +1,180 @@
+//! The confirmation dialog on platforms with no native one.
+//!
+//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
+//! has neither, so gpui falls back to a renderer of its own — and that one sets
+//! the message and the detail as single unbreakable lines inside a fixed-width
+//! box that clips its overflow. Any sentence longer than the box is cut off
+//! mid-word, which for the quit-and-stop warning is exactly the part that says
+//! what will be lost (#920). This is the same dialog laid out as a card that
+//! wraps its text, in the app's own surfaces, with Return and Escape answering
+//! the way they do on the native dialogs.
+
+use gpui::{
+ App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
+ PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
+};
+use gpui_component::button::{Button, ButtonVariants as _};
+use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
+
+/// Route every `window.prompt` through [`TextPrompt`] where the platform has
+/// no dialog of its own to show.
+pub(crate) fn install(cx: &mut App) {
+ if cfg!(any(target_os = "macos", target_os = "windows")) {
+ return;
+ }
+ cx.set_prompt_builder(build);
+}
+
+fn build(
+ _level: PromptLevel,
+ message: &str,
+ detail: Option<&str>,
+ answers: &[PromptButton],
+ handle: PromptHandle,
+ window: &mut Window,
+ cx: &mut App,
+) -> RenderablePromptHandle {
+ let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
+ handle.with_view(prompt, window, cx)
+}
+
+pub(crate) struct TextPrompt {
+ message: String,
+ detail: Option,
+ answers: Vec,
+ focus: FocusHandle,
+}
+
+impl TextPrompt {
+ fn new(
+ message: &str,
+ detail: Option<&str>,
+ answers: &[PromptButton],
+ cx: &mut Context,
+ ) -> Self {
+ Self {
+ message: message.to_string(),
+ detail: detail.map(str::to_string),
+ answers: answers.to_vec(),
+ focus: cx.focus_handle(),
+ }
+ }
+
+ /// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
+ /// action there for exactly this — and Escape is the one marked cancel;
+ /// a prompt with no cancel answer has nothing Escape can safely mean.
+ fn answer_for_key(&self, key: &str) -> Option {
+ match key {
+ "enter" if !self.answers.is_empty() => Some(0),
+ "escape" => self.answers.iter().position(PromptButton::is_cancel),
+ _ => None,
+ }
+ }
+}
+
+impl EventEmitter for TextPrompt {}
+
+impl Focusable for TextPrompt {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.focus.clone()
+ }
+}
+
+impl Render for TextPrompt {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let muted = cx.theme().muted_foreground;
+ // Answer 0 goes rightmost, where the native dialogs put it and where
+ // `confirm_answers` expects it to land.
+ let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
+ Button::new(("prompt-answer", ix))
+ .label(answer.label().clone())
+ .small()
+ .when(ix == 0 && !answer.is_cancel(), |b| b.primary())
+ .on_click(cx.listener(move |_, _, _, cx| {
+ cx.emit(PromptResponse(ix));
+ cx.stop_propagation();
+ }))
+ });
+
+ let card = v_flex()
+ .occlude()
+ .w(px(420.))
+ .max_w(relative(0.9))
+ .gap_3()
+ .p_5()
+ .map(|card| crate::ui::theme::floating_surface(card, cx))
+ .child(
+ div()
+ .text_sm()
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .child(self.message.clone()),
+ )
+ .children(
+ self.detail
+ .clone()
+ .map(|detail| div().text_sm().text_color(muted).child(detail)),
+ )
+ .child(
+ h_flex()
+ .pt_2()
+ .justify_end()
+ .flex_wrap()
+ .gap_2()
+ .children(buttons),
+ );
+
+ div()
+ .id("text-prompt")
+ .track_focus(&self.focus)
+ .size_full()
+ .cursor_default()
+ .bg(crate::ui::presets::scrim_fill(cx))
+ .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
+ if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
+ cx.emit(PromptResponse(ix));
+ cx.stop_propagation();
+ }
+ }))
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(card)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::TextPrompt;
+ use gpui::{AppContext as _, PromptButton, TestAppContext};
+
+ fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity {
+ cx.update(|cx| {
+ cx.new(|cx| {
+ TextPrompt::new(
+ "Quit and stop tty7 server?",
+ Some("a detail long enough to need more than one line"),
+ answers,
+ cx,
+ )
+ })
+ })
+ }
+
+ #[gpui::test]
+ fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
+ let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
+ p.read_with(cx, |p, _| {
+ assert_eq!(p.answer_for_key("enter"), Some(0));
+ assert_eq!(p.answer_for_key("escape"), Some(1));
+ assert_eq!(p.answer_for_key("a"), None);
+ });
+ }
+
+ #[gpui::test]
+ fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
+ let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
+ p.read_with(cx, |p, _| {
+ assert_eq!(p.answer_for_key("escape"), None);
+ });
+ }
+}
diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs
index 994f83c0..aabd3795 100644
--- a/src/ui/right_panel.rs
+++ b/src/ui/right_panel.rs
@@ -112,8 +112,11 @@ pub(crate) const ROW_INSET: f32 = 6.;
/// Local forwards only, and only those aimed at the far host's own loopback:
/// a forward to some third machine happens to carry the same number, and
/// pairing it with the port row would claim it leads somewhere it does not.
+/// Nor does a switched-off forward: it leads nowhere until it is switched back
+/// on, and its own row is where that switch is.
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
- m.kind == crate::daemon::protocol::SshForwardKind::Local
+ m.enabled
+ && m.kind == crate::daemon::protocol::SshForwardKind::Local
&& m.target_port == port
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
}
@@ -729,6 +732,18 @@ impl Tty7App {
input: &gpui::Entity,
cx: &mut Context,
) -> AnyElement {
+ self.panel_search_with(input, None, cx)
+ }
+
+ /// [`Self::panel_search`] with a tile at its far end, inset the way the
+ /// branch row's tile is so the two stack in one column.
+ pub(crate) fn panel_search_with(
+ &self,
+ input: &gpui::Entity,
+ trailing: Option,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.items_center()
@@ -753,6 +768,8 @@ impl Tty7App {
// still reads as one line of chrome.
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
)
+ .when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
+ .children(trailing)
.into_any_element()
}
@@ -1339,8 +1356,26 @@ impl Tty7App {
}),
);
if let Some(f) = forward {
- tiles_wide += 1;
+ tiles_wide += 2;
let forward_id = f.id;
+ // Switched off, the forward stops speaking for this row and
+ // comes out as a row of its own with the switch to turn it
+ // back on.
+ actions = actions.child(
+ crate::ui::tab_strip::chrome_tile_sized(
+ Button::new(("panel-port-switch-off", i))
+ .icon(Icon::empty().path("icons/power.svg")),
+ TILE_SIZE_XS,
+ TILE_GLYPH_XS,
+ false,
+ cx,
+ )
+ .rounded(px(4.))
+ .tooltip(t(L10nKey::ForwardTooltipTurnOff))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx)
+ })),
+ );
actions = actions.child(
self.info_tile(
("panel-port-unforward", i),
@@ -1956,6 +1991,7 @@ mod tests {
target_port,
description: None,
status: ForwardStatus::Listening,
+ enabled: true,
}
}
@@ -1987,6 +2023,15 @@ mod tests {
);
}
+ /// A switched-off forward reaches nothing, so it must not take over the
+ /// port row — its own row is the only place its switch is.
+ #[test]
+ fn a_switched_off_forward_is_not_the_way_to_a_port() {
+ let mut off = forward(SshForwardKind::Local, "localhost", 3000);
+ off.enabled = false;
+ assert!(!forwards_port(&off, 3000));
+ }
+
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
InfoRow {
label: "changes",
diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs
index 3d5ea2e1..bdede8b6 100644
--- a/src/ui/scm/actions.rs
+++ b/src/ui/scm/actions.rs
@@ -71,6 +71,16 @@ impl Tty7App {
cx.notify();
}
+ /// Switch the changed-file list between flat and tree, and remember it.
+ ///
+ /// Global like `diff_view`: someone who reads changes by directory reads
+ /// every repository's that way.
+ pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context) {
+ let next = !cx.global::().scm_changes_tree;
+ self.update_config(cx, |cfg| cfg.scm_changes_tree = next);
+ cx.notify();
+ }
+
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:
diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs
index 50f4f4e9..39529ce4 100644
--- a/src/ui/scm/mod.rs
+++ b/src/ui/scm/mod.rs
@@ -14,6 +14,7 @@ pub(crate) mod path;
pub(crate) mod state;
#[allow(dead_code)]
pub(crate) mod status;
+pub(crate) mod tree;
pub(crate) use actions::ScmIntent;
pub(crate) use state::{GraphState, ScmPanelState};
diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs
index 63013637..1bd1c430 100644
--- a/src/ui/scm/panel.rs
+++ b/src/ui/scm/panel.rs
@@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent;
use crate::ui::scm::path::split_display_path;
use crate::ui::scm::state::{RepoKey, ScmGroup};
use crate::ui::scm::status::{status_color, status_glyph};
+use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows};
/// Shared compact pitch for working-tree and commit-detail file rows.
pub(super) const ROW_H: f32 = 26.;
@@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.;
/// different numbers.
const BADGE_W: f32 = 14.;
+/// One level of the tree view, the file tree's step: the Files panel and this
+/// one draw the same directories, and the same depth should read the same.
+const TREE_INDENT: f32 = 14.;
+
/// The key context the message box installs, and the one `ScmCommit` is
/// bound inside. The two are the same string on purpose: a binding whose
/// context nothing attaches is a binding that never fires.
@@ -236,7 +241,12 @@ impl Tty7App {
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
- None => self.scm_groups(&repo, &status, cx),
+ None => {
+ // Pinned with the rest, so the filter stays in reach however
+ // far down the list has been scrolled.
+ pinned.push(self.scm_filter_row(window, cx));
+ self.scm_groups(&repo, &status, cx)
+ }
};
let history = self.render_graph_section(&repo, window, cx);
self.scm_shell_full(title, pinned, body, history)
@@ -1291,24 +1301,63 @@ impl Tty7App {
status: &Arc,
cx: &mut Context,
) -> AnyElement {
+ let query = self.scm_filter_query(cx);
+ let tree = cx.global::().scm_changes_tree;
+ let mut matched = 0;
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.));
for group in ScmGroup::ORDER {
+ // Filtered before anything else reads it, so the header's count
+ // and its stage/discard-all act on what is on screen — a
+ // "discard all" that also reached the files the filter hid would
+ // be the one surprise this panel cannot take back.
let entries: Vec<&StatusEntry> = status
.entries
.iter()
.filter(|e| in_group(e, group))
+ .filter(|e| {
+ query
+ .as_deref()
+ .is_none_or(|q| path_matches(e.path.as_str(), q))
+ })
.collect();
if entries.is_empty() {
continue;
}
+ matched += entries.len();
let collapsed = self.scm.group_collapsed(group, entries.len());
list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx));
if collapsed {
continue;
}
let shown = entries.len().min(MAX_RENDERED_FILES);
- for entry in entries.iter().take(shown) {
- list = list.child(self.scm_file_row(repo, group, entry, cx));
+ if tree {
+ // A filter opens every directory: a match folded away under
+ // a closed row is a match the filter failed to show.
+ let filtering = query.is_some();
+ let folded = &self.scm.folded_dirs;
+ let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| {
+ !filtering && folded.contains(&(group, key.to_string()))
+ });
+ for row in rows {
+ list = list.child(match row {
+ TreeRow::Dir {
+ key,
+ label,
+ depth,
+ files,
+ collapsed,
+ } => self.scm_dir_row(
+ group, key, label, depth, files, collapsed, !filtering, cx,
+ ),
+ TreeRow::File { index, depth } => {
+ self.scm_file_row(repo, group, entries[index], Some(depth), cx)
+ }
+ });
+ }
+ } else {
+ for entry in entries.iter().take(shown) {
+ list = list.child(self.scm_file_row(repo, group, entry, None, cx));
+ }
}
if entries.len() > shown {
list = list.child(self.scm_note(
@@ -1317,6 +1366,9 @@ impl Tty7App {
));
}
}
+ if query.is_some() && matched == 0 {
+ list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx));
+ }
if status.truncated {
list = list.child(self.scm_note(
t_fmt(
@@ -1332,6 +1384,142 @@ impl Tty7App {
list.into_any_element()
}
+ /// The filter over the changed files, with the list/tree switch at its
+ /// end.
+ ///
+ /// A standing field, like the Files panel's, rather than one behind a
+ /// tile like the history's: the list is what this panel is for, and a
+ /// run that touched sixty files is exactly when nobody wants to go
+ /// looking for how to narrow it.
+ fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement {
+ let input = match self.scm.filter.clone() {
+ Some(input) => input,
+ None => {
+ let input = cx.new(|cx| {
+ InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges))
+ });
+ // An `InputState` is its own entity; without this the list
+ // would never hear about the text typed into it.
+ self.scm.filter_sub =
+ Some(
+ cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| {
+ if matches!(ev, gpui_component::input::InputEvent::Change) {
+ cx.notify();
+ }
+ }),
+ );
+ self.scm.filter = Some(input.clone());
+ input
+ }
+ };
+ let tree = cx.global::().scm_changes_tree;
+ // The tile shows where it goes, not where you are — the rows under it
+ // already say which view this is.
+ let (glyph, tip) = match tree {
+ true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)),
+ false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)),
+ };
+ let toggle = crate::ui::tab_strip::chrome_tile_sized(
+ Button::new("scm-changes-view").icon(Icon::empty().path(glyph)),
+ crate::ui::app::TILE_SIZE_SM,
+ crate::ui::app::TILE_GLYPH_SM,
+ false,
+ cx,
+ )
+ .rounded_md()
+ .tooltip(tip)
+ .on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx)))
+ .into_any_element();
+ self.panel_search_with(&input, Some(toggle), cx)
+ }
+
+ /// The filter box's text, if it has any.
+ fn scm_filter_query(&self, cx: &gpui::App) -> Option {
+ normalize_query(&self.scm.filter.as_ref()?.read(cx).value())
+ }
+
+ /// A directory in the tree view: a chevron in the status letters'
+ /// column, the (possibly compacted) name, and how many changed files
+ /// are under it.
+ #[allow(clippy::too_many_arguments)]
+ fn scm_dir_row(
+ &self,
+ group: ScmGroup,
+ key: String,
+ label: String,
+ depth: usize,
+ files: usize,
+ collapsed: bool,
+ foldable: bool,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let sf = cx.global::().sidebar;
+ let mono = cx.theme().mono_font_family.clone();
+ let muted = cx.theme().muted_foreground;
+ let id = SharedString::from(format!("scm-dir-{group:?}-{key}"));
+ let selector = id.clone();
+ let toggle = cx.listener({
+ let key = key.clone();
+ move |this, _, _window, cx| {
+ this.scm.toggle_dir(group, key.clone());
+ cx.notify();
+ }
+ });
+ h_flex()
+ .id(id)
+ .debug_selector(move || selector.to_string())
+ .items_center()
+ .gap(px(8.))
+ .min_h(rems(ROW_H / 16.))
+ .w_full()
+ .min_w_0()
+ .px(px(ROW_INSET))
+ .pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
+ .rounded(px(5.))
+ .when(foldable, |row| {
+ row.cursor_pointer()
+ .hover(|s| s.bg(gpui::rgb(sf.hover)))
+ .on_click(toggle)
+ })
+ .tooltip(move |window, cx| {
+ gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx)
+ })
+ .child(
+ div()
+ .flex_none()
+ .w(px(BADGE_W))
+ .flex()
+ .justify_center()
+ .text_color(muted)
+ .child(
+ Icon::new(if collapsed {
+ IconName::ChevronRight
+ } else {
+ IconName::ChevronDown
+ })
+ .xsmall(),
+ ),
+ )
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .truncate()
+ .text_size(rems(crate::ui::right_panel::TEXT))
+ .text_color(gpui::rgb(sf.text_resting))
+ .child(label),
+ )
+ .child(
+ div()
+ .flex_none()
+ .text_size(rems(META_MONO))
+ .font_family(mono)
+ .text_color(muted)
+ .child(files.to_string()),
+ )
+ .into_any_element()
+ }
+
/// "…and 40 more", and the note about a status git had to truncate.
///
/// Secondary: it is prose about the list rather than a row of it, and the
@@ -1413,11 +1601,15 @@ impl Tty7App {
.into_any_element()
}
+ /// One changed file. `depth` is its level in the tree view, and `None` in
+ /// the flat list — where the row carries its directory beside the name,
+ /// since there is no directory row above it to say it.
fn scm_file_row(
&self,
repo: &RepoKey,
group: ScmGroup,
entry: &StatusEntry,
+ depth: Option,
cx: &mut Context,
) -> AnyElement {
let sf = cx.global::().sidebar;
@@ -1438,8 +1630,10 @@ impl Tty7App {
cx,
);
+ let selector = id.clone();
h_flex()
.id(id.clone())
+ .debug_selector(move || selector.to_string())
.group(id)
.relative()
.items_center()
@@ -1448,6 +1642,9 @@ impl Tty7App {
.w_full()
.min_w_0()
.px(px(ROW_INSET))
+ .when_some(depth, |row, depth| {
+ row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT))
+ })
.py(px(3.))
.rounded(px(5.))
.cursor_pointer()
@@ -1503,7 +1700,7 @@ impl Tty7App {
// Cap the secondary directory while the filename fills the rest.
// A rem cap also gives short paths their intrinsic width inside
// the context-menu wrapper's flex layout.
- .when(!dir.is_empty(), |this| {
+ .when(!dir.is_empty() && depth.is_none(), |this| {
this.child(
div()
.flex_none()
@@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests {
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
+
+ /// The tree view and the filter, end to end over a real repository: the
+ /// rows that come out are the ones the pure helpers promise, drawn.
+ #[gpui::test]
+ fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) {
+ let _serial = serial();
+ crate::core::config::pin_test_config_dir();
+ let root = scratch("tree");
+ git(&root, &["init", "--quiet"]);
+ std::fs::create_dir_all(root.join("src/ui")).unwrap();
+ std::fs::create_dir_all(root.join("docs")).unwrap();
+ std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap();
+ std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap();
+ std::fs::write(root.join("docs/readme.md"), "r\n").unwrap();
+
+ // Keyed off the root the panel resolved, not off `root`: on Windows
+ // `scratch` hands back a `\\?\` verbatim path that never compares
+ // equal to git's spelling of the same directory — see the note on
+ // `a_settled_source_control_panel_reaches_render_idle`.
+ let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| {
+ app.scm.repo.as_ref().is_some_and(|r| {
+ crate::terminal::git_data::status_of(cx, r.host, &r.root)
+ .is_some_and(|s| s.untracked().count() == 3)
+ })
+ });
+ let drawn = |vcx: &mut VisualTestContext, what: &'static str| {
+ app.update_in(vcx, |_, _, cx| cx.notify());
+ vcx.run_until_parked();
+ vcx.debug_bounds(what).is_some()
+ };
+ let a = "scm-row-Untracked-src/ui/a.rs";
+ let readme = "scm-row-Untracked-docs/readme.md";
+ let src_ui = "scm-dir-Untracked-src/ui";
+
+ // The flat list is the default, and it has no directory rows.
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, src_ui));
+
+ // `src` holds nothing but `ui`, so the two share one row.
+ app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
+ assert!(vcx.update(|_, cx| cx.global::().scm_changes_tree));
+ assert!(drawn(&mut vcx, src_ui));
+ assert!(!drawn(&mut vcx, "scm-dir-Untracked-src"));
+ assert!(drawn(&mut vcx, "scm-dir-Untracked-docs"));
+ assert!(drawn(&mut vcx, a));
+
+ // Folding the directory keeps its row and hides what is under it.
+ app.update(&mut vcx, |app, cx| {
+ app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into());
+ cx.notify();
+ });
+ assert!(drawn(&mut vcx, src_ui));
+ assert!(!drawn(&mut vcx, a));
+ assert!(drawn(&mut vcx, readme));
+
+ // A filter drops what does not match — and opens the folded
+ // directory the match is in.
+ app.update_in(&mut vcx, |app, window, cx| {
+ let input = app.scm.filter.clone().expect("the filter row was drawn");
+ input.update(cx, |state, cx| state.set_value("UI A", window, cx));
+ });
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs"));
+ assert!(!drawn(&mut vcx, readme));
+ assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs"));
+
+ // The same filter narrows the flat list too.
+ app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx));
+ assert!(drawn(&mut vcx, a));
+ assert!(!drawn(&mut vcx, readme));
+ assert!(!drawn(&mut vcx, src_ui));
+
+ let _ = std::fs::remove_dir_all(&root);
+ }
}
/// The action strip is the one place in the panel where a hover reveal sits
diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs
index a201385f..ef44bef5 100644
--- a/src/ui/scm/state.rs
+++ b/src/ui/scm/state.rs
@@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState {
/// opening one by hand has to outlast the next file landing in it.
pub(crate) collapsed: HashSet,
pub(crate) toggled: HashSet,
+ /// Directories folded shut in the tree view, per group — the same
+ /// directory can be open under Changes and shut under Staged. Keyed by
+ /// the full repo-relative path of the (possibly compacted) row.
+ pub(crate) folded_dirs: HashSet<(ScmGroup, String)>,
+ /// The filter over the changed files. Created on first render like
+ /// `commit_input`, with the subscription that turns typing into a repaint.
+ pub(crate) filter: Option>,
+ pub(crate) filter_sub: Option,
/// Working directory → the repository root containing it, or `None` when
/// there is none, with when the answer was given. The root is what every
/// write runs from and what every cache is keyed by, so it is resolved
@@ -165,6 +173,14 @@ impl ScmPanelState {
}
}
+ /// Fold a tree directory shut, or open it again.
+ pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) {
+ let entry = (group, key);
+ if !self.folded_dirs.remove(&entry) {
+ self.folded_dirs.insert(entry);
+ }
+ }
+
pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) {
self.toggled.insert(group);
if collapsed {
diff --git a/src/ui/scm/tree.rs b/src/ui/scm/tree.rs
new file mode 100644
index 00000000..c0ed93b4
--- /dev/null
+++ b/src/ui/scm/tree.rs
@@ -0,0 +1,298 @@
+//! The changed-file list as a tree, and the filter both views share.
+//!
+//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in
+//! one group's paths and gets back rows that point into that same slice, so
+//! the file rows it draws are the ones the flat list would have drawn — same
+//! letter, same click, same context menu — only indented under a directory.
+//!
+//! Everything here comes out of the status the host already sent. A remote
+//! repository gets the same tree without a single extra read of its disk.
+
+use std::collections::BTreeMap;
+
+/// One line of the tree view.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub(crate) enum TreeRow {
+ /// A directory, possibly several compacted into one: `src/ui/scm` when
+ /// `src` and `ui` hold nothing but the next directory down. `key` is the
+ /// full repo-relative path, which is what the fold state is keyed by;
+ /// `label` is what the row reads, relative to its parent row.
+ Dir {
+ key: String,
+ label: String,
+ depth: usize,
+ /// Every file underneath, at any depth — what a folded row still
+ /// owes the reader.
+ files: usize,
+ collapsed: bool,
+ },
+ /// A file, by its position in the paths the tree was built from.
+ File { index: usize, depth: usize },
+}
+
+#[derive(Default)]
+struct Node<'a> {
+ dirs: BTreeMap<&'a str, Node<'a>>,
+ files: Vec<(&'a str, usize)>,
+}
+
+impl Node<'_> {
+ fn file_count(&self) -> usize {
+ self.files.len() + self.dirs.values().map(Node::file_count).sum::()
+ }
+}
+
+/// Group `paths` by directory, directories before files at every level and
+/// each run in byte order — the order git itself lists paths in.
+///
+/// A directory whose only content is one more directory is folded into it,
+/// the way VS Code's compact folders do: an agent's change three levels down
+/// `crates/tty7-core/src` should cost one row to reach, not three.
+///
+/// `collapsed` is asked once per directory row with its full path. A folded
+/// directory still gets its own row; only what is under it is left out.
+pub(crate) fn tree_rows<'a>(
+ paths: impl IntoIterator- ,
+ collapsed: impl Fn(&str) -> bool,
+) -> Vec
{
+ let mut root = Node::default();
+ for (index, path) in paths.into_iter().enumerate() {
+ // An untracked directory arrives as `dir/`. It is a leaf here, the
+ // same as it is a single row in the flat list.
+ let trimmed = path.strip_suffix('/').unwrap_or(path);
+ let (dir, name) = match trimmed.rsplit_once('/') {
+ Some((dir, name)) => (Some(dir), name),
+ None => (None, trimmed),
+ };
+ let mut node = &mut root;
+ for part in dir.into_iter().flat_map(|d| d.split('/')) {
+ node = node.dirs.entry(part).or_default();
+ }
+ node.files.push((name, index));
+ }
+ let mut rows = Vec::new();
+ emit(&root, "", 0, &collapsed, &mut rows);
+ rows
+}
+
+fn emit(
+ node: &Node<'_>,
+ prefix: &str,
+ depth: usize,
+ collapsed: &impl Fn(&str) -> bool,
+ rows: &mut Vec,
+) {
+ for (name, child) in &node.dirs {
+ let mut label = (*name).to_string();
+ let mut child = child;
+ while child.files.is_empty() && child.dirs.len() == 1 {
+ let (next, grandchild) = child.dirs.iter().next().expect("one entry");
+ label.push('/');
+ label.push_str(next);
+ child = grandchild;
+ }
+ let key = match prefix {
+ "" => label.clone(),
+ _ => format!("{prefix}/{label}"),
+ };
+ let folded = collapsed(&key);
+ rows.push(TreeRow::Dir {
+ key: key.clone(),
+ label,
+ depth,
+ files: child.file_count(),
+ collapsed: folded,
+ });
+ if !folded {
+ emit(child, &key, depth + 1, collapsed, rows);
+ }
+ }
+ let mut files = node.files.clone();
+ files.sort_by(|a, b| a.0.cmp(b.0));
+ rows.extend(
+ files
+ .into_iter()
+ .map(|(_, index)| TreeRow::File { index, depth }),
+ );
+}
+
+/// The filter box's text, normalised once: lowercased, trimmed, and `None`
+/// when there is nothing left to filter by.
+pub(crate) fn normalize_query(text: &str) -> Option {
+ let q = text.trim().to_lowercase();
+ (!q.is_empty()).then_some(q)
+}
+
+/// Whether a changed path survives the filter.
+///
+/// Every whitespace-separated word has to appear somewhere in the path, in
+/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel
+/// scm`. `query` is what [`normalize_query`] returned.
+pub(crate) fn path_matches(path: &str, query: &str) -> bool {
+ let hay = path.to_lowercase();
+ query.split_whitespace().all(|word| hay.contains(word))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn open(paths: &[&str]) -> Vec {
+ tree_rows(paths.iter().copied(), |_| false)
+ }
+
+ fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow {
+ TreeRow::Dir {
+ key: key.into(),
+ label: label.into(),
+ depth,
+ files,
+ collapsed: false,
+ }
+ }
+
+ fn file(index: usize, depth: usize) -> TreeRow {
+ TreeRow::File { index, depth }
+ }
+
+ #[test]
+ fn files_are_grouped_under_their_directories() {
+ let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]);
+ assert_eq!(
+ rows,
+ vec![
+ dir("docs", "docs", 0, 1),
+ file(3, 1),
+ dir("src", "src", 0, 2),
+ file(0, 1),
+ file(2, 1),
+ // Files after directories, at the root as everywhere else.
+ file(1, 0),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_directory_holding_only_a_directory_is_compacted() {
+ let rows = open(&[
+ "crates/core/src/git/status.rs",
+ "crates/core/src/git/diff.rs",
+ "crates/core/Cargo.toml",
+ ]);
+ assert_eq!(
+ rows,
+ vec![
+ // `crates` holds only `core`, so they share a row; `core`
+ // holds a file, so the chain stops there.
+ dir("crates/core", "crates/core", 0, 3),
+ dir("crates/core/src/git", "src/git", 1, 2),
+ file(1, 2),
+ file(0, 2),
+ file(2, 1),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_directory_with_two_children_is_not_compacted() {
+ let rows = open(&["a/b/x.rs", "a/c/y.rs"]);
+ assert_eq!(
+ rows,
+ vec![
+ dir("a", "a", 0, 2),
+ dir("a/b", "b", 1, 1),
+ file(0, 2),
+ dir("a/c", "c", 1, 1),
+ file(1, 2),
+ ]
+ );
+ }
+
+ #[test]
+ fn a_folded_directory_keeps_its_row_and_its_count() {
+ let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| {
+ key == "src/ui"
+ });
+ assert_eq!(
+ rows,
+ vec![
+ dir("src", "src", 0, 3),
+ TreeRow::Dir {
+ key: "src/ui".into(),
+ label: "ui".into(),
+ depth: 1,
+ files: 2,
+ collapsed: true,
+ },
+ file(2, 1),
+ ]
+ );
+ }
+
+ #[test]
+ fn folding_is_asked_about_the_compacted_path() {
+ let asked = std::cell::RefCell::new(Vec::new());
+ tree_rows(["a/b/c/x.rs"], |key| {
+ asked.borrow_mut().push(key.to_string());
+ false
+ });
+ assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]);
+ }
+
+ #[test]
+ fn an_untracked_directory_is_a_leaf() {
+ let rows = open(&["build/", "src/new/"]);
+ assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]);
+ }
+
+ #[test]
+ fn every_path_lands_in_exactly_one_file_row() {
+ let paths = [
+ "z.rs",
+ "a/b/c.rs",
+ "a/b/d.rs",
+ "a/e.rs",
+ "f/g/h/i.rs",
+ "f/j.rs",
+ "a/b/",
+ ];
+ let mut seen: Vec = open(&paths)
+ .into_iter()
+ .filter_map(|row| match row {
+ TreeRow::File { index, .. } => Some(index),
+ TreeRow::Dir { .. } => None,
+ })
+ .collect();
+ seen.sort_unstable();
+ assert_eq!(seen, (0..paths.len()).collect::>());
+ }
+
+ #[test]
+ fn an_empty_list_is_an_empty_tree() {
+ assert!(open(&[]).is_empty());
+ }
+
+ #[test]
+ fn the_filter_needs_every_word_in_any_order_and_any_case() {
+ let q = normalize_query(" Scm PANEL ").unwrap();
+ assert!(path_matches("src/ui/scm/panel.rs", &q));
+ assert!(path_matches("src/ui/Panel/SCM.rs", &q));
+ assert!(!path_matches("src/ui/scm/state.rs", &q));
+ let q = normalize_query("panel scm").unwrap();
+ assert!(path_matches("src/ui/scm/panel.rs", &q));
+ }
+
+ #[test]
+ fn a_blank_filter_is_no_filter() {
+ assert_eq!(normalize_query(""), None);
+ assert_eq!(normalize_query(" "), None);
+ assert_eq!(normalize_query(" a "), Some("a".into()));
+ }
+
+ #[test]
+ fn the_filter_reads_non_ascii_paths() {
+ let q = normalize_query("设计").unwrap();
+ assert!(path_matches("文档/设计/方案.md", &q));
+ assert!(!path_matches("文档/实现.md", &q));
+ }
+}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 6e66e41e..6e6ecdf5 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -24,7 +24,7 @@ use uuid::Uuid;
use crate::core::config::{
BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode,
- TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
+ SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop,
};
use crate::core::keychain::{
CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents,
@@ -839,6 +839,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsDiffPreviewFromCounts,
keywords: SettingsSearchDiffPreviewFromCountsKeywords,
},
+ SearchEntry {
+ section: WindowTabs,
+ title: SettingsSshTabTitle,
+ keywords: SettingsSearchSshTabTitleKeywords,
+ },
SearchEntry {
section: General,
title: SettingsNotifyOnCommandFinish,
@@ -898,6 +903,7 @@ impl SearchEntry {
L10nKey::SettingsTabBarPosition => "tab_bar_position",
L10nKey::SettingsSidebarGrouping => "sidebar_grouping",
L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview",
+ L10nKey::SettingsSshTabTitle => "ssh_tab_title",
L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish",
L10nKey::SettingsNotifyThreshold => "notify_threshold_secs",
L10nKey::SettingsTerminalBell => "bell",
@@ -1007,6 +1013,7 @@ impl SearchEntry {
L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc),
L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc),
L10nKey::SettingsDiffPreviewFromCounts => t(L10nKey::SettingsDiffPreviewFromCountsDesc),
+ L10nKey::SettingsSshTabTitle => t(L10nKey::SettingsSshTabTitleDesc),
L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc),
L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc),
L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc),
@@ -1050,6 +1057,7 @@ impl SearchEntry {
L10nKey::SettingsDiffPreviewFromCounts => {
cfg.sidebar_diff_preview != defaults.sidebar_diff_preview
}
+ L10nKey::SettingsSshTabTitle => cfg.ssh_tab_title != defaults.ssh_tab_title,
L10nKey::SettingsNotifyOnCommandFinish => {
cfg.notify_on_command_finish != defaults.notify_on_command_finish
}
@@ -1554,6 +1562,7 @@ pub(crate) struct ForwardRuleForm {
pub(crate) target_host: Entity,
pub(crate) target_port: Entity,
pub(crate) description: Entity,
+ pub(crate) enabled: bool,
}
impl ForwardRuleForm {
@@ -1576,6 +1585,7 @@ impl ForwardRuleForm {
bind,
target,
description: val(&self.description),
+ enabled: self.enabled,
})
}
@@ -2019,6 +2029,7 @@ fn seed_forward_row(
&rule.description,
t(L10nKey::ForwardDescriptionPlaceholder),
),
+ enabled: rule.enabled,
}
}
@@ -6227,7 +6238,7 @@ impl Tty7App {
let count = form
.forwards
.iter()
- .filter(|r| r.collect(cx).is_some())
+ .filter(|r| r.enabled && r.collect(cx).is_some())
.count();
let summary = match count {
0 => t(L10nKey::SettingsNoneSummary).to_string(),
@@ -6372,6 +6383,20 @@ impl Tty7App {
.flex_1()
.min_w(px(80.))
.child(Input::new(&row.description).xsmall());
+ let enabled = div().flex_shrink_0().child(
+ crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx)
+ .checked(row.enabled)
+ .xsmall()
+ .tooltip(t(L10nKey::SettingsFwdEnabled))
+ .on_click(cx.listener(move |this, on: &bool, _w, cx| {
+ if let Some(f) = this.ssh_form_mut()
+ && let Some(r) = f.forwards.get_mut(idx)
+ {
+ r.enabled = *on;
+ cx.notify();
+ }
+ })),
+ );
let remove = crate::ui::tab_strip::hit_target(
Button::new(("ssh-fwd-remove", idx))
.icon(Icon::new(IconName::Close))
@@ -6388,6 +6413,7 @@ impl Tty7App {
h_flex()
.gap_2()
.items_center()
+ .child(enabled)
.child(kind_switch)
.child(description)
.child(remove),
@@ -6396,9 +6422,15 @@ impl Tty7App {
true => mapping(v_flex().gap_1().items_start()),
false => mapping(h_flex().gap_2().items_center()),
}),
- false => mapping(h_flex().gap_2().items_center().child(kind_switch))
- .child(description)
- .child(remove),
+ false => mapping(
+ h_flex()
+ .gap_2()
+ .items_center()
+ .child(enabled)
+ .child(kind_switch),
+ )
+ .child(description)
+ .child(remove),
};
v_flex()
@@ -7659,6 +7691,11 @@ impl Tty7App {
TabBarPosition::Left => 1,
};
let sidebar_diff_preview = cfg.sidebar_diff_preview;
+ let ssh_tab_title_idx = match cfg.ssh_tab_title {
+ SshTabTitle::Dynamic => 0,
+ SshTabTitle::ProfileName => 1,
+ SshTabTitle::Hostname => 2,
+ };
let sidebar_grouping_idx = match cfg.sidebar_grouping {
crate::core::config::SidebarGrouping::Repo => 0,
crate::core::config::SidebarGrouping::RepoOrDirectory => 1,
@@ -7790,6 +7827,25 @@ impl Tty7App {
},
);
+ let ssh_tab_title_radio = self.segmented(
+ "wt-ssh-tab-title",
+ &[
+ t(L10nKey::SettingsSshTabTitleDynamic),
+ t(L10nKey::SettingsSshTabTitleProfileName),
+ t(L10nKey::SettingsSshTabTitleHostname),
+ ],
+ ssh_tab_title_idx,
+ cx,
+ |this, ix, _w, cx| {
+ let mode = match ix {
+ 0 => SshTabTitle::Dynamic,
+ 1 => SshTabTitle::ProfileName,
+ _ => SshTabTitle::Hostname,
+ };
+ this.set_ssh_tab_title(mode, cx);
+ },
+ );
+
v_flex()
.when(general, |v| {
v.child(self.section_header(t(L10nKey::SettingsWindow), cx))
@@ -7845,6 +7901,12 @@ impl Tty7App {
sidebar_diff_switch,
cx,
))
+ .child(self.settings_row(
+ t(L10nKey::SettingsSshTabTitle),
+ t(L10nKey::SettingsSshTabTitleDesc),
+ ssh_tab_title_radio,
+ cx,
+ ))
})
.when(general, |v| {
v.child(self.section_rule(cx))
diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs
index c780c7bd..e1bf4210 100644
--- a/src/ui/ssh_connect.rs
+++ b/src/ui/ssh_connect.rs
@@ -461,6 +461,38 @@ fn build_spec_inner(
}
}
+/// The name an SSH pane's tab is pinned to under `mode` (#726), or `None` for
+/// [`SshTabTitle::Dynamic`], which leaves the tab to the title the pane is
+/// showing.
+///
+/// Built on the same evidence as the name the pane wears before the remote
+/// side speaks — [`NativeSshSpec::display_name`], set above — so the pinned
+/// name and that one only ever differ where the saved host has been renamed
+/// since the pane dialled: the live name wins there, looked up by
+/// `profile_id`. An alias from `~/.ssh/config` and a quick connect are not
+/// saved hosts, and their `display_name` is the alias and the address typed.
+pub(crate) fn pinned_ssh_title(
+ mode: crate::core::config::SshTabTitle,
+ spec: &NativeSshSpec,
+ profiles: &[SshProfile],
+) -> Option {
+ use crate::core::config::SshTabTitle;
+
+ let non_blank = |s: &str| Some(s.trim().to_string()).filter(|s| !s.is_empty());
+ match mode {
+ SshTabTitle::Dynamic => None,
+ SshTabTitle::ProfileName => spec
+ .profile_id
+ .as_deref()
+ .and_then(|id| Uuid::parse_str(id).ok())
+ .and_then(|id| profiles.iter().find(|p| p.id == id))
+ .and_then(|p| non_blank(&p.name))
+ .or_else(|| spec.display_name.as_deref().and_then(non_blank))
+ .or_else(|| non_blank(&spec.host)),
+ SshTabTitle::Hostname => non_blank(&spec.host),
+ }
+}
+
/// What a tab's host row opens when it is taken.
///
/// The two halves are not the same form. A saved host is already a record, so
@@ -574,6 +606,7 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule {
bind: HostPort::new(rule.bind_host.clone(), rule.bind_port),
target: HostPort::new(rule.target_host.clone(), rule.target_port),
description: rule.description.clone().unwrap_or_default(),
+ enabled: rule.enabled,
}
}
@@ -617,7 +650,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy {
SshProxy::None
}
-fn map_forward(rule: &ForwardRule) -> SshForwardRule {
+pub(crate) fn map_forward(rule: &ForwardRule) -> SshForwardRule {
SshForwardRule {
kind: match rule.kind {
ForwardKind::Local => SshForwardKind::Local,
@@ -629,6 +662,7 @@ fn map_forward(rule: &ForwardRule) -> SshForwardRule {
target_host: rule.target.host.clone(),
target_port: rule.target.port,
description: (!rule.description.is_empty()).then(|| rule.description.clone()),
+ enabled: rule.enabled,
}
}
@@ -823,7 +857,16 @@ mod tests {
bind: HostPort::new("localhost", 8080),
target: HostPort::new("127.0.0.1", 80),
description: "web".into(),
+ enabled: true,
}];
+ // A switched-off rule survives the trip through a live spec switched
+ // off, or reopening a host from its connection would turn it back on.
+ p.forwards.push(ForwardRule {
+ bind: HostPort::new("localhost", 8080),
+ target: HostPort::new("10.0.0.7", 80),
+ enabled: false,
+ ..ForwardRule::default()
+ });
let spec = build_native_ssh_spec(&p, &[], &store, true);
let back = profile_from_live_spec(&spec);
@@ -1061,4 +1104,83 @@ mod tests {
SshProxy::Http { .. }
));
}
+
+ /// #726's three sources under each mode: the saved host's own name, the
+ /// alias for a `~/.ssh/config` host, and the address for a quick connect;
+ /// `Hostname` always the address dialled, and `Dynamic` nothing at all.
+ #[test]
+ fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() {
+ use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName};
+ let store = InMemoryCredentialStore::new();
+
+ let mut saved = profile("prod-web", "10.0.0.5", "deploy");
+ let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
+ let profiles = vec![saved.clone()];
+ assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None);
+ assert_eq!(
+ pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
+ Some("prod-web")
+ );
+ assert_eq!(
+ pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
+ Some("10.0.0.5")
+ );
+ // Renamed after it dialled: the tab follows the saved host.
+ saved.name = "prod-api".into();
+ assert_eq!(
+ pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(),
+ Some("prod-api")
+ );
+ // A saved host nobody named reads as its address.
+ saved.name = " ".into();
+ let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
+ assert_eq!(
+ pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(),
+ Some("deploy@10.0.0.5")
+ );
+
+ let root = tempfile::tempdir().unwrap();
+ let ssh = root.path().join(".ssh");
+ std::fs::create_dir_all(&ssh).unwrap();
+ std::fs::write(
+ ssh.join("config"),
+ "Host bastion\n HostName 192.168.1.9\n User ops\n",
+ )
+ .unwrap();
+ let alias = crate::core::ssh_config::resolve_alias_to_profile_from(
+ ssh.join("config"),
+ root.path(),
+ "bastion",
+ )
+ .unwrap();
+ let spec = native_spec_from_transient_profile(
+ &alias.profile,
+ alias.proxy_jump,
+ &store,
+ true,
+ &|_| None,
+ );
+ assert_eq!(
+ pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
+ Some("bastion")
+ );
+ assert_eq!(
+ pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
+ Some("192.168.1.9")
+ );
+
+ // What `quick_connect` builds for an address that is no alias.
+ let mut typed = SshProfile::new("10.0.0.7");
+ typed.host = "10.0.0.7".into();
+ typed.user = "root".into();
+ let spec = build_native_ssh_spec(&typed, &profiles, &store, true);
+ assert_eq!(
+ pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
+ Some("10.0.0.7")
+ );
+ assert_eq!(
+ pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
+ Some("10.0.0.7")
+ );
+ }
}
diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs
index baffbb31..4f0acd3c 100644
--- a/src/ui/tab_strip.rs
+++ b/src/ui/tab_strip.rs
@@ -2488,6 +2488,84 @@ mod ssh_host_row_tests {
assert_eq!(spec.port, 2222, "a non-default port is part of the address");
});
}
+
+ /// #726 end to end: the setting reaches the name the strip draws, on the
+ /// title's rung — below a name the user gave the tab, above whatever the
+ /// remote shell titled itself — and only for an SSH pane.
+ #[gpui::test]
+ fn the_ssh_tab_title_setting_pins_only_ssh_tabs_and_only_their_label(cx: &mut TestAppContext) {
+ use crate::core::config::SshTabTitle;
+
+ set_locale("en");
+ let (app, mut vcx) = harness(cx);
+ let saved = uuid::Uuid::new_v4();
+ let set_mode = |cx: &mut gpui::App, mode: SshTabTitle| {
+ let mut cfg = cx.global::().clone();
+ cfg.ssh_tab_title = mode;
+ cx.set_global(cfg);
+ };
+
+ let (_ends, ssh) = app.update_in(&mut vcx, |app, window, cx| {
+ let mut cfg = cx.global::().clone();
+ let mut profile = SshProfile::new("prod-web");
+ profile.id = saved;
+ profile.user = "me".to_string();
+ profile.host = "build-box".to_string();
+ cfg.ssh_profiles = vec![profile];
+ cx.set_global(cfg);
+
+ let (local, a) = quiet_test_pane(1, window, cx);
+ let (ssh, b) = quiet_test_ssh_pane_of(2, Some(saved), window, cx);
+ // Both have titled themselves over OSC 0/2 the way a shell does.
+ local.update(cx, |v, _| v.title = "me@laptop:/work/here".into());
+ ssh.update(cx, |v, _| v.title = "me@build-box:/srv/app".into());
+ for view in [local, ssh.clone()] {
+ app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(view))));
+ }
+ app.active = 0;
+ ((a, b), ssh)
+ });
+ vcx.background_executor.run_until_parked();
+
+ app.update_in(&mut vcx, |app, window, cx| {
+ let label =
+ |app: &crate::ui::app::Tty7App, i: usize, window: &gpui::Window, cx: &gpui::App| {
+ app.tab_label(&app.tabs[i], i, Some(window), cx)
+ };
+ let dynamic_local = label(app, 0, window, cx);
+ let dynamic_ssh = label(app, 1, window, cx);
+ assert_eq!(dynamic_ssh, "/srv/app", "Dynamic is what tty7 always did");
+
+ set_mode(cx, SshTabTitle::ProfileName);
+ assert_eq!(label(app, 1, window, cx), "prod-web");
+ assert_eq!(
+ label(app, 0, window, cx),
+ dynamic_local,
+ "a local tab is untouched"
+ );
+ assert_eq!(
+ ssh.read(cx).title,
+ "me@build-box:/srv/app",
+ "the remote title is still tracked underneath"
+ );
+
+ set_mode(cx, SshTabTitle::Hostname);
+ assert_eq!(label(app, 1, window, cx), "build-box");
+
+ // A name the user gave the tab outranks the setting.
+ app.tabs[1].name = Some("mine".into());
+ assert_eq!(label(app, 1, window, cx), "mine");
+ app.tabs[1].name = None;
+
+ // An ended session keeps saying so under the pinned name.
+ ssh.update(cx, |v, _| v.terminal.exited = true);
+ assert_eq!(label(app, 1, window, cx), "build-box — process exited");
+ ssh.update(cx, |v, _| v.terminal.exited = false);
+
+ set_mode(cx, SshTabTitle::Dynamic);
+ assert_eq!(label(app, 1, window, cx), dynamic_ssh);
+ });
+ }
}
#[cfg(test)]