Merge remote-tracking branch 'origin/main' into feat/forward-toggle

# Conflicts:
#	CHANGELOG.md
#	src/ui/assets.rs
This commit is contained in:
l0ng-ai
2026-09-25 10:36:42 +02:00
46 changed files with 3871 additions and 127 deletions
+77
View File
@@ -21,6 +21,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+39
View File
@@ -61,6 +61,17 @@ pub trait Backend {
fn run_wait(&mut self) -> Result<Option<i32>>;
/// 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<u8>,
timeout: Option<std::time::Duration>,
) -> Result<crate::exec::ExecRun>;
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<ControlEvent>,
/// Set to make `hello` fail — doctor's unreachable-server branch.
pub unreachable: bool,
pub execs: Vec<(u64, Vec<u8>, Option<std::time::Duration>)>,
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<u8>,
timeout: Option<std::time::Duration>,
) -> Result<ExecRun> {
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)?;
+111
View File
@@ -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<u8>, timeout: Option<Duration>) -> Result<ExecRun> {
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<ExecRun> {
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() {
+144 -1
View File
@@ -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<String>,
}
#[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<String>,
#[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<u64>,
// 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<String>,
}
#[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<crate::keys::Key>,
// 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<std::path::PathBuf>,
#[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");
}
}
}
+589 -7
View File
@@ -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<Out
pane_split(args, ctx, backend)
}
Some(Command::Send(args)) => 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<Outcome> {
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<Outc
pressed.push(crate::keys::parse("enter").expect("enter is in the vocabulary"));
}
// Text from stdin or a file has no positional of its own, so the one
// positional there is can only be an address — and a word that is not one
// is a mistake, not text to type into the caller's own pane.
if args.stdin || args.from_file.is_some() {
let source = if args.stdin { "--stdin" } else { "--from-file" };
if let Some(first) = &args.first {
address::parse_pane(first).with_context(|| {
format!(
"with {source} the text comes from there, so '{first}' has to be the \
pane to send it to"
)
})?;
}
let pane = address::pane_or_context(args.first.as_deref(), ctx)?;
let payload = match &args.from_file {
Some(path) => {
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<Outc
};
let pane = address::pane_or_context(target, ctx)?;
let payload = match text {
Some(text) => 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<u8>,
},
}
fn deliver(
pane: u64,
payload: Payload,
args: &SendArgs,
pressed: Vec<crate::keys::Key>,
backend: &mut dyn Backend,
) -> Result<Outcome> {
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<Outc
"",
json!({
"pane": pane,
"sent": text.unwrap_or_default(),
"sent": match &payload {
Payload::Typed(text) => 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::<Vec<_>>(),
}),
@@ -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<Outcome> {
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<Outcome> {
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
let segments = backend.capture(pane, args.scrollback)?;
@@ -3446,6 +3686,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen: false,
bracketed_paste: None,
}
}
@@ -3472,6 +3713,7 @@ mod tests {
local_pty: true,
at_prompt,
remote_prompt_seen,
bracketed_paste: None,
}),
}
}
@@ -3493,6 +3735,7 @@ mod tests {
local_pty: false,
at_prompt,
remote_prompt_seen: at_prompt == Some(true),
bracketed_paste: None,
}),
}
}
@@ -4472,4 +4715,343 @@ mod tests {
backend.control_calls
);
}
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<bool>) -> 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());
}
}
+305
View File
@@ -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;<code>` 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<i32>, 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<i32>),
}
/// 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<u8>,
/// 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<u8>,
/// 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<usize>,
/// Where the newest `D` after `started` begins: where the output ends.
ended: Option<usize>,
/// Where an `A` that arrived with no `C` before it begins — the shell drew
/// a fresh prompt without running the line.
reprompted: Option<usize>,
exit_mark: Option<i32>,
}
/// What the daemon's `Prompt` report made of the pane.
pub struct Finished {
pub exit: Option<i32>,
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<i32>) -> Option<Finished> {
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<u8>, 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);
}
}
+6
View File
@@ -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(", ")
+1
View File
@@ -2,6 +2,7 @@ mod address;
mod backend;
mod cli;
mod commands;
mod exec;
mod gui;
mod keys;
mod output;
+223
View File
@@ -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:?}");
}
+480 -16
View File
@@ -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<String, String>,
#[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<crate::core::ssh_profile::SshProfile>,
#[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<uuid::Uuid, ProfileUsage>,
#[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<crate::core::ssh_profile::SshProfile>,
ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
}
#[derive(Serialize)]
struct ServersFileRef<'a> {
ssh_profiles: &'a [crate::core::ssh_profile::SshProfile],
ssh_profile_frecency: &'a HashMap<uuid::Uuid, ProfileUsage>,
}
/// 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::de::IgnoredAny>,
#[serde(default)]
ssh_profile_frecency: Option<serde::de::IgnoredAny>,
}
fn has_legacy_server_keys(text: &str) -> bool {
serde_json::from_str::<LegacyServerKeys>(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<String>) {
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::<Config>(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::<ServersFile>(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::<ServersFile>(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<PathBuf> {
@@ -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);
}
}
}
+1
View File
@@ -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;
+99
View File
@@ -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<u8> {
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<u8> {
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~"
);
}
}
+20
View File
@@ -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<Vec<u8>> {
@@ -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));
}
}
+45 -1
View File
@@ -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};
+74 -17
View File
@@ -225,23 +225,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
#[cfg(target_os = "macos")]
fn process_table() -> HashMap<u32, Row> {
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::<libc::c_int>()) + 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::<libc::c_int>()) as libc::c_int,
)
};
if written <= 0 {
return table;
}
let n = written as usize / std::mem::size_of::<libc::c_int>();
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<u32, Row> {
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<i32> {
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<u8> = 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<i32> = (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<u32, Row> = [(100, row(1, "zsh"))].into_iter().collect();
+9
View File
@@ -569,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<bool>,
}
fn default_term() -> String {
+6 -1
View File
@@ -31,7 +31,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
Tab position and placement, sidebar grouping, and diff previews.
Tab position and placement, sidebar grouping, diff previews, and what SSH
tabs are called.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
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.
+22 -4
View File
@@ -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.
<Note>
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
+2 -1
View File
@@ -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 | `<config>/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 | `<config>/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. |
+4 -2
View File
@@ -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
+6
View File
@@ -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
+41 -6
View File
@@ -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.
+70 -10
View File
@@ -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
+2 -3
View File
@@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String>
}
pub fn submit_bytes(prompt: &str) -> Vec<u8> {
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
}
+8 -4
View File
@@ -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();
+362 -28
View File
@@ -884,8 +884,8 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
thread_local! {
static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = 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<Pixels>>)> =
/// Measured ink bounds and the font size they were measured at.
static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option<Bounds<Pixels>>>)> =
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<Pixels>,
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<Pixels>,
scale: f32,
room: gpui::Size<Pixels>,
centre_x: bool,
ascent: Pixels,
descent: Pixels,
) -> Point<Pixels> {
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<Pixels> {
) -> Option<Bounds<Pixels>> {
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<gpui::FontId>; 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<Pixels> {
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<Pixels>, 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<RenderCell> {
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));
}
}
+31 -20
View File
@@ -850,15 +850,7 @@ fn ring_system_bell() -> bool {
}
fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
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<String> {
let pinned = self.ssh_spec.as_deref().and_then(|spec| {
let cfg = cx.try_global::<Config>()?;
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);
}
+13 -1
View File
@@ -612,7 +612,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),
@@ -3547,6 +3550,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>,
) {
self.update_config(cx, |cfg| cfg.ssh_tab_title = mode);
}
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<Config>().tab_bar_position {
TabBarPosition::Top => TabBarPosition::Left,
@@ -5905,6 +5916,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)
}
+4
View File
@@ -48,6 +48,8 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"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"),
@@ -115,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(),
+14
View File
@@ -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"
}
@@ -1220,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 => {
+14
View File
@@ -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"
}
@@ -1294,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} 件のみ表示しています。"
}
+10
View File
@@ -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,
@@ -917,6 +923,10 @@ l10n_keys! {
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmFilterChanges,
ScmViewAsTree,
ScmViewAsList,
ScmNoMatchingChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
+14
View File
@@ -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"
}
@@ -1160,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 => {
+1
View File
@@ -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;
+180
View File
@@ -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<String>,
answers: Vec<PromptButton>,
focus: FocusHandle,
}
impl TextPrompt {
fn new(
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
cx: &mut Context<Self>,
) -> 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<usize> {
match key {
"enter" if !self.answers.is_empty() => Some(0),
"escape" => self.answers.iter().position(PromptButton::is_cancel),
_ => None,
}
}
}
impl EventEmitter<PromptResponse> 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<Self>) -> 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<TextPrompt> {
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);
});
}
}
+14
View File
@@ -732,6 +732,18 @@ impl Tty7App {
input: &gpui::Entity<gpui_component::input::InputState>,
cx: &mut Context<Self>,
) -> 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<gpui_component::input::InputState>,
trailing: Option<AnyElement>,
cx: &mut Context<Self>,
) -> AnyElement {
let has_trailing = trailing.is_some();
h_flex()
.flex_none()
.items_center()
@@ -756,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()
}
+10
View File
@@ -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<Self>) {
let next = !cx.global::<crate::core::config::Config>().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`:
+1
View File
@@ -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};
+275 -4
View File
@@ -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<WorkingTreeStatus>,
cx: &mut Context<Self>,
) -> AnyElement {
let query = self.scm_filter_query(cx);
let tree = cx.global::<crate::core::config::Config>().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<Self>) -> 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::<crate::core::config::Config>().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<String> {
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<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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<usize>,
cx: &mut Context<Self>,
) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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::<crate::core::config::Config>().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
+16
View File
@@ -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<ScmGroup>,
pub(crate) toggled: HashSet<ScmGroup>,
/// 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<Entity<InputState>>,
pub(crate) filter_sub: Option<gpui::Subscription>,
/// 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 {
+298
View File
@@ -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::<usize>()
}
}
/// 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<Item = &'a str>,
collapsed: impl Fn(&str) -> bool,
) -> Vec<TreeRow> {
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<TreeRow>,
) {
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<String> {
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<TreeRow> {
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<usize> = 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::<Vec<_>>());
}
#[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));
}
}
+39 -1
View File
@@ -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
}
@@ -7683,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,
@@ -7814,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))
@@ -7869,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))
+111
View File
@@ -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<String> {
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
@@ -1072,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")
);
}
}
+78
View File
@@ -2434,6 +2434,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::<Config>().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::<Config>().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)]