mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
Merge remote-tracking branch 'origin/main' into feat/pinned-sidebar-groups
# Conflicts: # CHANGELOG.md # crates/tty7-core/src/daemon/control.rs # docs/customization/settings.mdx # docs/reference/configuration.mdx # src/ui/app.rs # src/ui/home.rs # src/ui/settings.rs
This commit is contained in:
+133
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7
|
||||
recognises whose binary is on `PATH` is a palette command — "Agent: Claude
|
||||
Code", "Agent: Codex", … — ordered by how often and how recently it was
|
||||
launched or seen running, and each is bindable as `LaunchAgent:<slug>`. "New
|
||||
Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is
|
||||
select-all) starts the one used last, and the New Tab menu's "Launch Agent…"
|
||||
row opens the palette on the list. A launch always opens a new tab in the
|
||||
current tab's directory and types the agent's command into its shell once the
|
||||
pane exists, never into a pane that was already there, so detection, status
|
||||
and resume work as for a hand-typed agent and quitting it returns to the
|
||||
shell. The command is the agent's bare binary unless `agent_launch` in
|
||||
`config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`);
|
||||
a wrapper named there is detected as that agent without an `agent_commands`
|
||||
entry, including a script run under its interpreter, and the daemon picks up
|
||||
an edit without a restart. A running agent's pane menu has "Set Current Launch
|
||||
Args as Default", which writes the flags it was started with — minus the
|
||||
session it resumed and any prompt — into `agent_launch`. A remote workspace
|
||||
cannot be asked for its `PATH`, so there the list is the agents already seen
|
||||
running in that workspace.
|
||||
|
||||
- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a
|
||||
tab → Hibernate, or "Hibernate Tab" in the command palette, stops every
|
||||
process in the tab to give its memory back while the tab keeps its place in
|
||||
the sidebar, faded and marked with a moon. Selecting it wakes it — or
|
||||
right-click → Wake to warm it up without switching. Waking is the restore a
|
||||
reboot already runs: each pane comes back in its old directory and shell,
|
||||
opens on the screen it left, and a supported agent with a captured session
|
||||
is resumed. A sleeping tab stays asleep across app and daemon restarts; the
|
||||
tab on screen is always awake, so the last awake tab cannot be put to sleep.
|
||||
`tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane
|
||||
ls --json` carry a `hibernated` flag. Offered only where the machine's server
|
||||
can do it (this build or newer).
|
||||
|
||||
- **A port forward can be switched off without losing its rule** (#439). A
|
||||
forward could only be removed, so pointing one local port at a different
|
||||
remote target meant deleting the rule and typing the other one in again. Each
|
||||
forward in the Ports section, and each rule under Settings → SSH → Port
|
||||
forwarding, now has a switch: off releases the listener and keeps the rule in
|
||||
the list, on binds it again from the rule it was made from. Switching one on
|
||||
while another switched-on forward holds its port is refused with a notice
|
||||
naming that forward, rather than failing at bind time. A switch flipped in the
|
||||
panel on a rule that came from a saved host is written back to that host, so
|
||||
the next connection opens the same set; a rule saved switched off is listed
|
||||
but not opened. Profiles saved before this load with every rule on.
|
||||
|
||||
- **SSH tabs can be named after the host instead of whatever the remote shell
|
||||
titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is
|
||||
*Dynamic* (the default, and what tty7 always did), *Profile name* — the saved
|
||||
host's name, the alias for a `~/.ssh/config` host, the address typed for a
|
||||
quick connect — or *Hostname*, the address dialled. Only the tab's name is
|
||||
pinned: OSC 0/2 titles are still tracked and come back the moment it is
|
||||
*Dynamic* again, a tab you renamed keeps its name, a split tab follows the
|
||||
pane in front as before, and an ended session still says so. The key is
|
||||
`ssh_tab_title` (`dynamic`, `profile-name`, `hostname`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **The sidebar groups tabs by repo automatically; pin what you want to keep**
|
||||
@@ -29,6 +86,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
grouping* choice; off, unpinned tabs sit in one flat list under the pinned
|
||||
groups. Scratch is now **Ungrouped**.
|
||||
|
||||
- **The New Tab menu names three shells, not every one the machine has.** A
|
||||
stock macOS box reports nine, so the `+` menu opened on a column of `csh`,
|
||||
`tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for.
|
||||
The Local section now lists the default shell, always first, and then only
|
||||
shells that have actually been opened, by frecency — three rows at most, the
|
||||
way the SSH section already caps its hosts. The rest sit behind an "Other
|
||||
Shells…" row that opens the command palette filtered to them: every shell is
|
||||
now a palette command, "Shell: {name}", and running one does what its menu
|
||||
row does, splitting instead when ⌥ (Alt) is held. The row is left out when
|
||||
the menu already names the whole list.
|
||||
|
||||
- **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.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The *By repo or folder* grouping mode, "Group Automatically", and groups
|
||||
@@ -38,6 +154,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
moves to v11, so each remote host needs one Update Server, which ends the
|
||||
sessions on it.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -102,6 +102,8 @@ that tells tty7 which session to fork.
|
||||
None of them are wrapped or proxied — the agent you start is the agent you get,
|
||||
in a normal PTY, with its own interface. An agent launched through a wrapper
|
||||
script can be mapped to one by name with `agent_commands` in `config.json`.
|
||||
Every agent on your `PATH` is also a palette command (**Agent: Claude Code**, …)
|
||||
that opens it in a new tab; `agent_launch` sets the command line it starts with.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
|
||||
|
||||
tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。
|
||||
如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。
|
||||
PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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="M12 3v8.6"/><path d="M17.9 6.6a8.4 8.4 0 1 1-11.8 0"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
@@ -707,6 +947,7 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R
|
||||
"folder": g.folder,
|
||||
})),
|
||||
"panes": tab.root.pane_ids(),
|
||||
"hibernated": tab.hibernated,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -920,6 +1161,7 @@ fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Resu
|
||||
"tab": tab.id.to_string(),
|
||||
"cwd": record.and_then(|r| r.cwd.clone()),
|
||||
"live": record.map(|r| r.live),
|
||||
"hibernated": tab.hibernated,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3511,6 +3753,7 @@ mod tests {
|
||||
local_pty: true,
|
||||
at_prompt,
|
||||
remote_prompt_seen: false,
|
||||
bracketed_paste: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3537,6 +3780,7 @@ mod tests {
|
||||
local_pty: true,
|
||||
at_prompt,
|
||||
remote_prompt_seen,
|
||||
bracketed_paste: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -3558,6 +3802,7 @@ mod tests {
|
||||
local_pty: false,
|
||||
at_prompt,
|
||||
remote_prompt_seen: at_prompt == Some(true),
|
||||
bracketed_paste: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -4537,4 +4782,375 @@ mod tests {
|
||||
backend.control_calls
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() {
|
||||
let mut backend = mock();
|
||||
backend.machine.workspaces[0].tabs[1].hibernated = true;
|
||||
|
||||
let Outcome::Report(tabs) = run_cli(
|
||||
&["tty7", "tab", "ls", "api"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
) else {
|
||||
panic!("tab ls must report");
|
||||
};
|
||||
let tabs = tabs.json["tabs"].as_array().expect("tabs").clone();
|
||||
assert_eq!(tabs[0]["hibernated"], false);
|
||||
assert_eq!(tabs[1]["hibernated"], true);
|
||||
|
||||
let Outcome::Report(panes) = run_cli(
|
||||
&["tty7", "pane", "ls", "api"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
) else {
|
||||
panic!("pane ls must report");
|
||||
};
|
||||
let panes = panes.json["panes"].as_array().expect("panes").clone();
|
||||
let asleep: Vec<u64> = panes
|
||||
.iter()
|
||||
.filter(|p| p["hibernated"] == true)
|
||||
.map(|p| p["pane"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(asleep, vec![2, 3]);
|
||||
}
|
||||
|
||||
fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun {
|
||||
crate::exec::ExecRun {
|
||||
end,
|
||||
output: output.to_vec(),
|
||||
dropped: 0,
|
||||
size: segment(b"").size,
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) {
|
||||
match execute(cli(args), ctx, backend) {
|
||||
Ok(Outcome::Exit(code, report)) => (code, report),
|
||||
other => panic!("exec stands in for the command, so it exits: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_types_one_line_and_passes_the_exit_code_through() {
|
||||
let mut backend = mock();
|
||||
backend.exec_run = exec_run(
|
||||
ExecEnd::Finished {
|
||||
exit: Some(3),
|
||||
ran: true,
|
||||
},
|
||||
b"built\r\n",
|
||||
);
|
||||
let (code, report) = exec_outcome(
|
||||
&[
|
||||
"tty7",
|
||||
"exec",
|
||||
"%1",
|
||||
"--raw",
|
||||
"--timeout",
|
||||
"30",
|
||||
"--",
|
||||
"make",
|
||||
"&&",
|
||||
"ls",
|
||||
],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
);
|
||||
assert_eq!(code, 3, "the command's own code, not ours");
|
||||
assert_eq!(
|
||||
backend.execs,
|
||||
vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))],
|
||||
"the words are one shell line, typed and submitted"
|
||||
);
|
||||
assert_eq!(report.human, "built\r\n");
|
||||
assert_eq!(report.json["exit"], 3);
|
||||
assert_eq!(report.json["exit_code_known"], true);
|
||||
assert_eq!(report.json["command"], "make && ls");
|
||||
assert_eq!(report.json["output"], "built\r\n");
|
||||
assert_eq!(report.json["timed_out"], false);
|
||||
// Nothing was sent anywhere but through the exec seam.
|
||||
assert!(backend.sent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_renders_the_output_the_way_capture_plain_does() {
|
||||
let mut backend = mock();
|
||||
backend.exec_run = exec_run(
|
||||
ExecEnd::Finished {
|
||||
exit: Some(0),
|
||||
ran: true,
|
||||
},
|
||||
b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n",
|
||||
);
|
||||
let (code, report) = exec_outcome(
|
||||
&["tty7", "exec", "%1", "--", "x"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
);
|
||||
assert_eq!(code, 0);
|
||||
assert!(!report.human.contains('\u{1b}'), "{:?}", report.human);
|
||||
assert!(report.human.contains("red"), "{:?}", report.human);
|
||||
assert!(report.human.contains("progress 100%"), "{:?}", report.human);
|
||||
assert!(
|
||||
!report.human.contains("progress 10%\n"),
|
||||
"{:?}",
|
||||
report.human
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() {
|
||||
// The backend sends nothing on these; the verb has to turn them into
|
||||
// an error that says what to do instead of an exit code that says
|
||||
// nothing.
|
||||
let mut backend = mock();
|
||||
backend.exec_run = exec_run(ExecEnd::NoIntegration, b"");
|
||||
let err = execute(
|
||||
cli(&["tty7", "exec", "%1", "--", "ls"]),
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("shell-integration"), "{err}");
|
||||
assert!(err.contains("wait --until free"), "{err}");
|
||||
|
||||
let mut backend = mock();
|
||||
backend.exec_run = exec_run(ExecEnd::Busy, b"");
|
||||
let err = execute(
|
||||
cli(&["tty7", "exec", "%1", "--", "ls"]),
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("not at a shell prompt"), "{err}");
|
||||
|
||||
// The caller's own pane can only ever be busy — running this.
|
||||
let ctx = Context {
|
||||
pane: Some("%1".into()),
|
||||
..Context::default()
|
||||
};
|
||||
let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("this shell"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_refuses_a_line_the_editor_would_act_on() {
|
||||
for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] {
|
||||
let mut backend = mock();
|
||||
let err = execute(
|
||||
cli(&["tty7", "exec", "%1", "--", line]),
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("control"), "{line:?}: {err}");
|
||||
assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_reports_every_way_it_can_end_without_an_exit_code() {
|
||||
let cases = [
|
||||
(ExecEnd::TimedOut { started: true }, 124, true, false),
|
||||
(ExecEnd::TimedOut { started: false }, 124, true, false),
|
||||
(ExecEnd::PaneExited(Some(0)), 1, false, true),
|
||||
(
|
||||
ExecEnd::Finished {
|
||||
exit: None,
|
||||
ran: true,
|
||||
},
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
ExecEnd::Finished {
|
||||
exit: None,
|
||||
ran: false,
|
||||
},
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (end, want, timed_out, pane_exited) in cases {
|
||||
let mut backend = mock();
|
||||
backend.exec_run = exec_run(end.clone(), b"partial\r\n");
|
||||
let (code, report) = exec_outcome(
|
||||
&["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
);
|
||||
assert_eq!(code, want, "{end:?}");
|
||||
assert_eq!(report.json["exit_code_known"], false, "{end:?}");
|
||||
assert_eq!(report.json["timed_out"], timed_out, "{end:?}");
|
||||
assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}");
|
||||
assert_eq!(
|
||||
report.human, "partial\r\n",
|
||||
"what did arrive is still the answer: {end:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn procs_with_paste_mode(mode: Option<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -2,6 +2,7 @@ mod address;
|
||||
mod backend;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod exec;
|
||||
mod gui;
|
||||
mod keys;
|
||||
mod output;
|
||||
|
||||
@@ -151,9 +151,14 @@ pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> String {
|
||||
record
|
||||
.and_then(|r| r.cwd.clone())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
record
|
||||
.map(|r| if r.live { "yes" } else { "no" }.to_string())
|
||||
.unwrap_or_else(|| "?".to_string()),
|
||||
// Not running, and meant not to be: a sleeping tab's panes
|
||||
// are waiting for their tab to be woken, not dead.
|
||||
match record {
|
||||
_ if tab.hibernated => "asleep".to_string(),
|
||||
Some(r) if r.live => "yes".to_string(),
|
||||
Some(_) => "no".to_string(),
|
||||
None => "?".to_string(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -659,4 +664,29 @@ mod tests {
|
||||
procs_tables(&empty)
|
||||
);
|
||||
}
|
||||
|
||||
/// A sleeping tab's panes are not running and not dead either: the table
|
||||
/// says which, so nobody reads a tab put to sleep as one that crashed.
|
||||
#[test]
|
||||
fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() {
|
||||
let mut m = two_workspace_machine();
|
||||
m.workspaces[0].tabs[1].hibernated = true;
|
||||
for pane in &mut m.panes {
|
||||
if pane.id == 2 || pane.id == 3 {
|
||||
pane.live = false;
|
||||
}
|
||||
}
|
||||
let rendered = pane_table(&m, None);
|
||||
let row = |pane: &str| {
|
||||
rendered
|
||||
.lines()
|
||||
.find(|l| l.starts_with(pane))
|
||||
.unwrap_or_else(|| panic!("no row for {pane}: {rendered}"))
|
||||
.to_string()
|
||||
};
|
||||
assert!(row("%1 ").ends_with("yes"), "{rendered}");
|
||||
assert!(row("%2 ").ends_with("asleep"), "{rendered}");
|
||||
assert!(row("%3 ").ends_with("asleep"), "{rendered}");
|
||||
assert!(row("%5 ").ends_with("yes"), "{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn two_workspace_machine() -> Machine {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
},
|
||||
Tab {
|
||||
id: TabId::new(),
|
||||
@@ -25,6 +26,7 @@ pub fn two_workspace_machine() -> Machine {
|
||||
a: Box::new(PaneNode::Leaf { pane: 2 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 3 }),
|
||||
},
|
||||
hibernated: false,
|
||||
},
|
||||
],
|
||||
active_tab: None,
|
||||
@@ -41,6 +43,7 @@ pub fn two_workspace_machine() -> Machine {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 5 },
|
||||
hibernated: false,
|
||||
}],
|
||||
active_tab: None,
|
||||
attachment: None,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -304,6 +304,32 @@ impl CLIAgent {
|
||||
}
|
||||
|
||||
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
|
||||
let tail = self.session_free_tail(argv)?;
|
||||
let safe = |t: &str| {
|
||||
!t.is_empty()
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
|
||||
};
|
||||
if !tail.iter().all(|t| safe(t)) {
|
||||
return None;
|
||||
}
|
||||
let mut prev_was_flag = false;
|
||||
for t in &tail {
|
||||
let is_flag = t.starts_with('-');
|
||||
if !is_flag && !prev_was_flag {
|
||||
return None;
|
||||
}
|
||||
prev_was_flag = is_flag;
|
||||
}
|
||||
Some(tail.into_iter().map(String::from).collect())
|
||||
}
|
||||
|
||||
/// What follows the agent's own name in `argv`, with everything that names
|
||||
/// a session taken out: the resume/fork subcommands and the flags listed as
|
||||
/// stale for this agent, each with its value. `None` when no token in
|
||||
/// `argv` names this agent — a wrapper, where there is no telling which
|
||||
/// arguments are the agent's.
|
||||
fn session_free_tail(self, argv: &[String]) -> Option<Vec<&str>> {
|
||||
let names_self = |token: &str| {
|
||||
token.split(['/', '\\']).any(|seg| {
|
||||
CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self)
|
||||
@@ -490,24 +516,7 @@ impl CLIAgent {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let safe = |t: &str| {
|
||||
!t.is_empty()
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
|
||||
};
|
||||
if !tail.iter().all(|t| safe(t)) {
|
||||
return None;
|
||||
}
|
||||
let mut prev_was_flag = false;
|
||||
for t in &tail {
|
||||
let is_flag = t.starts_with('-');
|
||||
if !is_flag && !prev_was_flag {
|
||||
return None;
|
||||
}
|
||||
prev_was_flag = is_flag;
|
||||
}
|
||||
Some(tail.into_iter().map(String::from).collect())
|
||||
Some(tail)
|
||||
}
|
||||
|
||||
pub fn accent_rgb(self) -> u32 {
|
||||
@@ -598,6 +607,58 @@ impl CLIAgent {
|
||||
.find(|a| a.aliases().contains(&token))
|
||||
}
|
||||
|
||||
/// The program a launch with no override runs: the first name the agent
|
||||
/// is detected by, which is the one its installer puts on `PATH`.
|
||||
pub fn binary(self) -> &'static str {
|
||||
self.aliases()[0]
|
||||
}
|
||||
|
||||
/// The command line a quick launch types for this agent: the user's
|
||||
/// `agent_launch` entry when there is one, the bare binary otherwise.
|
||||
///
|
||||
/// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a
|
||||
/// hand-written `"Claude"` still counts. A blank entry is no override.
|
||||
pub fn launch_command(self, overrides: &HashMap<String, String>) -> String {
|
||||
overrides
|
||||
.iter()
|
||||
.find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self))
|
||||
.map(|(_, line)| line.trim())
|
||||
.filter(|line| !line.is_empty())
|
||||
.map_or_else(|| self.binary().to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// `argv` as a command line worth launching this agent with again: its
|
||||
/// binary followed by the flags it ran with, minus everything that names
|
||||
/// one particular session (a resume, a fork, a session id) and minus
|
||||
/// positional arguments such as a prompt, which belong to that one run.
|
||||
///
|
||||
/// A run that went through something that does not name the agent — a
|
||||
/// wrapper script mapped by `agent_commands` — cannot be taken apart, so
|
||||
/// it comes back whole: it is what was typed, and it launched the agent.
|
||||
pub fn launch_argv_for_default(self, argv: &[String]) -> Option<Vec<String>> {
|
||||
let env = argv.iter().take_while(|t| is_env_assignment(t)).count();
|
||||
if argv.len() == env {
|
||||
return None;
|
||||
}
|
||||
let Some(tail) = self.session_free_tail(argv) else {
|
||||
return Some(argv.to_vec());
|
||||
};
|
||||
let mut out: Vec<String> = argv[..env].to_vec();
|
||||
out.push(self.binary().to_string());
|
||||
let mut takes_value = false;
|
||||
for t in tail {
|
||||
let is_flag = t.starts_with('-') && t.len() > 1;
|
||||
if is_flag {
|
||||
out.push(t.to_string());
|
||||
takes_value = !t.contains('=');
|
||||
} else if takes_value {
|
||||
out.push(t.to_string());
|
||||
takes_value = false;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn detect_from_argv(argv: &[String]) -> Option<CLIAgent> {
|
||||
Self::detect_from_argv_with(argv, &HashMap::new())
|
||||
@@ -625,6 +686,20 @@ impl CLIAgent {
|
||||
return Some(agent);
|
||||
}
|
||||
|
||||
// A wrapper that is a script runs as its interpreter, so the name the
|
||||
// user mapped is the script's, one token in: `bash ~/bin/cc`,
|
||||
// `node ~/bin/cc.js`. Only the mapped names are looked for there — the
|
||||
// built-in ones already have the scan below.
|
||||
if (is_interpreter(launcher_stem) || is_shell(launcher_stem))
|
||||
&& let Some(agent) = rest
|
||||
.clone()
|
||||
.find(|a| !a.starts_with('-') && !is_env_assignment(a))
|
||||
.and_then(|script| custom.get(&base_stem(script).to_ascii_lowercase()))
|
||||
.and_then(|slug| CLIAgent::from_slug(slug))
|
||||
{
|
||||
return Some(agent);
|
||||
}
|
||||
|
||||
if is_interpreter(launcher_stem) {
|
||||
for arg in rest {
|
||||
if arg.starts_with('-') {
|
||||
@@ -719,6 +794,99 @@ fn is_interpreter(stem: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_shell(stem: &str) -> bool {
|
||||
matches!(
|
||||
stem.to_ascii_lowercase().as_str(),
|
||||
"sh" | "bash" | "zsh" | "dash" | "ksh" | "fish"
|
||||
)
|
||||
}
|
||||
|
||||
/// The program a launch command line runs: its first word that is not an
|
||||
/// environment assignment.
|
||||
pub fn launch_program(command: &str) -> Option<String> {
|
||||
command_argv(command)
|
||||
.into_iter()
|
||||
.find(|t| !is_env_assignment(t))
|
||||
}
|
||||
|
||||
/// The wrapper names `agent_launch` implies, in the shape `agent_commands`
|
||||
/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code.
|
||||
///
|
||||
/// This is how a pane opened by a quick launch through a wrapper is still
|
||||
/// recognised without the user also writing an `agent_commands` entry: the
|
||||
/// launch typed exactly this command, so its program is known to be that
|
||||
/// agent. A program that is already an agent's own name needs no entry, and
|
||||
/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped
|
||||
/// wholesale — `npx` is not Claude Code just because Claude Code is one of the
|
||||
/// things it can run.
|
||||
pub fn launch_aliases(overrides: &HashMap<String, String>) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for (slug, line) in overrides {
|
||||
let Some(agent) = CLIAgent::from_slug(slug) else {
|
||||
continue;
|
||||
};
|
||||
let Some(program) = launch_program(line) else {
|
||||
continue;
|
||||
};
|
||||
let stem = base_stem(&program).to_ascii_lowercase();
|
||||
if stem.is_empty()
|
||||
|| CLIAgent::match_token(&stem).is_some()
|
||||
|| is_interpreter(&stem)
|
||||
|| is_shell(&stem)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.insert(stem, agent.slug().to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `program` names an executable file, either directly (it has a
|
||||
/// directory in it) or through one of the directories in `path`.
|
||||
///
|
||||
/// On Windows a bare name is also tried with the extensions an npm or winget
|
||||
/// install leaves behind, since `claude` there is really `claude.cmd`.
|
||||
pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool {
|
||||
let program = program.trim();
|
||||
if program.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if program.contains(['/', '\\']) {
|
||||
// The launch is typed into a shell, which expands a leading `~/`.
|
||||
let home = program
|
||||
.strip_prefix("~/")
|
||||
.and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest)));
|
||||
return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program)));
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) {
|
||||
&["", ".exe", ".cmd", ".bat", ".ps1"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
std::env::split_paths(path).any(|dir| {
|
||||
exts.iter()
|
||||
.any(|ext| is_executable(&dir.join(format!("{program}{ext}"))))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_executable(path: &std::path::Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -2253,4 +2421,160 @@ mod tests {
|
||||
"\"waiting\""
|
||||
);
|
||||
}
|
||||
|
||||
fn overrides(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_is_the_bare_binary_unless_overridden() {
|
||||
let none = HashMap::new();
|
||||
assert_eq!(CLIAgent::Claude.launch_command(&none), "claude");
|
||||
assert_eq!(CLIAgent::Codex.launch_command(&none), "codex");
|
||||
assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent");
|
||||
assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy");
|
||||
assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder");
|
||||
|
||||
let set = overrides(&[
|
||||
("claude", " claude --dangerously-skip-permissions "),
|
||||
// Keys are read the way `from_slug` reads them.
|
||||
("Codex", "codex --model o3"),
|
||||
// A blank entry is no override at all.
|
||||
("gemini", " "),
|
||||
]);
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.launch_command(&set),
|
||||
"claude --dangerously-skip-permissions"
|
||||
);
|
||||
assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3");
|
||||
assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini");
|
||||
assert_eq!(CLIAgent::Aider.launch_command(&set), "aider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_agent_launches_as_a_name_it_is_detected_by() {
|
||||
let none = HashMap::new();
|
||||
for agent in CLIAgent::ALL {
|
||||
let argv = command_argv(&agent.launch_command(&none));
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv),
|
||||
Some(agent),
|
||||
"{agent:?} would launch as something detection does not know"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() {
|
||||
let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")]));
|
||||
// Run as a binary, and run as a script under its interpreter.
|
||||
for run in [
|
||||
argv(&["cc", "--fast"]),
|
||||
argv(&["/Users/me/bin/cc", "--fast"]),
|
||||
argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]),
|
||||
argv(&["node", "/Users/me/bin/cc.js", "--fast"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&run, &aliases),
|
||||
Some(CLIAgent::Claude),
|
||||
"{run:?}"
|
||||
);
|
||||
}
|
||||
// Without the override the same wrapper is nobody.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() {
|
||||
let aliases = launch_aliases(&overrides(&[
|
||||
("claude", "npx @anthropic-ai/claude-code"),
|
||||
("codex", "bash -lc codex"),
|
||||
// Already detected as itself; mapping it would add nothing.
|
||||
("gemini", "gemini --yolo"),
|
||||
("not-an-agent", "cc"),
|
||||
]));
|
||||
assert!(aliases.is_empty(), "{aliases:?}");
|
||||
// A shell running something else is still nobody.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(
|
||||
&argv(&["bash", "./build.sh"]),
|
||||
&launch_aliases(&overrides(&[("claude", "cc")]))
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() {
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.launch_argv_for_default(&argv(&[
|
||||
"node",
|
||||
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
"abc-123",
|
||||
"--append-system-prompt",
|
||||
"be terse",
|
||||
"fix the bug",
|
||||
])),
|
||||
Some(argv(&[
|
||||
"claude",
|
||||
"--model",
|
||||
"opus",
|
||||
"--append-system-prompt",
|
||||
"be terse",
|
||||
]))
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Codex.launch_argv_for_default(&argv(&[
|
||||
"codex",
|
||||
"resume",
|
||||
"019a",
|
||||
"--model=o3",
|
||||
"prompt",
|
||||
])),
|
||||
Some(argv(&["codex", "--model=o3"]))
|
||||
);
|
||||
// A wrapper cannot be taken apart; what was typed is what launched it.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])),
|
||||
Some(argv(&["cc", "--fast", "x"]))
|
||||
);
|
||||
assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_on_path_finds_only_executables() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let bin = dir.path().join("claude");
|
||||
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::write(dir.path().join("codex"), "not executable").unwrap();
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(!program_on_path("codex", dir.path().as_os_str()));
|
||||
}
|
||||
std::fs::create_dir(dir.path().join("gemini")).unwrap();
|
||||
let path =
|
||||
std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap();
|
||||
assert!(program_on_path("claude", &path));
|
||||
assert!(
|
||||
!program_on_path("gemini", &path),
|
||||
"a directory is not a program"
|
||||
);
|
||||
assert!(!program_on_path("aider", &path));
|
||||
assert!(program_on_path(
|
||||
bin.to_str().unwrap(),
|
||||
std::ffi::OsStr::new("")
|
||||
));
|
||||
assert!(!program_on_path("", &path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -365,20 +376,45 @@ 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>,
|
||||
/// How often and how lately each shell was opened from the New Tab menu
|
||||
/// or its palette command, keyed by the label the inventory shows it
|
||||
/// under. Orders the menu's short list of shells the way
|
||||
/// `ssh_profile_frecency` orders its hosts.
|
||||
#[serde(default)]
|
||||
pub shell_frecency: HashMap<String, ProfileUsage>,
|
||||
|
||||
#[serde(default)]
|
||||
pub command_frecency: HashMap<String, ProfileUsage>,
|
||||
|
||||
#[serde(default)]
|
||||
pub agent_commands: HashMap<String, String>,
|
||||
/// The command line a quick launch types for an agent, keyed by its slug:
|
||||
/// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no
|
||||
/// entry is launched as its bare binary. A wrapper named here is detected
|
||||
/// as that agent without an `agent_commands` entry of its own.
|
||||
#[serde(default)]
|
||||
pub agent_launch: HashMap<String, String>,
|
||||
/// How often and how recently each agent (by slug) was launched or seen
|
||||
/// running — what orders the quick-launch commands and picks the agent
|
||||
/// "New Agent Tab" opens.
|
||||
#[serde(default)]
|
||||
pub agent_frecency: HashMap<String, ProfileUsage>,
|
||||
#[serde(default = "default_true")]
|
||||
pub restore_agent_sessions: bool,
|
||||
/// Give each pane its own shell history instead of one file every pane
|
||||
@@ -400,6 +436,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)]
|
||||
@@ -477,6 +552,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
|
||||
@@ -644,6 +734,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,
|
||||
@@ -653,6 +744,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_auto_grouping: true,
|
||||
@@ -691,11 +783,15 @@ impl Default for Config {
|
||||
verify_host_keys: true,
|
||||
ssh_warn_on_close: false,
|
||||
ssh_profile_frecency: HashMap::new(),
|
||||
shell_frecency: HashMap::new(),
|
||||
command_frecency: HashMap::new(),
|
||||
agent_commands: HashMap::new(),
|
||||
agent_launch: HashMap::new(),
|
||||
agent_frecency: HashMap::new(),
|
||||
restore_agent_sessions: true,
|
||||
per_pane_history: false,
|
||||
quarantined: false,
|
||||
servers_unreadable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -742,13 +838,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:
|
||||
@@ -761,13 +886,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
|
||||
@@ -779,14 +904,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 {
|
||||
@@ -874,18 +1068,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> {
|
||||
@@ -1170,15 +1408,44 @@ pub fn extra_env() -> HashMap<String, String> {
|
||||
Config::load().env
|
||||
}
|
||||
|
||||
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
|
||||
static CACHE: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
Config::load()
|
||||
.agent_commands
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_ascii_lowercase(), v))
|
||||
.collect()
|
||||
})
|
||||
/// Every wrapper name the daemon should read as an agent: `agent_commands`,
|
||||
/// plus the programs `agent_launch` entries run (see
|
||||
/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands`
|
||||
/// entry wins over an implied one.
|
||||
///
|
||||
/// Read again whenever `config.json` changes rather than once per process: the
|
||||
/// daemon outlives any number of edits, and a launch override added while it
|
||||
/// runs has to be recognised by the very next launch that uses it.
|
||||
pub fn agent_detection_aliases() -> std::sync::Arc<HashMap<String, String>> {
|
||||
type Stamp = Option<(std::time::SystemTime, u64)>;
|
||||
static CACHE: std::sync::Mutex<Option<(Stamp, std::sync::Arc<HashMap<String, String>>)>> =
|
||||
std::sync::Mutex::new(None);
|
||||
let stamp: Stamp = Config::path()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| Some((m.modified().ok()?, m.len())));
|
||||
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((seen, aliases)) = cache.as_ref()
|
||||
&& *seen == stamp
|
||||
{
|
||||
return aliases.clone();
|
||||
}
|
||||
let cfg = Config::load();
|
||||
let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch));
|
||||
*cache = Some((stamp, aliases.clone()));
|
||||
aliases
|
||||
}
|
||||
|
||||
/// [`agent_detection_aliases`] over the two maps it reads, for a caller that
|
||||
/// already holds them.
|
||||
pub fn detection_aliases(
|
||||
commands: &HashMap<String, String>,
|
||||
launch: &HashMap<String, String>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut aliases = crate::core::cli_agent::launch_aliases(launch);
|
||||
for (name, slug) in commands {
|
||||
aliases.insert(name.to_ascii_lowercase(), slug.clone());
|
||||
}
|
||||
aliases
|
||||
}
|
||||
|
||||
fn default_preset() -> String {
|
||||
@@ -1405,8 +1672,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);
|
||||
}
|
||||
@@ -2517,4 +2788,273 @@ mod tests {
|
||||
std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap();
|
||||
assert_eq!(portable_data_dir(exe_dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_overrides_teach_the_daemon_their_wrappers_as_the_file_changes() {
|
||||
let _guard = lock_config_file();
|
||||
pin_config_dir();
|
||||
let mut cfg = Config::default();
|
||||
cfg.agent_launch
|
||||
.insert("claude".to_string(), "cc --fast".to_string());
|
||||
cfg.save();
|
||||
let aliases = agent_detection_aliases();
|
||||
assert_eq!(aliases.get("cc").map(String::as_str), Some("claude"));
|
||||
|
||||
// An edit made while the daemon runs is picked up on the next read,
|
||||
// and an explicit `agent_commands` entry outranks an implied one.
|
||||
cfg.agent_launch
|
||||
.insert("codex".to_string(), "/opt/bin/cx".to_string());
|
||||
cfg.agent_commands
|
||||
.insert("CC".to_string(), "gemini".to_string());
|
||||
cfg.save();
|
||||
let aliases = agent_detection_aliases();
|
||||
assert_eq!(aliases.get("cc").map(String::as_str), Some("gemini"));
|
||||
assert_eq!(aliases.get("cx").map(String::as_str), Some("codex"));
|
||||
|
||||
let back: Config = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
assert_eq!(back.agent_launch, cfg.agent_launch);
|
||||
Config::default().save();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,19 @@ pub struct Tab {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_auto: Option<AutoKey>,
|
||||
pub root: PaneNode,
|
||||
/// The tab was put to sleep: its panes were stopped to give their memory
|
||||
/// back, and it stays in the workspace to be woken later (#762).
|
||||
///
|
||||
/// The panes keep their ids in `root` and their records in
|
||||
/// [`Machine::panes`] — cwd, shell, agent session — because waking is a
|
||||
/// restore of exactly those panes, through the same path a reboot takes.
|
||||
/// That is also what keeps the sweeps off them: a pane the tree still
|
||||
/// names is one whose stored screen and history are kept.
|
||||
///
|
||||
/// Left out of the document while false, so a tree written by this build
|
||||
/// reads the same to one that predates the field.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hibernated: bool,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
@@ -187,6 +200,7 @@ impl Tab {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane },
|
||||
hibernated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,6 +786,43 @@ impl MachineStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Put a tab to sleep, or mark it awake again. Answers the panes the tab
|
||||
/// holds, which for a tab going to sleep are the ones whoever asked is now
|
||||
/// expected to stop — see [`ControlRequest::TabSetHibernated`].
|
||||
///
|
||||
/// Only the mark changes here. The panes stay in the tab and in the pane
|
||||
/// list, so the facts a wake needs survive the processes: waking hands the
|
||||
/// same ids to the ordinary restore, which spawns successors and swaps
|
||||
/// them in with `pane_replace`. Setting the mark a tab already has is not
|
||||
/// a change and raises no delta.
|
||||
///
|
||||
/// The delta is `TabRestructured` carrying the whole tab rather than a
|
||||
/// variant of its own, so a client that predates the mark still decodes
|
||||
/// it — it reads the same tab back, minus a field it does not know.
|
||||
///
|
||||
/// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated
|
||||
pub fn tab_set_hibernated(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
hibernated: bool,
|
||||
origin: Option<SubscriberId>,
|
||||
) -> io::Result<Vec<u64>> {
|
||||
self.mutate(origin, |m| {
|
||||
let t = find_tab(m, workspace, tab)?;
|
||||
let panes = t.root.pane_ids();
|
||||
if t.hibernated == hibernated {
|
||||
return Ok((panes, Vec::new()));
|
||||
}
|
||||
t.hibernated = hibernated;
|
||||
let delta = LayoutDelta::TabRestructured {
|
||||
tab: t.clone(),
|
||||
pane: None,
|
||||
};
|
||||
Ok((panes, vec![(workspace, delta)]))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tab_set_group(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -2921,4 +2972,101 @@ mod tests {
|
||||
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]);
|
||||
assert!(machine.panes.is_empty());
|
||||
}
|
||||
|
||||
/// A tab put to sleep keeps its panes — ids in the layout, records in the
|
||||
/// pane list — because waking is a restore of exactly those. Only the
|
||||
/// orphan collection a close runs could take them, and a sleeping tab
|
||||
/// still names them.
|
||||
#[test]
|
||||
fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() {
|
||||
let (store, _dir, ws, tab) = store_with_tab();
|
||||
store
|
||||
.pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None)
|
||||
.unwrap();
|
||||
let (_sub, heard) = recorded(&store);
|
||||
|
||||
let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
|
||||
|
||||
assert_eq!(panes, vec![1, 2]);
|
||||
let m = store.machine();
|
||||
let asleep = &m.workspaces[0].tabs[0];
|
||||
assert!(asleep.hibernated);
|
||||
assert_eq!(asleep.root.pane_ids(), vec![1, 2]);
|
||||
assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2));
|
||||
let heard = heard.lock().unwrap();
|
||||
assert_eq!(heard.len(), 1);
|
||||
match &heard[0].1 {
|
||||
LayoutDelta::TabRestructured { tab: t, pane: None } => {
|
||||
assert_eq!(t.id, tab.id);
|
||||
assert!(t.hibernated, "the delta carries the mark");
|
||||
}
|
||||
other => panic!("expected TabRestructured, heard {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_the_mark_a_tab_already_has_raises_nothing() {
|
||||
let (store, _dir, ws, tab) = store_with_tab();
|
||||
let (_sub, heard) = recorded(&store);
|
||||
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
|
||||
assert!(heard.lock().unwrap().is_empty());
|
||||
|
||||
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
|
||||
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
|
||||
assert_eq!(heard.lock().unwrap().len(), 1);
|
||||
|
||||
store.tab_set_hibernated(ws, tab.id, false, None).unwrap();
|
||||
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
|
||||
assert_eq!(heard.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hibernating_a_tab_that_is_not_there_is_refused() {
|
||||
let (store, _dir, ws, _tab) = store_with_tab();
|
||||
let err = store
|
||||
.tab_set_hibernated(ws, TabId::new(), true, None)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
/// Asleep has to outlive the daemon: a restart reads the tree back from
|
||||
/// disk, and a tab that came back awake would be respawned on the spot —
|
||||
/// every process the user put to sleep, started again unasked.
|
||||
#[test]
|
||||
fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() {
|
||||
let (store, dir, ws, tab) = store_with_tab();
|
||||
let other = store
|
||||
.tab_create(ws, None, seed(5, "/else"), None, None)
|
||||
.unwrap();
|
||||
store.tab_set_hibernated(ws, tab.id, true, None).unwrap();
|
||||
drop(store);
|
||||
|
||||
let reopened = MachineStore::open(dir.path().join(MACHINE_FILE));
|
||||
let m = reopened.machine();
|
||||
let tabs = &m.workspaces[0].tabs;
|
||||
assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated);
|
||||
assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated);
|
||||
let record = m.panes.iter().find(|p| p.id == 1).unwrap();
|
||||
assert_eq!(record.cwd.as_deref(), Some("/work"));
|
||||
assert!(!record.live);
|
||||
}
|
||||
|
||||
/// Awake is the default and stays out of the document, so a tree this
|
||||
/// build writes for a tab that never slept reads the same to one that
|
||||
/// predates the mark, and a tree from before it decodes as awake.
|
||||
#[test]
|
||||
fn the_mark_is_only_written_while_it_is_set() {
|
||||
let tab = Tab::leaf(4);
|
||||
let json = serde_json::to_string(&tab).unwrap();
|
||||
assert!(!json.contains("hibernated"), "{json}");
|
||||
let back: Tab = serde_json::from_str(&json).unwrap();
|
||||
assert!(!back.hibernated);
|
||||
|
||||
let asleep = Tab {
|
||||
hibernated: true,
|
||||
..Tab::leaf(4)
|
||||
};
|
||||
let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap();
|
||||
assert!(back.hibernated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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~"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ pub struct SessionTab {
|
||||
pub last_auto: Option<crate::core::group_key::AutoKey>,
|
||||
#[serde(skip)]
|
||||
pub tree_id: Option<crate::core::machine::TabId>,
|
||||
/// Asleep in the tree: comes back as a placeholder holding `pane`, and
|
||||
/// nothing is spawned for it until it is woken. See
|
||||
/// [`Tab::hibernated`](crate::core::machine::Tab::hibernated).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hibernated: bool,
|
||||
/// What a sleeping tab is called while nothing in it runs to say — read
|
||||
/// off the tree's pane records, the same way the switcher names a tab of
|
||||
/// a window it does not own.
|
||||
#[serde(skip)]
|
||||
pub asleep_view: Option<crate::core::tab_view::TabView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -379,6 +389,11 @@ pub struct WindowView {
|
||||
/// is another client's activity, not ours); opening one clears the mark.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub synced: bool,
|
||||
/// Slugs of the coding agents seen running in this workspace, most recent
|
||||
/// last. A remote workspace cannot be asked what is on its `PATH`, so
|
||||
/// this is what its quick-launch list is made of.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub seen_agents: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for WindowView {
|
||||
@@ -392,6 +407,7 @@ impl Default for WindowView {
|
||||
label: None,
|
||||
subject: None,
|
||||
synced: false,
|
||||
seen_agents: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,6 +428,17 @@ impl WindowView {
|
||||
self.host.is_some()
|
||||
}
|
||||
|
||||
/// Remember `slug` as running here, moving it to the end if it was
|
||||
/// already known. Answers whether anything changed.
|
||||
pub fn saw_agent(&mut self, slug: &str) -> bool {
|
||||
if self.seen_agents.last().is_some_and(|s| s == slug) {
|
||||
return false;
|
||||
}
|
||||
self.seen_agents.retain(|s| s != slug);
|
||||
self.seen_agents.push(slug.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
pub fn host_id(&self) -> crate::host::HostId {
|
||||
match &self.host {
|
||||
Some(r) => r.host_id(),
|
||||
@@ -1167,4 +1194,29 @@ mod tests {
|
||||
views.views.retain(|w| w.id != b_id);
|
||||
assert_eq!(numbers(&views), vec![a_id, c_id]);
|
||||
}
|
||||
|
||||
/// A remote workspace's quick-launch list is the agents seen running in
|
||||
/// it, so the list has to be in `views.json` with the rest of the
|
||||
/// workspace — an in-memory one would be empty after every restart.
|
||||
#[test]
|
||||
fn agents_seen_in_a_workspace_survive_a_restart() {
|
||||
let _file = lock_session_file();
|
||||
pin_config_dir();
|
||||
let mut entry = remote_view("build-box");
|
||||
assert!(entry.saw_agent("claude"));
|
||||
assert!(entry.saw_agent("codex"));
|
||||
assert!(!entry.saw_agent("codex"), "already the most recent");
|
||||
assert!(entry.saw_agent("claude"), "moves to the end");
|
||||
let id = entry.id;
|
||||
WindowViews {
|
||||
active: Some(id),
|
||||
views: vec![entry, view()],
|
||||
}
|
||||
.save();
|
||||
let loaded = WindowViews::load().expect("a saved views file should load back");
|
||||
assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]);
|
||||
// A workspace that has seen nothing writes nothing for it.
|
||||
let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap();
|
||||
assert_eq!(text.matches("seen_agents").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ pub struct ForwardRule {
|
||||
pub bind: HostPort,
|
||||
pub target: HostPort,
|
||||
pub description: String,
|
||||
/// Whether the rule is opened with the connection. A switched-off rule
|
||||
/// keeps its whole configuration and simply binds nothing, so one local
|
||||
/// port can be pointed at different targets by switching rules rather
|
||||
/// than retyping them.
|
||||
///
|
||||
/// Missing from every profile saved before the switch existed, and those
|
||||
/// rules were all live: the container's `#[serde(default)]` fills it from
|
||||
/// `Default`, which says `true`, or upgrading would silently turn every
|
||||
/// saved forward off.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ForwardRule {
|
||||
@@ -162,6 +172,7 @@ impl Default for ForwardRule {
|
||||
bind: HostPort::default(),
|
||||
target: HostPort::default(),
|
||||
description: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,6 +580,7 @@ mod tests {
|
||||
bind: HostPort::new("127.0.0.1", 8080),
|
||||
target: HostPort::new("10.0.0.1", 80),
|
||||
description: "web".to_string(),
|
||||
enabled: false,
|
||||
}];
|
||||
original.socks_proxy = Some(HostPort::new("proxy", 1080));
|
||||
original.algorithms.kex = vec!["curve25519-sha256".to_string()];
|
||||
@@ -597,6 +609,25 @@ mod tests {
|
||||
let m: AuthMode = serde_json::from_str("\"agent\"").unwrap();
|
||||
assert_eq!(m, AuthMode::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() {
|
||||
let old = r#"{"name":"box","host":"h","forwards":[
|
||||
{"kind":"local","bind":{"host":"127.0.0.1","port":8080},
|
||||
"target":{"host":"srv","port":80},"description":"web"}
|
||||
]}"#;
|
||||
let p: SshProfile = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(p.forwards.len(), 1);
|
||||
assert!(p.forwards[0].enabled, "an old rule must stay live");
|
||||
assert!(ForwardRule::default().enabled);
|
||||
|
||||
let mut off = p.forwards[0].clone();
|
||||
off.enabled = false;
|
||||
let back: ForwardRule =
|
||||
serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap();
|
||||
assert!(!back.enabled, "a switched-off rule stays off across a save");
|
||||
assert_eq!(back, off);
|
||||
}
|
||||
}
|
||||
|
||||
fn new_id() -> Uuid {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ pub mod feature {
|
||||
/// simply unknown here: the pane lives in the peer's registry, and the
|
||||
/// local daemon this client would otherwise ask has never heard of it.
|
||||
pub const PANE_PROCS: &str = "pane-procs";
|
||||
/// The peer can put a tab to sleep: mark it in the tree and stop its panes
|
||||
/// while keeping their screens (#762). Needs both a tree and panes, so a
|
||||
/// peer serving either alone does not say it.
|
||||
pub const TAB_HIBERNATE: &str = "tab-hibernate";
|
||||
}
|
||||
|
||||
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
|
||||
@@ -305,6 +309,16 @@ pub enum ControlRequest {
|
||||
workspace: WorkspaceId,
|
||||
groups: crate::core::group_key::WorkspaceGroups,
|
||||
},
|
||||
/// Put a tab to sleep or mark it awake. Going to sleep, the peer also
|
||||
/// stops every pane the tab holds, keeping each one's screen on disk for
|
||||
/// the wake to restore; waking only clears the mark, because the panes a
|
||||
/// wake brings up are spawned by the client like any restored pane.
|
||||
/// Answers the tab's panes. Gated on [`feature::TAB_HIBERNATE`].
|
||||
TabSetHibernated {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
hibernated: bool,
|
||||
},
|
||||
PaneSplit {
|
||||
workspace: WorkspaceId,
|
||||
pane: u64,
|
||||
@@ -413,6 +427,7 @@ impl ControlRequest {
|
||||
| TabMove { .. }
|
||||
| TabSetGroup { .. }
|
||||
| WorkspaceSetGroups { .. }
|
||||
| TabSetHibernated { .. }
|
||||
| PaneSplit { .. }
|
||||
| PaneClose { .. }
|
||||
| PaneSetRatio { .. }
|
||||
|
||||
@@ -839,6 +839,10 @@ enum PaneBackend {
|
||||
}
|
||||
|
||||
struct ForegroundProbes {
|
||||
/// The pty's foreground process group: one ioctl, cheap enough to ask on
|
||||
/// every read, and a change in it is what says the probes below would now
|
||||
/// answer differently.
|
||||
group: Box<dyn Fn() -> Option<i32> + Send>,
|
||||
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
|
||||
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
|
||||
cwd: Box<dyn Fn() -> Option<PathBuf> + Send>,
|
||||
@@ -1680,6 +1684,7 @@ impl DaemonPane {
|
||||
);
|
||||
|
||||
let fg_master = master.clone();
|
||||
let group_master = master.clone();
|
||||
let remote_master = master.clone();
|
||||
let agent_master = master.clone();
|
||||
let cwd_master = master.clone();
|
||||
@@ -1691,6 +1696,7 @@ impl DaemonPane {
|
||||
writer.clone(),
|
||||
move || foreground_command_running(&fg_master, shell_pid),
|
||||
ForegroundProbes {
|
||||
group: Box::new(move || pty_foreground_pgid(&group_master)),
|
||||
remote: Box::new(move || foreground_remote_context(&remote_master)),
|
||||
agent: Box::new(move || foreground_agent(&agent_master)),
|
||||
cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)),
|
||||
@@ -1914,6 +1920,7 @@ impl DaemonPane {
|
||||
writer.clone(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -1963,6 +1970,7 @@ impl DaemonPane {
|
||||
death: Arc<DeathReporter>,
|
||||
) -> JoinHandle<()> {
|
||||
let ForegroundProbes {
|
||||
group: foreground_group,
|
||||
remote: foreground_remote,
|
||||
agent: foreground_agent_fn,
|
||||
cwd: foreground_cwd_fn,
|
||||
@@ -1996,6 +2004,7 @@ impl DaemonPane {
|
||||
let mut tr_read_t = std::time::Duration::ZERO;
|
||||
let mut tr_disp_t = std::time::Duration::ZERO;
|
||||
let mut next_remote_check = std::time::Instant::now();
|
||||
let mut last_group = None;
|
||||
|
||||
loop {
|
||||
if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
@@ -2073,8 +2082,21 @@ impl DaemonPane {
|
||||
// reads, most visibly) stayed on the far end until
|
||||
// some unrelated output arrived (#817).
|
||||
let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt);
|
||||
let poll_now =
|
||||
back_at_prompt || std::time::Instant::now() >= next_remote_check;
|
||||
// A new foreground group is a new program in front
|
||||
// — probe for it on its first output. The interval
|
||||
// alone missed an agent that drew its whole first
|
||||
// screen within it of the prompt it was typed at
|
||||
// and then sat waiting for a key: a launch that
|
||||
// types the command the moment the shell is up
|
||||
// did that every time, and the pane never learned
|
||||
// it was running an agent until something else
|
||||
// printed.
|
||||
let group = foreground_group();
|
||||
let group_changed = group != last_group;
|
||||
last_group = group;
|
||||
let poll_now = back_at_prompt
|
||||
|| group_changed
|
||||
|| std::time::Instant::now() >= next_remote_check;
|
||||
if poll_now {
|
||||
next_remote_check =
|
||||
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
|
||||
@@ -2272,6 +2294,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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3013,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
if shell_mark_capture_changed(&st.shell, &shell) {
|
||||
apply_agent(
|
||||
st,
|
||||
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()),
|
||||
agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()),
|
||||
);
|
||||
}
|
||||
st.shell = shell.clone();
|
||||
@@ -3057,6 +3080,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 +3214,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));
|
||||
}
|
||||
@@ -3335,7 +3368,7 @@ fn foreground_agent(
|
||||
let argv = crate::daemon::remote::foreground_argv(pid)?;
|
||||
let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
&argv,
|
||||
crate::core::config::agent_commands_cached(),
|
||||
&crate::core::config::agent_detection_aliases(),
|
||||
)?;
|
||||
Some((agent, argv))
|
||||
};
|
||||
@@ -5238,6 +5271,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| Some(None)),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -5299,6 +5333,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| Some(None)),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -5378,6 +5413,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};
|
||||
@@ -6060,6 +6128,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote,
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6107,6 +6176,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6211,6 +6281,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6308,6 +6379,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6345,6 +6417,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6415,6 +6488,7 @@ mod tests {
|
||||
writer,
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6508,6 +6582,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6589,6 +6664,7 @@ mod tests {
|
||||
writer,
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6626,6 +6702,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6677,6 +6754,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))),
|
||||
@@ -6708,6 +6786,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -6734,6 +6813,7 @@ mod tests {
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(|| None),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
cwd: Box::new(|| None),
|
||||
@@ -7297,4 +7377,70 @@ mod tests {
|
||||
apply_signals(&mut st, SniffSignals::default());
|
||||
assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x")));
|
||||
}
|
||||
|
||||
/// A quick launch types the agent's command the moment the shell is up, so
|
||||
/// the agent's whole first screen can land inside the poll interval of the
|
||||
/// prompt it was typed at — and then it waits for a key, printing nothing
|
||||
/// more. Probing only on the interval, the pane never found out an agent
|
||||
/// was running. A change of foreground process group now probes at once.
|
||||
#[test]
|
||||
fn a_new_foreground_group_is_probed_inside_the_poll_interval() {
|
||||
struct Chunks(std::collections::VecDeque<Vec<u8>>);
|
||||
|
||||
impl Read for Chunks {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self.0.pop_front() {
|
||||
Some(chunk) => {
|
||||
buf[..chunk.len()].copy_from_slice(&chunk);
|
||||
Ok(chunk.len())
|
||||
}
|
||||
None => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let probes_for = |groups: Vec<i32>| {
|
||||
let state = Arc::new(Mutex::new(test_state(true)));
|
||||
let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = probes.clone();
|
||||
let groups = Mutex::new(groups.into_iter());
|
||||
let handle = DaemonPane::spawn_reader(
|
||||
state,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::new(OutputGate::new()),
|
||||
// Plain output, no prompt marks: only the interval or the
|
||||
// group can trigger a probe.
|
||||
Box::new(Chunks(
|
||||
[b"$ ".to_vec(), b"agent screen".to_vec()]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)),
|
||||
null_writer(),
|
||||
|| false,
|
||||
ForegroundProbes {
|
||||
group: Box::new(move || groups.lock().unwrap().next()),
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(move || {
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
}),
|
||||
cwd: Box::new(|| None),
|
||||
},
|
||||
Arc::new(DeathReporter::new(|| {})),
|
||||
);
|
||||
handle.join().unwrap();
|
||||
probes.load(Ordering::SeqCst)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
probes_for(vec![10, 10]),
|
||||
1,
|
||||
"same program: the interval rules"
|
||||
);
|
||||
assert_eq!(
|
||||
probes_for(vec![10, 11]),
|
||||
2,
|
||||
"a new program is probed at once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -181,6 +181,10 @@ pub enum WorkspaceOp {
|
||||
RemoveForward {
|
||||
forward_id: u64,
|
||||
},
|
||||
SetForwardEnabled {
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
},
|
||||
ListForwards,
|
||||
TeardownForwards,
|
||||
SftpList {
|
||||
@@ -261,6 +265,13 @@ pub struct SshForwardRule {
|
||||
pub target_port: u16,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// A switched-off rule is registered but binds nothing. Defaults to on,
|
||||
/// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never
|
||||
/// sends it and every rule it knows is live, while one that receives it
|
||||
/// ignores it — the worst case is an older far end opening a rule the
|
||||
/// user switched off, which is what it always did.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -420,6 +431,13 @@ pub struct ManagedForward {
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub status: ForwardStatus,
|
||||
/// Whether the forward is switched on. A field rather than a
|
||||
/// `ForwardStatus` variant for the reason `loop_exit_status` gives: an
|
||||
/// older peer fails the whole frame on a variant it has never heard of,
|
||||
/// but skips a field it does not know. A switched-off forward's status is
|
||||
/// an `Error` saying so, which is what such a peer then draws.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -551,6 +569,15 @@ pub struct PaneContext {
|
||||
/// prompt" on a remote pane means nothing: the newest mark is then the
|
||||
/// near shell's own "I started `ssh`", and it will never be replaced.
|
||||
pub remote_prompt_seen: bool,
|
||||
/// Whether the pane's output has switched bracketed paste (mode 2004) on,
|
||||
/// from the same fold the replay restores modes from. `None` from a daemon
|
||||
/// built before the field existed — "cannot say", not "off".
|
||||
///
|
||||
/// It is here so a client with no terminal of its own — `tty7 send
|
||||
/// --paste` — can frame a paste the way the GUI does, by asking the pane
|
||||
/// rather than assuming.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bracketed_paste: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_term() -> String {
|
||||
@@ -892,6 +919,11 @@ pub enum ClientMsg {
|
||||
pane_id: u64,
|
||||
forward_id: u64,
|
||||
},
|
||||
SetForwardEnabled {
|
||||
pane_id: u64,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
},
|
||||
ListForwards {
|
||||
pane_id: u64,
|
||||
},
|
||||
@@ -987,6 +1019,7 @@ mod kind {
|
||||
pub const ADD_FORWARD: u8 = 20;
|
||||
pub const REMOVE_FORWARD: u8 = 21;
|
||||
pub const LIST_FORWARDS: u8 = 22;
|
||||
pub const SET_FORWARD_ENABLED: u8 = 23;
|
||||
pub const VERSION: u8 = 40;
|
||||
pub const QUERY_PROCS: u8 = 50;
|
||||
pub const ON_WORKSPACE: u8 = 52;
|
||||
@@ -1260,6 +1293,15 @@ impl ClientMsg {
|
||||
pane_id,
|
||||
forward_id,
|
||||
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
|
||||
ClientMsg::SetForwardEnabled {
|
||||
pane_id,
|
||||
forward_id,
|
||||
enabled,
|
||||
} => write_frame(
|
||||
w,
|
||||
kind::SET_FORWARD_ENABLED,
|
||||
&to_json(&(pane_id, forward_id, enabled))?,
|
||||
),
|
||||
ClientMsg::QueryProcs { pane_id } => {
|
||||
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
|
||||
}
|
||||
@@ -1390,6 +1432,14 @@ impl ClientMsg {
|
||||
forward_id,
|
||||
}
|
||||
}
|
||||
kind::SET_FORWARD_ENABLED => {
|
||||
let (pane_id, forward_id, enabled) = from_json(&payload)?;
|
||||
ClientMsg::SetForwardEnabled {
|
||||
pane_id,
|
||||
forward_id,
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
kind::LIST_FORWARDS => ClientMsg::ListForwards {
|
||||
pane_id: from_json(&payload)?,
|
||||
},
|
||||
@@ -1758,6 +1808,7 @@ mod tests {
|
||||
target_host: "10.0.0.5".into(),
|
||||
target_port: 80,
|
||||
description: Some("web".into()),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
ClientMsg::AddForward {
|
||||
@@ -1769,12 +1820,18 @@ mod tests {
|
||||
target_host: String::new(),
|
||||
target_port: 0,
|
||||
description: None,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
ClientMsg::RemoveForward {
|
||||
pane_id: 7,
|
||||
forward_id: 3,
|
||||
},
|
||||
ClientMsg::SetForwardEnabled {
|
||||
pane_id: 7,
|
||||
forward_id: 3,
|
||||
enabled: false,
|
||||
},
|
||||
ClientMsg::ListForwards { pane_id: 7 },
|
||||
ClientMsg::Version,
|
||||
];
|
||||
@@ -1934,6 +1991,7 @@ mod tests {
|
||||
target_port: 80,
|
||||
description: Some("web".into()),
|
||||
status: ForwardStatus::Listening,
|
||||
enabled: true,
|
||||
},
|
||||
ManagedForward {
|
||||
id: 2,
|
||||
@@ -1945,6 +2003,7 @@ mod tests {
|
||||
target_port: 3000,
|
||||
description: None,
|
||||
status: ForwardStatus::Error("bind refused".into()),
|
||||
enabled: false,
|
||||
},
|
||||
]),
|
||||
DaemonMsg::Version(DaemonVersion {
|
||||
@@ -2385,6 +2444,7 @@ mod tests {
|
||||
target_host: "127.0.0.1".into(),
|
||||
target_port: 80,
|
||||
description: Some("web".into()),
|
||||
enabled: true,
|
||||
}],
|
||||
keepalive_interval_s: Some(30),
|
||||
keepalive_count_max: Some(3),
|
||||
@@ -2471,9 +2531,14 @@ mod tests {
|
||||
target_host: "127.0.0.1".into(),
|
||||
target_port: 5432,
|
||||
description: Some("db".into()),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
WorkspaceOp::RemoveForward { forward_id: 4 },
|
||||
WorkspaceOp::SetForwardEnabled {
|
||||
forward_id: 4,
|
||||
enabled: true,
|
||||
},
|
||||
WorkspaceOp::ListForwards,
|
||||
WorkspaceOp::TeardownForwards,
|
||||
WorkspaceOp::SftpList {
|
||||
@@ -2702,4 +2767,39 @@ mod tests {
|
||||
"the session daemon must not advertise a dialect it cannot serve"
|
||||
);
|
||||
}
|
||||
|
||||
/// `enabled` crosses the same peers as `rejected` above and gets the same
|
||||
/// treatment: a rule or a forward from a peer that predates the switch is
|
||||
/// a live one, and a peer that predates it reads a frame carrying it.
|
||||
#[test]
|
||||
fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() {
|
||||
let rule: SshForwardRule = serde_json::from_str(
|
||||
r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080,
|
||||
"target_host":"srv","target_port":80}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rule.enabled);
|
||||
|
||||
let managed: ManagedForward = serde_json::from_str(
|
||||
r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1",
|
||||
"bind_port":8080,"status":"listening"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(managed.enabled);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyManagedForward {
|
||||
id: u64,
|
||||
status: ForwardStatus,
|
||||
}
|
||||
let off = serde_json::to_string(&ManagedForward {
|
||||
enabled: false,
|
||||
status: ForwardStatus::Error("disabled".into()),
|
||||
..managed
|
||||
})
|
||||
.unwrap();
|
||||
let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap();
|
||||
assert_eq!(legacy.id, 1);
|
||||
assert_eq!(legacy.status, ForwardStatus::Error("disabled".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry {
|
||||
self.get(pane_id).map(|p| p.procs()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn hibernate_pane(&self, pane_id: u64) {
|
||||
hibernate_pane(self, pane_id);
|
||||
}
|
||||
|
||||
fn agent_states(&self) -> Vec<crate::daemon::control::PaneAgentState> {
|
||||
let panes: Vec<Arc<DaemonPane>> = self.panes.lock().unwrap().values().cloned().collect();
|
||||
let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect();
|
||||
@@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) {
|
||||
crate::daemon::scrollback::forget(pane_id);
|
||||
}
|
||||
|
||||
/// Stop a pane whose tab is going to sleep, keeping its screen.
|
||||
///
|
||||
/// [`kill_pane`] with the other half of the bargain: the screen is written one
|
||||
/// last time, exactly, and left on disk rather than dropped, so the pane that
|
||||
/// wakes in this one's place opens showing it — the same restore a daemon that
|
||||
/// died unannounced gets, asked for the same way. The tree still names this
|
||||
/// pane, which is what keeps the sweeps off the file until then.
|
||||
fn hibernate_pane(registry: &Registry, pane_id: u64) {
|
||||
let Some(pane) = registry.remove(pane_id) else {
|
||||
return;
|
||||
};
|
||||
let (segments, title, _) = pane.scrollback_snapshot();
|
||||
crate::daemon::scrollback::save(pane_id, &segments, title.as_deref());
|
||||
pane.kill();
|
||||
}
|
||||
|
||||
fn ssh_connection_for(
|
||||
registry: &Registry,
|
||||
pane_id: u64,
|
||||
@@ -1035,6 +1055,31 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
ClientMsg::SetForwardEnabled {
|
||||
pane_id,
|
||||
forward_id,
|
||||
enabled,
|
||||
} => {
|
||||
let mut w = write_stream;
|
||||
// Only switching on needs the connection; switching off has to
|
||||
// work on a pane whose connection is already gone.
|
||||
let conn = match forward_pane_connection(®istry, pane_id) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(e) if enabled => {
|
||||
DaemonMsg::Error(e).encode(&mut w)?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
match crate::daemon::ssh::SshManager::global()
|
||||
.set_forward_enabled(pane_id, conn, forward_id, enabled)
|
||||
{
|
||||
Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?,
|
||||
Err(e) => DaemonMsg::Error(e).encode(&mut w)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
ClientMsg::QueryProcs { pane_id } => {
|
||||
let mut w = write_stream;
|
||||
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
|
||||
|
||||
@@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
|
||||
))))
|
||||
}
|
||||
|
||||
/// What a switched-off forward reports. An `Error` for the same reason as
|
||||
/// `loop_exit_status`, so an older peer reading it draws a stopped forward
|
||||
/// with a word of explanation; a current one reads `enabled` instead.
|
||||
fn disabled_status() -> SharedStatus {
|
||||
Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string())))
|
||||
}
|
||||
|
||||
/// Whether two forwards would want the same listening socket: the same port,
|
||||
/// on the same side of the connection. Local and dynamic forwards both listen
|
||||
/// here; a remote forward listens on the far host.
|
||||
fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool {
|
||||
let far = |k| k == SshForwardKind::Remote;
|
||||
a_port != 0 && a_port == b_port && far(a) == far(b)
|
||||
}
|
||||
|
||||
struct ForwardEntry {
|
||||
id: u64,
|
||||
kind: SshForwardKind,
|
||||
bind_host: String,
|
||||
bind_port: u16,
|
||||
/// The port the rule asked for, which `bind_port` is not when that was 0
|
||||
/// and the OS picked one. Switching the forward back on asks again for
|
||||
/// this, not for whatever it happened to get last time.
|
||||
requested_port: u16,
|
||||
target_host: String,
|
||||
target_port: u16,
|
||||
description: Option<String>,
|
||||
status: SharedStatus,
|
||||
cancel: ForwardCancel,
|
||||
auto_local: bool,
|
||||
/// Switched off: the entry, and the rule in it, stay listed, but nothing
|
||||
/// is bound and `cancel` is `None`.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
@@ -288,6 +310,19 @@ impl ForwardEntry {
|
||||
target_port: self.target_port,
|
||||
description: self.description.clone(),
|
||||
status: self.status.lock().unwrap().clone(),
|
||||
enabled: self.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn rule(&self) -> SshForwardRule {
|
||||
SshForwardRule {
|
||||
kind: self.kind,
|
||||
bind_host: self.bind_host.clone(),
|
||||
bind_port: self.requested_port,
|
||||
target_host: self.target_host.clone(),
|
||||
target_port: self.target_port,
|
||||
description: self.description.clone(),
|
||||
enabled: self.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +353,23 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
conn: Option<Arc<SshConnection>>,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> Result<Vec<ManagedForward>, String> {
|
||||
self.set_enabled_owned(
|
||||
&ForwardOwner::Pane(pane_id),
|
||||
pane_id,
|
||||
conn,
|
||||
forward_id,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn teardown_pane(&self, pane_id: u64) {
|
||||
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
|
||||
}
|
||||
@@ -347,6 +399,24 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_enabled_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> Result<Vec<ManagedForward>, String> {
|
||||
self.set_enabled_owned(
|
||||
&ForwardOwner::Workspace(workspace),
|
||||
view_pane,
|
||||
Some(conn),
|
||||
forward_id,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
|
||||
self.teardown_owned(&ForwardOwner::Workspace(workspace))
|
||||
.await;
|
||||
@@ -360,22 +430,24 @@ impl SshForwardRegistry {
|
||||
rule: &SshForwardRule,
|
||||
) -> ManagedForward {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (bind_port, status, cancel) = match rule.kind {
|
||||
SshForwardKind::Local => self.start_local(&conn, rule).await,
|
||||
SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await,
|
||||
SshForwardKind::Remote => self.start_remote(&conn, rule).await,
|
||||
let (bind_port, status, cancel) = if rule.enabled {
|
||||
self.start(&conn, rule).await
|
||||
} else {
|
||||
(rule.bind_port, disabled_status(), ForwardCancel::None)
|
||||
};
|
||||
let entry = ForwardEntry {
|
||||
id,
|
||||
kind: rule.kind,
|
||||
bind_host: rule.bind_host.clone(),
|
||||
bind_port,
|
||||
requested_port: rule.bind_port,
|
||||
target_host: rule.target_host.clone(),
|
||||
target_port: rule.target_port,
|
||||
description: rule.description.clone(),
|
||||
status,
|
||||
cancel,
|
||||
auto_local: false,
|
||||
enabled: rule.enabled,
|
||||
};
|
||||
let managed = entry.to_managed(view_pane);
|
||||
self.owners
|
||||
@@ -413,20 +485,145 @@ impl SshForwardRegistry {
|
||||
})
|
||||
};
|
||||
if let Some(entry) = removed {
|
||||
Self::cancel_entry(entry).await;
|
||||
Self::cancel(entry.cancel).await;
|
||||
}
|
||||
self.list_owned(owner, view_pane)
|
||||
}
|
||||
|
||||
/// Switch one forward off or back on, keeping its entry — and so its
|
||||
/// place in the list and the rule it was made from — either way.
|
||||
///
|
||||
/// Switching on refuses, rather than fails at bind time with whatever the
|
||||
/// OS says, when another switched-on forward of the same owner already
|
||||
/// holds the port: the answer then names that forward, which is the one
|
||||
/// the user has to switch off first. A forward that cannot be started for
|
||||
/// any other reason stays off, with the reason as the error.
|
||||
async fn set_enabled_owned(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
view_pane: u64,
|
||||
conn: Option<Arc<SshConnection>>,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> Result<Vec<ManagedForward>, String> {
|
||||
if !enabled {
|
||||
let cancel = {
|
||||
let mut owners = self.owners.lock().unwrap();
|
||||
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
|
||||
entry.enabled = false;
|
||||
entry.status = disabled_status();
|
||||
std::mem::replace(&mut entry.cancel, ForwardCancel::None)
|
||||
};
|
||||
Self::cancel(cancel).await;
|
||||
return Ok(self.list_owned(owner, view_pane));
|
||||
}
|
||||
|
||||
let Some(rule) = self.enable_plan(owner, forward_id)? else {
|
||||
return Ok(self.list_owned(owner, view_pane));
|
||||
};
|
||||
let Some(conn) = conn else {
|
||||
return Err("the SSH connection is gone — reconnect and try again".to_string());
|
||||
};
|
||||
let (bound, status, cancel) = self.start(&conn, &rule).await;
|
||||
if let Some(e) = bind_error(&status) {
|
||||
Self::cancel(cancel).await;
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) {
|
||||
// Removed while it was being started: nobody wants what was bound.
|
||||
Self::cancel(cancel).await;
|
||||
}
|
||||
Ok(self.list_owned(owner, view_pane))
|
||||
}
|
||||
|
||||
fn entry_mut<'a>(
|
||||
owners: &'a mut HashMap<ForwardOwner, Vec<ForwardEntry>>,
|
||||
owner: &ForwardOwner,
|
||||
forward_id: u64,
|
||||
) -> Result<&'a mut ForwardEntry, String> {
|
||||
owners
|
||||
.get_mut(owner)
|
||||
.and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id))
|
||||
.ok_or_else(|| format!("no forward {forward_id}"))
|
||||
}
|
||||
|
||||
/// The rule to start for switching `forward_id` on, `None` when it is on
|
||||
/// already, or why it may not be switched on.
|
||||
fn enable_plan(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
forward_id: u64,
|
||||
) -> Result<Option<SshForwardRule>, String> {
|
||||
let mut owners = self.owners.lock().unwrap();
|
||||
let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
|
||||
if entry.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let rule = SshForwardRule {
|
||||
enabled: true,
|
||||
..entry.rule()
|
||||
};
|
||||
let holder = owners.get(owner).into_iter().flatten().find(|e| {
|
||||
e.id != forward_id
|
||||
&& e.enabled
|
||||
&& same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port)
|
||||
});
|
||||
if let Some(h) = holder {
|
||||
let what = match h.kind {
|
||||
SshForwardKind::Dynamic => "a SOCKS forward".to_string(),
|
||||
_ => format!("the forward to {}:{}", h.target_host, h.target_port),
|
||||
};
|
||||
return Err(format!(
|
||||
"port {} is already used by {what}; switch that one off first",
|
||||
rule.bind_port
|
||||
));
|
||||
}
|
||||
Ok(Some(rule))
|
||||
}
|
||||
|
||||
/// Record a started forward as switched on. Hands `cancel` back when the
|
||||
/// entry is no longer there to take it.
|
||||
fn finish_enable(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
forward_id: u64,
|
||||
bound: u16,
|
||||
status: SharedStatus,
|
||||
cancel: ForwardCancel,
|
||||
) -> Option<ForwardCancel> {
|
||||
let mut owners = self.owners.lock().unwrap();
|
||||
let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else {
|
||||
return Some(cancel);
|
||||
};
|
||||
entry.enabled = true;
|
||||
entry.bind_port = bound;
|
||||
entry.status = status;
|
||||
// Anything already in the slot is a second switch-on that raced this
|
||||
// one; it is replaced, and has to be stopped rather than leaked.
|
||||
Some(std::mem::replace(&mut entry.cancel, cancel))
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
conn: &Arc<SshConnection>,
|
||||
rule: &SshForwardRule,
|
||||
) -> (u16, SharedStatus, ForwardCancel) {
|
||||
match rule.kind {
|
||||
SshForwardKind::Local => self.start_local(conn, rule).await,
|
||||
SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await,
|
||||
SshForwardKind::Remote => self.start_remote(conn, rule).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn teardown_owned(&self, owner: &ForwardOwner) {
|
||||
let entries = self.owners.lock().unwrap().remove(owner);
|
||||
for entry in entries.into_iter().flatten() {
|
||||
Self::cancel_entry(entry).await;
|
||||
Self::cancel(entry.cancel).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_entry(entry: ForwardEntry) {
|
||||
match entry.cancel {
|
||||
async fn cancel(cancel: ForwardCancel) {
|
||||
match cancel {
|
||||
ForwardCancel::Task(handle) => {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
@@ -634,6 +831,7 @@ impl SshForwardRegistry {
|
||||
target_host: remote_host.to_string(),
|
||||
target_port: remote_port,
|
||||
description: Some(format!("localhost link → :{remote_port}")),
|
||||
enabled: true,
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await;
|
||||
@@ -658,12 +856,14 @@ impl SshForwardRegistry {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: rule.bind_host.clone(),
|
||||
bind_port,
|
||||
requested_port: rule.bind_port,
|
||||
target_host: rule.target_host.clone(),
|
||||
target_port: rule.target_port,
|
||||
description: rule.description.clone(),
|
||||
status,
|
||||
cancel,
|
||||
auto_local: true,
|
||||
enabled: true,
|
||||
};
|
||||
self.owners
|
||||
.lock()
|
||||
@@ -735,6 +935,20 @@ impl SshManager {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_workspace_forward_enabled(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
view_pane: u64,
|
||||
conn: Arc<SshConnection>,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> Result<Vec<ManagedForward>, String> {
|
||||
self.runtime.block_on(
|
||||
self.forwards
|
||||
.set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_workspace_forwards(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -941,12 +1155,14 @@ mod tests {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: port,
|
||||
requested_port: port,
|
||||
target_host: "h".into(),
|
||||
target_port: 80,
|
||||
description: None,
|
||||
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
|
||||
cancel: ForwardCancel::Task(task),
|
||||
auto_local: false,
|
||||
enabled: true,
|
||||
}
|
||||
};
|
||||
{
|
||||
@@ -986,12 +1202,14 @@ mod tests {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: port,
|
||||
requested_port: port,
|
||||
target_host: "h".into(),
|
||||
target_port: 80,
|
||||
description: None,
|
||||
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
|
||||
cancel: ForwardCancel::Task(handle),
|
||||
auto_local: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,12 +1265,14 @@ mod tests {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: port,
|
||||
requested_port: port,
|
||||
target_host: "127.0.0.1".into(),
|
||||
target_port: 3000,
|
||||
description: None,
|
||||
status: Arc::new(Mutex::new(ForwardStatus::Listening)),
|
||||
cancel: ForwardCancel::Task(handle),
|
||||
auto_local: true,
|
||||
enabled: true,
|
||||
};
|
||||
reg.owners
|
||||
.lock()
|
||||
@@ -1143,4 +1363,170 @@ mod tests {
|
||||
);
|
||||
assert_eq!(table.lookup("", 0), None);
|
||||
}
|
||||
|
||||
fn push_disabled(
|
||||
reg: &SshForwardRegistry,
|
||||
owner: ForwardOwner,
|
||||
id: u64,
|
||||
kind: SshForwardKind,
|
||||
port: u16,
|
||||
) {
|
||||
reg.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(owner)
|
||||
.or_default()
|
||||
.push(ForwardEntry {
|
||||
id,
|
||||
kind,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: port,
|
||||
requested_port: port,
|
||||
target_host: "srv-b".into(),
|
||||
target_port: 80,
|
||||
description: None,
|
||||
status: disabled_status(),
|
||||
cancel: ForwardCancel::None,
|
||||
auto_local: false,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Switching a forward off has to release what it bound — that is the
|
||||
/// point, so the port can be taken by another rule — while leaving the
|
||||
/// entry, and the rule in it, exactly where it was.
|
||||
#[tokio::test]
|
||||
async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
let guard = push_listener(®, ForwardOwner::Pane(7), 3).await;
|
||||
let port = reg.list(7)[0].bind_port;
|
||||
|
||||
let list = reg.set_enabled(7, None, 3, false).await.unwrap();
|
||||
assert_eq!(list.len(), 1, "the forward is still listed");
|
||||
assert_eq!(list[0].id, 3);
|
||||
assert!(!list[0].enabled);
|
||||
assert_eq!(list[0].bind_port, port);
|
||||
assert_eq!(
|
||||
(list[0].target_host.as_str(), list[0].target_port),
|
||||
("127.0.0.1", 3000)
|
||||
);
|
||||
assert!(matches!(list[0].status, ForwardStatus::Error(_)));
|
||||
assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone");
|
||||
TcpListener::bind(("127.0.0.1", port))
|
||||
.await
|
||||
.expect("the port is free for someone else");
|
||||
|
||||
// Twice is the same as once, and a forward nobody has is an error.
|
||||
assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list);
|
||||
assert!(reg.set_enabled(7, None, 99, false).await.is_err());
|
||||
|
||||
reg.teardown_pane(7).await;
|
||||
assert!(reg.list(7).is_empty());
|
||||
}
|
||||
|
||||
/// Two rules on one local port, pointed at different targets, is the
|
||||
/// whole use of the switch. Turning the second on while the first still
|
||||
/// holds the port is refused by name, not left to fail at bind time.
|
||||
#[tokio::test]
|
||||
async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
push_listener(®, ForwardOwner::Pane(7), 0).await;
|
||||
let port = reg.list(7)[0].bind_port;
|
||||
push_disabled(®, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port);
|
||||
|
||||
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
|
||||
assert!(err.contains(&port.to_string()), "{err}");
|
||||
assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}");
|
||||
assert!(!reg.list(7)[1].enabled, "the refused forward stays off");
|
||||
|
||||
// A SOCKS forward listens here too, so it collides the same way.
|
||||
push_disabled(
|
||||
®,
|
||||
ForwardOwner::Pane(7),
|
||||
2,
|
||||
SshForwardKind::Dynamic,
|
||||
port,
|
||||
);
|
||||
assert!(reg.set_enabled(7, None, 2, true).await.is_err());
|
||||
|
||||
// Once the holder is off, the only thing left in the way is the
|
||||
// missing connection — which is said, and changes nothing.
|
||||
reg.set_enabled(7, None, 0, false).await.unwrap();
|
||||
let err = reg.set_enabled(7, None, 1, true).await.unwrap_err();
|
||||
assert!(err.contains("connection"), "{err}");
|
||||
assert!(reg.list(7).iter().all(|m| !m.enabled));
|
||||
|
||||
// Another owner's forwards are not this one's business.
|
||||
push_disabled(®, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port);
|
||||
assert!(
|
||||
reg.enable_plan(&ForwardOwner::Pane(8), 0)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_same_port_on_the_same_side_collides() {
|
||||
use SshForwardKind::{Dynamic, Local, Remote};
|
||||
assert!(same_socket(Local, 8080, Local, 8080));
|
||||
assert!(same_socket(Local, 8080, Dynamic, 8080));
|
||||
assert!(same_socket(Remote, 8080, Remote, 8080));
|
||||
assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts");
|
||||
assert!(!same_socket(Local, 8080, Local, 8081));
|
||||
assert!(
|
||||
!same_socket(Local, 0, Local, 0),
|
||||
"0 asks the OS for a fresh one"
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching back on asks for the port the rule asked for, not the one
|
||||
/// the OS last picked for it, and records what it got this time.
|
||||
#[tokio::test]
|
||||
async fn switching_on_starts_from_the_rule_and_records_the_new_listener() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
push_disabled(®, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0);
|
||||
reg.owners
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&ForwardOwner::Pane(7))
|
||||
.unwrap()[0]
|
||||
.bind_port = 41000;
|
||||
|
||||
let rule = reg
|
||||
.enable_plan(&ForwardOwner::Pane(7), 0)
|
||||
.unwrap()
|
||||
.expect("an off forward has something to start");
|
||||
assert_eq!(rule.bind_port, 0);
|
||||
assert!(rule.enabled);
|
||||
assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80));
|
||||
|
||||
let guard = Arc::new(());
|
||||
let held = guard.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let _held = held;
|
||||
std::future::pending::<()>().await
|
||||
});
|
||||
let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening));
|
||||
let leftover = reg.finish_enable(
|
||||
&ForwardOwner::Pane(7),
|
||||
0,
|
||||
42000,
|
||||
status,
|
||||
ForwardCancel::Task(task),
|
||||
);
|
||||
assert!(matches!(leftover, Some(ForwardCancel::None)));
|
||||
let now = ®.list(7)[0];
|
||||
assert!(now.enabled);
|
||||
assert_eq!(now.bind_port, 42000);
|
||||
assert_eq!(now.status, ForwardStatus::Listening);
|
||||
assert!(
|
||||
reg.enable_plan(&ForwardOwner::Pane(7), 0)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"an on forward has nothing to start"
|
||||
);
|
||||
|
||||
reg.teardown_pane(7).await;
|
||||
assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,19 @@ impl SshManager {
|
||||
.block_on(self.forwards.remove(pane_id, forward_id))
|
||||
}
|
||||
|
||||
pub fn set_forward_enabled(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
conn: Option<Arc<SshConnection>>,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> Result<Vec<ManagedForward>, String> {
|
||||
self.runtime.block_on(
|
||||
self.forwards
|
||||
.set_enabled(pane_id, conn, forward_id, enabled),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_forwards(&self, pane_id: u64) -> Vec<ManagedForward> {
|
||||
self.forwards.list(pane_id)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
|
||||
WorkspaceOp::RemoveForward { forward_id } => {
|
||||
DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id))
|
||||
}
|
||||
WorkspaceOp::SetForwardEnabled {
|
||||
forward_id,
|
||||
enabled,
|
||||
} => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) {
|
||||
Ok(list) => DaemonMsg::ForwardList(list),
|
||||
Err(e) => DaemonMsg::Error(e),
|
||||
},
|
||||
WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)),
|
||||
WorkspaceOp::TeardownForwards => {
|
||||
mgr.teardown_workspace_forwards(ws);
|
||||
|
||||
@@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync {
|
||||
/// is this process and not the client's own daemon: the client asks over
|
||||
/// the control link precisely because the processes are here.
|
||||
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs;
|
||||
/// Stop a pane for a tab going to sleep: the processes go, the pane's last
|
||||
/// screen stays on disk for the wake to open with. Unlike closing a pane,
|
||||
/// which drops that screen because nobody will ask for it again.
|
||||
fn hibernate_pane(&self, pane_id: u64);
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -383,6 +387,12 @@ fn handshake<R: Read>(
|
||||
if services.machine.is_some() {
|
||||
features.push(feature::MACHINE_TREE.to_string());
|
||||
}
|
||||
// A sleeping tab is a mark in the tree *and* stopped panes; a peer that can
|
||||
// do only half of that would leave a tab asleep with its shells running,
|
||||
// or its shells stopped with nothing saying the tab is meant to wake.
|
||||
if services.machine.is_some() && services.panes.is_some() {
|
||||
features.push(feature::TAB_HIBERNATE.to_string());
|
||||
}
|
||||
// The pane daemon's features ride along, same as `protocol_version` above:
|
||||
// a pane connection answers exactly one message, so a client that wanted
|
||||
// them from `ClientMsg::Version` would pay a whole routed connection — for
|
||||
@@ -783,6 +793,34 @@ fn run_request(
|
||||
.workspace_set_groups(workspace, groups, conn.machine_origin)?;
|
||||
(ReplyOk::Unit, Vec::new())
|
||||
}
|
||||
ControlRequest::TabSetHibernated {
|
||||
workspace,
|
||||
tab,
|
||||
hibernated,
|
||||
} => {
|
||||
let Some(directory) = conn.panes.as_ref() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"this peer serves no panes, so it cannot put a tab to sleep",
|
||||
));
|
||||
};
|
||||
let panes = conn.machine()?.tab_set_hibernated(
|
||||
workspace,
|
||||
tab,
|
||||
hibernated,
|
||||
conn.machine_origin,
|
||||
)?;
|
||||
// After the mark, not before: a pane stopped under a tab the tree
|
||||
// does not yet call asleep is, to anyone reading the tree in
|
||||
// between, a pane that died — and a restore reading it that way
|
||||
// would spawn it straight back.
|
||||
if hibernated {
|
||||
for pane in &panes {
|
||||
directory.hibernate_pane(*pane);
|
||||
}
|
||||
}
|
||||
(ReplyOk::Panes(panes), Vec::new())
|
||||
}
|
||||
ControlRequest::PaneSplit {
|
||||
workspace,
|
||||
pane,
|
||||
@@ -1722,6 +1760,8 @@ mod aggregate_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn hibernate_pane(&self, _pane_id: u64) {}
|
||||
|
||||
fn agent_states(&self) -> Vec<PaneAgentState> {
|
||||
vec![PaneAgentState {
|
||||
pane_id: 7,
|
||||
@@ -1902,6 +1942,151 @@ mod aggregate_tests {
|
||||
"whatever links exist are named; none are blank"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records which panes were stopped for a sleeping tab, and in what order
|
||||
/// relative to the tree: each entry carries whether the tree already
|
||||
/// called the tab asleep when the pane was stopped.
|
||||
struct SleepRecorder {
|
||||
store: Arc<MachineStore>,
|
||||
stopped: std::sync::Mutex<Vec<(u64, bool)>>,
|
||||
}
|
||||
|
||||
impl PaneDirectory for SleepRecorder {
|
||||
fn pane_count(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
fn panes(&self) -> Vec<PaneInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn agent_states(&self) -> Vec<PaneAgentState> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn hibernate_pane(&self, pane_id: u64) {
|
||||
let asleep = self.store.machine().workspaces.iter().any(|w| {
|
||||
w.tabs
|
||||
.iter()
|
||||
.any(|t| t.hibernated && t.root.contains(pane_id))
|
||||
});
|
||||
self.stopped.lock().unwrap().push((pane_id, asleep));
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(pane: u64) -> machine::PaneSeed {
|
||||
machine::PaneSeed {
|
||||
pane,
|
||||
cwd: Some("/repo".into()),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
shell: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Going to sleep stops every pane of the tab — after the tree says the
|
||||
/// tab is asleep, so nobody reading the tree in between takes the stopped
|
||||
/// panes for dead ones — and keeps them in the tree for the wake. Waking
|
||||
/// only clears the mark: the client brings the panes back.
|
||||
#[test]
|
||||
fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
|
||||
let ws = store.workspace_create(None, None, None).unwrap();
|
||||
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
|
||||
store
|
||||
.pane_split(
|
||||
ws.id,
|
||||
7,
|
||||
machine::Axis::Horizontal,
|
||||
0.5,
|
||||
seed(8),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap();
|
||||
let recorder = Arc::new(SleepRecorder {
|
||||
store: Arc::clone(&store),
|
||||
stopped: std::sync::Mutex::new(Vec::new()),
|
||||
});
|
||||
let client = client_with(Services {
|
||||
machine: Some(Arc::clone(&store)),
|
||||
attachments: Arc::new(AttachRegistry::default()),
|
||||
panes: Some(recorder.clone()),
|
||||
});
|
||||
assert!(client.hello().has_feature(feature::TAB_HIBERNATE));
|
||||
|
||||
let ReplyOk::Panes(panes) = client
|
||||
.call(ControlRequest::TabSetHibernated {
|
||||
workspace: ws.id,
|
||||
tab: tab.id,
|
||||
hibernated: true,
|
||||
})
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("TabSetHibernated must answer with the tab's panes");
|
||||
};
|
||||
assert_eq!(panes, vec![7, 8]);
|
||||
assert_eq!(
|
||||
*recorder.stopped.lock().unwrap(),
|
||||
vec![(7, true), (8, true)],
|
||||
"both panes stopped, each after the tree marked the tab"
|
||||
);
|
||||
let machine = store.machine();
|
||||
let asleep = &machine.workspaces[0].tabs;
|
||||
assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated);
|
||||
assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated);
|
||||
for pane in [7, 8] {
|
||||
assert!(
|
||||
machine.panes.iter().any(|p| p.id == pane),
|
||||
"pane {pane}'s record is what the wake restores from"
|
||||
);
|
||||
}
|
||||
|
||||
recorder.stopped.lock().unwrap().clear();
|
||||
let ReplyOk::Panes(_) = client
|
||||
.call(ControlRequest::TabSetHibernated {
|
||||
workspace: ws.id,
|
||||
tab: tab.id,
|
||||
hibernated: false,
|
||||
})
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("TabSetHibernated must answer with the tab's panes");
|
||||
};
|
||||
assert!(recorder.stopped.lock().unwrap().is_empty());
|
||||
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
|
||||
}
|
||||
|
||||
/// A peer with a tree and no panes could mark a tab asleep and leave its
|
||||
/// shells running. It does not offer to, and refuses if asked anyway —
|
||||
/// before the mark, so the tree is not left claiming a sleep that never
|
||||
/// happened.
|
||||
#[test]
|
||||
fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
|
||||
let ws = store.workspace_create(None, None, None).unwrap();
|
||||
let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap();
|
||||
let client = client_with(Services::with_machine(Arc::clone(&store)));
|
||||
assert!(client.hello().has_feature(feature::MACHINE_TREE));
|
||||
assert!(!client.hello().has_feature(feature::TAB_HIBERNATE));
|
||||
assert!(
|
||||
client
|
||||
.call(ControlRequest::TabSetHibernated {
|
||||
workspace: ws.id,
|
||||
tab: tab.id,
|
||||
hibernated: true,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(!store.machine().workspaces[0].tabs[0].hibernated);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -66,6 +66,41 @@ The key is your command's name; the value is one of the slugs above (`claude`,
|
||||
`droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`,
|
||||
`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`).
|
||||
|
||||
## Quick launch
|
||||
|
||||
Every agent tty7 finds on your `PATH` is one palette command away: **Agent:
|
||||
Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's
|
||||
**Launch Agent…** row opens the palette on that list, and **New Agent Tab**
|
||||
(<kbd>⌘ ⇧ A</kbd> on macOS, unbound elsewhere) starts whichever agent you used
|
||||
last.
|
||||
|
||||
A launch always opens a new tab in the current tab's directory and types the
|
||||
agent's command into its shell, so the agent is detected, tracked and resumed
|
||||
exactly as if you had typed it — and quitting it leaves you at the prompt. Each
|
||||
`Agent: …` command is bindable too, as `LaunchAgent:<slug>`.
|
||||
|
||||
By default an agent launches as its bare binary (`claude`, `codex`,
|
||||
`cursor-agent`, …). Give it a command line of its own with `agent_launch`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_launch": {
|
||||
"claude": "claude --dangerously-skip-permissions",
|
||||
"codex": "cx --profile work"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A wrapper named there (`cx` above) is recognised as that agent without an
|
||||
`agent_commands` entry. To keep the flags a running agent was started with,
|
||||
right-click its pane and choose **Set Current Launch Args as Default**; tty7
|
||||
writes them into `agent_launch`, minus the session they resumed and any prompt.
|
||||
|
||||
In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers
|
||||
the agents it has already seen running there: start one by hand once and it
|
||||
joins the list. A wrapper named in your local `agent_launch` is only recognised
|
||||
over there if the remote `config.json` names it too.
|
||||
|
||||
## What you get for free
|
||||
|
||||
Just by running an agent in a pane:
|
||||
|
||||
@@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` |
|
||||
| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` |
|
||||
| `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` |
|
||||
| `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` |
|
||||
| `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` |
|
||||
@@ -357,7 +357,7 @@ a stand-in.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` |
|
||||
| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag (`asleep` for a hibernated tab's panes) | `{"panes":[…]}` |
|
||||
| `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` |
|
||||
| `pane split …` | Identical to top-level `split` | `{"pane"}` |
|
||||
| `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` |
|
||||
|
||||
@@ -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 auto grouping, and diff previews.
|
||||
Tab position and placement, sidebar auto 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.
|
||||
|
||||
|
||||
@@ -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_auto_grouping` | bool | `true` | Groups unpinned tabs by repository (SSH tabs by host). Off: a flat list below the pinned groups, which show either way. Pinned groups and folds are stored with the workspace, not here. |
|
||||
| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. |
|
||||
| `sidebar_width` | number | `220` | Pixels (100–2000). |
|
||||
@@ -112,7 +113,7 @@ their id from the file name. [More about themes →](/customization/themes)
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. `args` you write are launched verbatim, which also turns off [shell integration](/reference/shell-integration) for that shell — leave them out to keep it. |
|
||||
| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. |
|
||||
| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells — in the menu's **Other Shells…** list until you have opened it, and in the palette as *Shell:* followed by its label. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. |
|
||||
| `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. |
|
||||
| `env` | object | `{}` | Extra environment variables for every pane. |
|
||||
| `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. |
|
||||
@@ -177,6 +178,8 @@ angle brackets, and a tab:
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. |
|
||||
| `agent_launch` | object | `{}` | The command line quick launch types for an agent, by slug: `{"claude": "claude --dangerously-skip-permissions"}`. Unset agents launch as their bare binary. [More →](/agents/overview#quick-launch) |
|
||||
| `agent_frecency` | object | `{}` | Written by tty7: how often and how recently each agent ran, to order quick launch. |
|
||||
| `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. |
|
||||
| `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. |
|
||||
|
||||
@@ -186,10 +189,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 +230,9 @@ 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.
|
||||
`shell_frecency` and `command_frecency` record how often and how recently you
|
||||
open a shell or run a command, so the new-tab menu and the palette can rank
|
||||
them. They are written by the app; there is no reason to edit them.
|
||||
|
||||
<Note>
|
||||
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
|
||||
|
||||
@@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default.
|
||||
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
|
||||
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
|
||||
| New Window | <kbd>⌘ N</kbd> | — |
|
||||
| New Agent Tab | <kbd>⌘ ⇧ A</kbd> | — |
|
||||
|
||||
## Panes
|
||||
|
||||
@@ -107,10 +108,10 @@ Keybindings**:
|
||||
|
||||
| Group | Actions |
|
||||
|---|---|
|
||||
| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` |
|
||||
| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` |
|
||||
| Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` |
|
||||
| Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` |
|
||||
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` |
|
||||
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:<slug>` (one per agent, e.g. `LaunchAgent:claude`) |
|
||||
| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` |
|
||||
| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` |
|
||||
| Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`.
|
||||
|
||||
These live only as long as the session unless you save them into the profile.
|
||||
|
||||
## Switching a rule off
|
||||
|
||||
Every forward in the Ports section has a switch. Off, the forward lets go of
|
||||
its port but keeps its whole rule — kind, ports, target, description — so it
|
||||
can be switched back on without retyping anything. That is how one local port
|
||||
is pointed at different places: keep one rule per target on the same port, and
|
||||
switch on the one you want.
|
||||
|
||||
Only one of them can hold the port at a time. Switching one on while another
|
||||
still has its port is refused, and the notice names the forward to switch off
|
||||
first.
|
||||
|
||||
Rules from a profile carry their switch into it, so the next connection opens
|
||||
the same set. The same switch sits beside each rule under **Settings → SSH →**
|
||||
a profile **→ Port forwarding**; a rule saved switched off is listed but not
|
||||
opened. Rules added mid-session are still gone when the session ends, on or
|
||||
off.
|
||||
|
||||
<Frame caption="The Ports section with a local forward and a dynamic (SOCKS) rule">
|
||||
<img src="/images/ports.webp" alt="The ports section" />
|
||||
</Frame>
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ Results are grouped, and the groups are the map of the app:
|
||||
|
||||
| Group | Examples |
|
||||
|---|---|
|
||||
| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
|
||||
| **Tabs & Panes** | New Tab · Shell: zsh · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
|
||||
| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… |
|
||||
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
|
||||
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,12 +30,15 @@ actions!(
|
||||
RevealLinkUnderPointer,
|
||||
CopyLinkPathUnderPointer,
|
||||
MarkTabUnread,
|
||||
HibernateTab,
|
||||
ForkAgentSession,
|
||||
ForkAgentSessionRight,
|
||||
ForkAgentSessionLeft,
|
||||
ForkAgentSessionDown,
|
||||
ForkAgentSessionUp,
|
||||
CopyAgentSessionId,
|
||||
NewAgentTab,
|
||||
SaveAgentLaunchArgs,
|
||||
SplitRight,
|
||||
SplitDown,
|
||||
FocusNextPane,
|
||||
@@ -125,3 +128,11 @@ actions!(
|
||||
Quit
|
||||
]
|
||||
);
|
||||
|
||||
/// Open a new tab running one particular coding agent. The keymap names one of
|
||||
/// these per agent (`LaunchAgent:claude`, …); see `ui::agent_launch`.
|
||||
#[derive(Clone, Debug, PartialEq, gpui::Action)]
|
||||
#[action(namespace = tty7, no_json)]
|
||||
pub struct LaunchAgent {
|
||||
pub agent: tty7_core::core::cli_agent::CLIAgent,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,24 @@ impl WorkspaceStore {
|
||||
store.views.save();
|
||||
}
|
||||
|
||||
/// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`).
|
||||
pub fn record_agent_seen(
|
||||
cx: &mut gpui::App,
|
||||
id: WorkspaceId,
|
||||
agent: tty7_core::core::cli_agent::CLIAgent,
|
||||
) {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
return;
|
||||
};
|
||||
if store
|
||||
.views
|
||||
.get_mut(id)
|
||||
.is_some_and(|view| view.saw_agent(agent.slug()))
|
||||
{
|
||||
store.views.save();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId {
|
||||
host_for(Self::all(cx), id)
|
||||
}
|
||||
|
||||
@@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
|
||||
bind,
|
||||
target,
|
||||
description: String::new(),
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,10 @@ pub struct RemoteTerminal {
|
||||
pub exited: bool,
|
||||
size: TermSize,
|
||||
synced_size: bool,
|
||||
/// This link reattached to a pane that was already running rather than
|
||||
/// spawning a fresh one, so whatever it reports first — an agent in the
|
||||
/// foreground included — predates this client.
|
||||
reattached: bool,
|
||||
/// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked
|
||||
/// alongside `size` so a display-scale change still reaches the child even
|
||||
/// when the grid dimensions are unchanged.
|
||||
@@ -1087,6 +1091,7 @@ impl RemoteTerminal {
|
||||
exited: false,
|
||||
size,
|
||||
synced_size: false,
|
||||
reattached: awaiting_replay,
|
||||
synced_cell: (0, 0),
|
||||
resize_sent_at: None,
|
||||
link,
|
||||
@@ -1878,6 +1883,11 @@ impl RemoteTerminal {
|
||||
self.remote_context.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
/// See the field: an attach to a running pane, not a fresh spawn.
|
||||
pub fn reattached(&self) -> bool {
|
||||
self.reattached
|
||||
}
|
||||
|
||||
pub fn at_prompt(&self) -> bool {
|
||||
self.shell_state
|
||||
.lock()
|
||||
@@ -2296,6 +2306,29 @@ impl RemoteTerminal {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The list after switching one forward on or off, or why it was not
|
||||
/// switched — the daemon's own refusal, which names what is in the way.
|
||||
pub fn set_forward_enabled(
|
||||
pane_id: u64,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> anyhow::Result<Vec<ManagedForward>> {
|
||||
let mut stream = connect()?;
|
||||
ClientMsg::SetForwardEnabled {
|
||||
pane_id,
|
||||
forward_id,
|
||||
enabled,
|
||||
}
|
||||
.encode(&mut stream)?;
|
||||
match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::ForwardList(list) => Ok(list),
|
||||
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unexpected reply to SetForwardEnabled: {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_forwards(pane_id: u64) -> Vec<ManagedForward> {
|
||||
fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
|
||||
let mut stream = connect()?;
|
||||
|
||||
+182
-22
@@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead};
|
||||
use crate::core::actions::{
|
||||
CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown,
|
||||
ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab,
|
||||
OpenLinkUnderPointer, RevealLinkUnderPointer, SendBackTab, SendTab, SplitDown, SplitRight,
|
||||
ToggleMaximizePane,
|
||||
OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab,
|
||||
SplitDown, SplitRight, ToggleMaximizePane,
|
||||
};
|
||||
use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode};
|
||||
use crate::core::shell_quote::quote_for_shell;
|
||||
@@ -140,6 +140,12 @@ pub struct AgentSessionChanged;
|
||||
|
||||
impl gpui::EventEmitter<AgentSessionChanged> for TerminalView {}
|
||||
|
||||
/// A coding agent started running in this pane: its foreground went from no
|
||||
/// agent to this one. What quick launch counts as the agent being used.
|
||||
pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent);
|
||||
|
||||
impl gpui::EventEmitter<AgentDetected> for TerminalView {}
|
||||
|
||||
/// A file link the user clicked, on its way to whoever can show it. The
|
||||
/// terminal resolves the path — it is the only thing that knows the pane's
|
||||
/// directory and host — and the app opens it, because the editor and the file
|
||||
@@ -419,6 +425,15 @@ pub struct TerminalView {
|
||||
running_since: Option<std::time::Instant>,
|
||||
running_title: String,
|
||||
running_agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
/// The foreground agent as of the last poll, so a new one is reported
|
||||
/// once ([`AgentDetected`]) rather than on every frame it keeps running.
|
||||
seen_agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
/// Whether an agent appearing here is news. A pane this view spawned is
|
||||
/// armed from the start; one it reattached to — an app restart, a
|
||||
/// workspace switched back to — may already be running an agent that was
|
||||
/// counted when it started, so it arms only once its shell is seen back
|
||||
/// at the prompt with no agent in front.
|
||||
agent_detection_armed: bool,
|
||||
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
|
||||
last_agent_session: (Option<String>, Option<Vec<String>>),
|
||||
agent_turn_started: Option<std::time::Instant>,
|
||||
@@ -833,15 +848,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
|
||||
@@ -1435,6 +1442,7 @@ impl TerminalView {
|
||||
.map(crate::core::config::gpui_font_features);
|
||||
let report_mouse = config.mouse_reporting;
|
||||
let prompt_editor = config.prompt_editor;
|
||||
let agent_detection_armed = !terminal.reattached();
|
||||
let mut font = gpui::font(font_family);
|
||||
font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone()));
|
||||
if let Some(features) = &font_features {
|
||||
@@ -1649,6 +1657,8 @@ impl TerminalView {
|
||||
running_since: None,
|
||||
running_title: String::new(),
|
||||
running_agent: None,
|
||||
seen_agent: None,
|
||||
agent_detection_armed,
|
||||
last_agent_status: None,
|
||||
last_agent_session: (None, None),
|
||||
agent_turn_started: None,
|
||||
@@ -1757,6 +1767,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
|
||||
@@ -2234,17 +2273,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);
|
||||
}
|
||||
@@ -3751,6 +3780,8 @@ impl TerminalView {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.poll_agent_detection(at_prompt, cx);
|
||||
|
||||
let turn_finished = self.poll_agent_status(notify_allowed, window, cx);
|
||||
|
||||
let session = self.terminal.agent_session();
|
||||
@@ -4011,6 +4042,24 @@ impl TerminalView {
|
||||
);
|
||||
}
|
||||
|
||||
/// Report an agent that has just started in this pane ([`AgentDetected`]),
|
||||
/// once per start, and never one that was already running when this view
|
||||
/// reattached to the pane.
|
||||
fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context<Self>) {
|
||||
let agent = self.terminal.foreground_agent();
|
||||
if agent != self.seen_agent {
|
||||
self.seen_agent = agent;
|
||||
if let Some(agent) = agent
|
||||
&& self.agent_detection_armed
|
||||
{
|
||||
cx.emit(AgentDetected(agent));
|
||||
}
|
||||
}
|
||||
if agent.is_none() && at_prompt {
|
||||
self.agent_detection_armed = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_agent_status(
|
||||
&mut self,
|
||||
notify_allowed: bool,
|
||||
@@ -7305,6 +7354,12 @@ impl Render for TerminalView {
|
||||
let fork_ready = can_fork
|
||||
&& view.remote_context().is_none()
|
||||
&& view.agent_session().is_some_and(|s| s.session_id.is_some());
|
||||
// `None` over a pane with no agent; otherwise whether it knows
|
||||
// the command line it was started with.
|
||||
let launch_argv_known = view.agent().map(|_| {
|
||||
view.agent_session()
|
||||
.is_some_and(|s| s.launch_argv.is_some())
|
||||
});
|
||||
|
||||
let menu = match (can_fork, fork_ready) {
|
||||
(true, true) => {
|
||||
@@ -7338,6 +7393,21 @@ impl Render for TerminalView {
|
||||
(false, _) => menu,
|
||||
};
|
||||
|
||||
// Only over a running agent, and only live when the pane knows
|
||||
// the command line it was started with — there is nothing to
|
||||
// save otherwise. Beside Fork when both are there.
|
||||
let menu = match launch_argv_known {
|
||||
Some(known) => {
|
||||
let menu = if can_fork { menu } else { menu.separator() };
|
||||
menu.menu_with_disabled(
|
||||
t(L10nKey::AppMenuSaveAgentLaunchArgs),
|
||||
Box::new(SaveAgentLaunchArgs),
|
||||
!known,
|
||||
)
|
||||
}
|
||||
None => menu,
|
||||
};
|
||||
|
||||
menu.separator()
|
||||
.menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight))
|
||||
.menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown))
|
||||
@@ -16208,6 +16278,96 @@ mod gpui_tests {
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Quick launch counts an agent each time one starts. Reattaching to a pane
|
||||
/// whose agent was already running — every agent tab after an app restart
|
||||
/// — is not a start, and counting it bumped every agent once per launch of
|
||||
/// the app. Once that pane's agent exits, the next one is a start again.
|
||||
#[gpui::test]
|
||||
fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) {
|
||||
use crate::core::cli_agent::CLIAgent;
|
||||
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let (window, _root_daemon) = harness(cx);
|
||||
let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let pane = |reattached: bool, cx: &mut TestAppContext| {
|
||||
let (pane, daemon) = window
|
||||
.update(cx, |_, window, cx| {
|
||||
if reattached {
|
||||
super::quiet_reattached_test_pane(2, window, cx)
|
||||
} else {
|
||||
super::quiet_test_pane(3, window, cx)
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let seen = detected.clone();
|
||||
cx.update(|cx| {
|
||||
cx.subscribe(&pane, move |_, ev: &AgentDetected, _| {
|
||||
seen.borrow_mut().push(ev.0)
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
(pane, daemon)
|
||||
};
|
||||
let report = |agent: Option<CLIAgent>,
|
||||
at_prompt: bool,
|
||||
pane: &Entity<TerminalView>,
|
||||
daemon: &mut Stream,
|
||||
cx: &mut TestAppContext| {
|
||||
DaemonMsg::Agent(agent).encode(daemon).unwrap();
|
||||
for _ in 0..200 {
|
||||
if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx));
|
||||
cx.run_until_parked();
|
||||
};
|
||||
|
||||
let (restored, mut restored_daemon) = pane(true, cx);
|
||||
report(
|
||||
Some(CLIAgent::Claude),
|
||||
false,
|
||||
&restored,
|
||||
&mut restored_daemon,
|
||||
cx,
|
||||
);
|
||||
report(
|
||||
Some(CLIAgent::Claude),
|
||||
false,
|
||||
&restored,
|
||||
&mut restored_daemon,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
*detected.borrow(),
|
||||
vec![],
|
||||
"already running when reattached"
|
||||
);
|
||||
report(None, true, &restored, &mut restored_daemon, cx);
|
||||
report(
|
||||
Some(CLIAgent::Codex),
|
||||
false,
|
||||
&restored,
|
||||
&mut restored_daemon,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
*detected.borrow(),
|
||||
vec![CLIAgent::Codex],
|
||||
"started after the reattach"
|
||||
);
|
||||
|
||||
let (fresh, mut fresh_daemon) = pane(false, cx);
|
||||
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
|
||||
report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx);
|
||||
assert_eq!(
|
||||
*detected.borrow(),
|
||||
vec![CLIAgent::Codex, CLIAgent::Claude],
|
||||
"a spawned pane counts its first agent, once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window between the shell reporting a prompt and its line editor
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Quick launch for the coding agents this machine can run (#955).
|
||||
//!
|
||||
//! Launching an agent never types into a pane that already exists. It opens a
|
||||
//! new shell — a tab in the active tab's directory, or a split when the entry
|
||||
//! point was asked for one — and types the agent's command line into *that*
|
||||
//! shell once it is there. The agent then runs the way a hand-typed one does:
|
||||
//! the daemon detects it from the pane's foreground, status and resume work as
|
||||
//! usual, and quitting it leaves the shell behind.
|
||||
//!
|
||||
//! Which agents are offered: on this computer, those whose launch program is
|
||||
//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can
|
||||
//! answer — it has no way to run a command or read the far shell's
|
||||
//! environment — so there the list is the agents seen running in that
|
||||
//! workspace before (`WindowView::seen_agents`). Either way the order is
|
||||
//! frecency, recorded the way saved SSH hosts' is.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use gpui::{App, Axis, Context, Window};
|
||||
use gpui_component::WindowExt as _;
|
||||
|
||||
use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path};
|
||||
use crate::core::config::{Config, ProfileUsage, unix_now};
|
||||
use crate::core::session::WorkspaceStore;
|
||||
use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args};
|
||||
use crate::ui::i18n::{L10nKey, t_fmt};
|
||||
use crate::ui::pane::PaneSlot;
|
||||
|
||||
/// What the New Tab menu's "Launch Agent…" row types into the palette: every
|
||||
/// quick-launch command is titled `Agent: {name}` (the same word in every
|
||||
/// language we ship), so this lands on the list with the cursor ready to
|
||||
/// narrow it — the same seam `PALETTE_SSH_QUERY` is for hosts.
|
||||
pub(crate) const PALETTE_AGENT_QUERY: &str = "agent";
|
||||
|
||||
/// How the keymap spells "launch this agent": `LaunchAgent:claude`.
|
||||
const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:";
|
||||
|
||||
/// One keymap action name per agent, in [`CLIAgent::ALL`] order.
|
||||
pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] {
|
||||
static NAMES: LazyLock<Vec<(CLIAgent, String)>> = LazyLock::new(|| {
|
||||
CLIAgent::ALL
|
||||
.into_iter()
|
||||
.map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug())))
|
||||
.collect()
|
||||
});
|
||||
&NAMES
|
||||
}
|
||||
|
||||
pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str {
|
||||
launch_action_names()
|
||||
.iter()
|
||||
.find(|(a, _)| *a == agent)
|
||||
.map(|(_, name)| name.as_str())
|
||||
.expect("every agent has a launch action")
|
||||
}
|
||||
|
||||
pub(crate) fn agent_for_launch_action(action: &str) -> Option<CLIAgent> {
|
||||
action
|
||||
.strip_prefix(LAUNCH_ACTION_PREFIX)
|
||||
.and_then(CLIAgent::from_slug)
|
||||
}
|
||||
|
||||
/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order.
|
||||
/// An agent with an `agent_launch` override is looked up by the override's
|
||||
/// program, since that is what the launch will run.
|
||||
pub(crate) fn installed_on(
|
||||
path: &std::ffi::OsStr,
|
||||
overrides: &HashMap<String, String>,
|
||||
) -> Vec<CLIAgent> {
|
||||
CLIAgent::ALL
|
||||
.into_iter()
|
||||
.filter(|agent| {
|
||||
launch_program(&agent.launch_command(overrides))
|
||||
.is_some_and(|program| program_on_path(&program, path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `agents` most-used-first by frecency score; agents with the same score
|
||||
/// keep the order they came in.
|
||||
pub(crate) fn by_frecency(
|
||||
agents: impl IntoIterator<Item = CLIAgent>,
|
||||
usage: &HashMap<String, ProfileUsage>,
|
||||
now: u64,
|
||||
) -> Vec<CLIAgent> {
|
||||
let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0);
|
||||
let mut agents: Vec<CLIAgent> = agents.into_iter().collect();
|
||||
agents.sort_by(|a, b| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
agents
|
||||
}
|
||||
|
||||
/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen
|
||||
/// most recently; the first offered when none has been used yet.
|
||||
pub(crate) fn most_recent(
|
||||
offered: &[CLIAgent],
|
||||
usage: &HashMap<String, ProfileUsage>,
|
||||
) -> Option<CLIAgent> {
|
||||
let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used);
|
||||
offered
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|agent| last_used(agent) > 0)
|
||||
// `max_by_key` keeps the last of equals; reversed, that is the first.
|
||||
.rev()
|
||||
.max_by_key(last_used)
|
||||
.or_else(|| offered.first().copied())
|
||||
}
|
||||
|
||||
/// The `agent_launch` line "Set Current Launch Args as Default" writes for a
|
||||
/// pane that `agent` is running in with `argv`.
|
||||
pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option<String> {
|
||||
agent
|
||||
.launch_argv_for_default(argv)
|
||||
.map(|argv| join_shell_args(&argv))
|
||||
}
|
||||
|
||||
/// Make `line` the command `agent` is launched with from now on.
|
||||
pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) {
|
||||
// One entry per agent, whatever case an existing key was written in.
|
||||
cfg.agent_launch
|
||||
.retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent));
|
||||
cfg.agent_launch.insert(agent.slug().to_string(), line);
|
||||
}
|
||||
|
||||
/// Type `command` into the shell `slot` holds — now if it is up, or the
|
||||
/// moment it lands if it is still connecting.
|
||||
fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) {
|
||||
match slot {
|
||||
PaneSlot::Ready(view) => view.read(cx).run_command_line(&command),
|
||||
PaneSlot::Connecting(pending) => {
|
||||
pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The agents this window's quick launch offers, most likely first.
|
||||
pub(crate) fn offered_agents(&self, cx: &App) -> Vec<CLIAgent> {
|
||||
let cfg = cx.global::<Config>();
|
||||
let candidates = match WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.filter(|view| view.is_remote())
|
||||
{
|
||||
Some(view) => view
|
||||
.seen_agents
|
||||
.iter()
|
||||
.filter_map(|slug| CLIAgent::from_slug(slug))
|
||||
.collect(),
|
||||
None => installed_on(
|
||||
&std::env::var_os("PATH").unwrap_or_default(),
|
||||
&cfg.agent_launch,
|
||||
),
|
||||
};
|
||||
by_frecency(candidates, &cfg.agent_frecency, unix_now())
|
||||
}
|
||||
|
||||
/// Open a new shell for `agent` and start it there.
|
||||
pub(crate) fn launch_agent(
|
||||
&mut self,
|
||||
agent: CLIAgent,
|
||||
at: SpawnWhere,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let command = agent.launch_command(&cx.global::<Config>().agent_launch);
|
||||
let slot = match at {
|
||||
SpawnWhere::NewTab => {
|
||||
let cwd = self.tabs.get(self.active).and_then(|t| {
|
||||
t.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
|
||||
});
|
||||
self.new_tab_slot(cwd, None, window, cx)
|
||||
}
|
||||
SpawnWhere::Split => {
|
||||
self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx)
|
||||
}
|
||||
};
|
||||
// Nothing opened (the spawn failed, or this workspace cannot host a
|
||||
// shell right now), and the reason is already on screen. The command
|
||||
// goes nowhere rather than into whatever pane was focused before.
|
||||
let Some(slot) = slot else {
|
||||
log::warn!("no pane opened for {}; not launching it", agent.slug());
|
||||
return;
|
||||
};
|
||||
run_when_ready(&slot, command, cx);
|
||||
// Recency only: the run is counted when the pane reports the agent
|
||||
// actually running, so a launch and its detection are one use, not two.
|
||||
self.update_config(cx, |cfg| {
|
||||
cfg.agent_frecency
|
||||
.entry(agent.slug().to_string())
|
||||
.or_default()
|
||||
.last_used = unix_now();
|
||||
});
|
||||
}
|
||||
|
||||
/// "New Agent Tab": launch the agent used most recently.
|
||||
pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let offered = self.offered_agents(cx);
|
||||
match most_recent(&offered, &cx.global::<Config>().agent_frecency) {
|
||||
Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx),
|
||||
None => {
|
||||
let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some();
|
||||
window.push_notification(
|
||||
crate::ui::i18n::t(if remote {
|
||||
L10nKey::AppNoAgentSeenHere
|
||||
} else {
|
||||
L10nKey::AppNoAgentOnPath
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pane in this window started running `agent`.
|
||||
pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| {
|
||||
let entry = cfg
|
||||
.agent_frecency
|
||||
.entry(agent.slug().to_string())
|
||||
.or_default();
|
||||
entry.count = entry.count.saturating_add(1);
|
||||
entry.last_used = unix_now();
|
||||
});
|
||||
if WorkspaceStore::remote_ref(cx, self.workspace).is_some() {
|
||||
WorkspaceStore::record_agent_seen(cx, self.workspace, agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Set Current Launch Args as Default": make the focused pane's agent
|
||||
/// launch the way this one was started.
|
||||
pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(view) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let view = view.read(cx);
|
||||
let Some(agent) = view.agent() else {
|
||||
window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx);
|
||||
return;
|
||||
};
|
||||
let name = agent.display_name();
|
||||
let Some(line) = view
|
||||
.agent_session()
|
||||
.and_then(|s| s.launch_argv)
|
||||
.and_then(|argv| launch_line_to_save(agent, &argv))
|
||||
else {
|
||||
window.push_notification(
|
||||
t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let saved = line.clone();
|
||||
self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved));
|
||||
window.push_notification(
|
||||
t_fmt(
|
||||
L10nKey::AppAgentLaunchSaved,
|
||||
&[("name", name), ("command", &line)],
|
||||
),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn usage(pairs: &[(&str, u32, u64)]) -> HashMap<String, ProfileUsage> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(slug, count, last_used)| {
|
||||
(
|
||||
slug.to_string(),
|
||||
ProfileUsage {
|
||||
count: *count,
|
||||
last_used: *last_used,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn argv(parts: &[&str]) -> Vec<String> {
|
||||
parts.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
const NOW: u64 = 1_800_000_000;
|
||||
const DAY: u64 = 86_400;
|
||||
|
||||
#[test]
|
||||
fn agents_are_offered_most_used_first() {
|
||||
let used = usage(&[
|
||||
("codex", 3, NOW - DAY),
|
||||
("claude", 12, NOW - 2 * DAY),
|
||||
// Used a lot, but so long ago that it has decayed below Codex.
|
||||
("gemini", 20, NOW - 200 * DAY),
|
||||
]);
|
||||
let offered = by_frecency(
|
||||
[
|
||||
CLIAgent::Aider,
|
||||
CLIAgent::Gemini,
|
||||
CLIAgent::Codex,
|
||||
CLIAgent::Claude,
|
||||
CLIAgent::Amp,
|
||||
],
|
||||
&used,
|
||||
NOW,
|
||||
);
|
||||
assert_eq!(
|
||||
offered,
|
||||
vec![
|
||||
CLIAgent::Claude,
|
||||
CLIAgent::Codex,
|
||||
CLIAgent::Gemini,
|
||||
// Never used: they keep the order they were found in.
|
||||
CLIAgent::Aider,
|
||||
CLIAgent::Amp,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_agent_tab_opens_the_most_recently_used_agent() {
|
||||
let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini];
|
||||
// Claude is used more, but Codex was the last one run or launched.
|
||||
let used = usage(&[
|
||||
("claude", 40, NOW - DAY),
|
||||
("codex", 1, NOW - 60),
|
||||
("aider", 5, NOW),
|
||||
]);
|
||||
assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex));
|
||||
// Aider was more recent still, but it is not offered here.
|
||||
// Nothing used yet: the first one offered.
|
||||
assert_eq!(
|
||||
most_recent(&offered, &HashMap::new()),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(most_recent(&[], &used), None);
|
||||
// A launch that only stamped recency counts as the most recent.
|
||||
let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]);
|
||||
assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_launch_args_survive_a_round_trip_through_the_command_line() {
|
||||
let run = argv(&[
|
||||
"/opt/homebrew/bin/claude",
|
||||
"--append-system-prompt",
|
||||
"be terse, don't guess",
|
||||
"--add-dir",
|
||||
"/Users/me/My Projects",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
"abc-123",
|
||||
]);
|
||||
let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap();
|
||||
assert_eq!(
|
||||
crate::ui::app::split_shell_args(&line).unwrap(),
|
||||
argv(&[
|
||||
"claude",
|
||||
"--append-system-prompt",
|
||||
"be terse, don't guess",
|
||||
"--add-dir",
|
||||
"/Users/me/My Projects",
|
||||
"--model",
|
||||
"opus",
|
||||
])
|
||||
);
|
||||
// And the saved line is recognised as the agent it launches.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with(&line, &HashMap::new()),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_current_launch_args_as_default_writes_the_agent_launch_entry() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.agent_launch
|
||||
.insert("Claude".to_string(), "claude --old".to_string());
|
||||
cfg.agent_launch
|
||||
.insert("codex".to_string(), "codex --model o3".to_string());
|
||||
let line = launch_line_to_save(
|
||||
CLIAgent::Claude,
|
||||
&argv(&["claude", "--dangerously-skip-permissions", "--continue"]),
|
||||
)
|
||||
.unwrap();
|
||||
remember_launch_line(&mut cfg, CLIAgent::Claude, line);
|
||||
|
||||
let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap();
|
||||
assert_eq!(
|
||||
json["agent_launch"],
|
||||
serde_json::json!({
|
||||
"claude": "claude --dangerously-skip-permissions",
|
||||
"codex": "codex --model o3",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.launch_command(&cfg.agent_launch),
|
||||
"claude --dangerously-skip-permissions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_agent_has_a_bindable_launch_action() {
|
||||
for agent in CLIAgent::ALL {
|
||||
let name = launch_action_name(agent);
|
||||
assert_eq!(agent_for_launch_action(name), Some(agent), "{name}");
|
||||
}
|
||||
assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None);
|
||||
assert_eq!(agent_for_launch_action("NewAgentTab"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_agents_whose_launch_program_is_on_path_are_offered() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
for name in ["codex", "cc"] {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, "#!/bin/sh\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
}
|
||||
let path = dir.path().as_os_str();
|
||||
assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]);
|
||||
// With an override, the override's program is what has to be there.
|
||||
let overrides: HashMap<String, String> = [
|
||||
("claude".to_string(), "cc --fast".to_string()),
|
||||
("codex".to_string(), "codex-nightly".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]);
|
||||
}
|
||||
}
|
||||
+659
-29
@@ -451,6 +451,25 @@ pub struct Tab {
|
||||
/// the tab no longer holds are dropped as the next one is written, so a
|
||||
/// closed pane leaves nothing behind either.
|
||||
focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>,
|
||||
/// Set while the tab is asleep (#762): its panes were stopped and `pane`
|
||||
/// is `Pane::Empty`. What is kept instead is what it takes to bring them
|
||||
/// back — see [`Asleep`].
|
||||
pub(crate) asleep: Option<Asleep>,
|
||||
}
|
||||
|
||||
/// A sleeping tab: nothing of it runs, and everything a wake needs is here.
|
||||
///
|
||||
/// `layout` is the tab as the restore path reads a tab — pane ids, cwds,
|
||||
/// shells, agent sessions — so waking is that restore, run on one tab: each
|
||||
/// pane's id asks the daemon for the screen it stored when the pane was
|
||||
/// stopped, and an agent with a known session is resumed the way a reboot
|
||||
/// resumes it. `view` is what the tab is called in the meantime, read off
|
||||
/// the panes as they went to sleep (or off the tree's records, after a
|
||||
/// restart), since there is no terminal left to ask.
|
||||
pub(crate) struct Asleep {
|
||||
pub(crate) layout: SessionPane,
|
||||
pub(crate) view: tty7_core::core::tab_view::TabView,
|
||||
pub(crate) home: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
@@ -477,6 +496,7 @@ impl Tab {
|
||||
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
||||
last_used: std::cell::Cell::new(0),
|
||||
focus_origin: Default::default(),
|
||||
asleep: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,9 +520,25 @@ impl Tab {
|
||||
tree_id: std::cell::Cell::new(tree.id),
|
||||
last_used: std::cell::Cell::new(0),
|
||||
focus_origin: Default::default(),
|
||||
asleep: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_asleep(&self) -> bool {
|
||||
self.asleep.is_some()
|
||||
}
|
||||
|
||||
/// The layout a sleeping tab will wake into; `None` for a tab that is awake.
|
||||
pub(crate) fn asleep_layout(&self) -> Option<&SessionPane> {
|
||||
self.asleep.as_ref().map(|a| &a.layout)
|
||||
}
|
||||
|
||||
/// The agent a sleeping tab was running when it went to sleep, so its row
|
||||
/// still says what it is while nothing in it can.
|
||||
pub(crate) fn asleep_agent(&self) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
self.asleep.as_ref().and_then(|a| a.view.agent)
|
||||
}
|
||||
|
||||
fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> {
|
||||
match self.last_focused {
|
||||
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
|
||||
@@ -601,6 +637,14 @@ impl Tab {
|
||||
Option<std::path::PathBuf>,
|
||||
) {
|
||||
let name = self.name.clone();
|
||||
if let Some(asleep) = &self.asleep {
|
||||
let view = tty7_core::core::tab_view::TabView {
|
||||
id: self.tree_id.get(),
|
||||
name,
|
||||
..asleep.view.clone()
|
||||
};
|
||||
return (view, asleep.home.clone());
|
||||
}
|
||||
let Some(leaf) = self.title_leaf(window, cx) else {
|
||||
return (
|
||||
tty7_core::core::tab_view::TabView {
|
||||
@@ -628,7 +672,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),
|
||||
@@ -1874,6 +1921,7 @@ impl Tty7App {
|
||||
self.tabs = tabs;
|
||||
self.active = active;
|
||||
self.maximized = None;
|
||||
self.wake_active_if_asleep(window, cx);
|
||||
self.save_session(cx);
|
||||
crate::ui::windows::refresh_menu(cx);
|
||||
self.focus_active(window, cx);
|
||||
@@ -1921,6 +1969,7 @@ impl Tty7App {
|
||||
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
||||
last_used: std::cell::Cell::new(0),
|
||||
focus_origin: Default::default(),
|
||||
asleep: None,
|
||||
},
|
||||
);
|
||||
self.active = insert_at;
|
||||
@@ -3076,7 +3125,7 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let fields = self.managed_forward_fields(cx);
|
||||
let Some(rule) = fields.collect() else {
|
||||
let Some(mut rule) = fields.collect() else {
|
||||
// Add is disabled while the fields do not make a rule and the form
|
||||
// already says what is missing, so there is nothing to do here and
|
||||
// nothing left to explain.
|
||||
@@ -3084,6 +3133,10 @@ impl Tty7App {
|
||||
};
|
||||
let route = self.forward_route(pane_id, cx);
|
||||
let previous = self.loopback_panel.mf_editing.clone();
|
||||
// Editing a switched-off rule edits it; it does not switch it on.
|
||||
if let Some(old) = &previous {
|
||||
rule.enabled = old.enabled;
|
||||
}
|
||||
// A saved edit is a replace, and the rule being replaced has to come
|
||||
// out first: the ordinary edit keeps the bind port, and the far side
|
||||
// really does bind it, so adding first would collide with the very
|
||||
@@ -3187,10 +3240,14 @@ impl Tty7App {
|
||||
let Some(list) = route.add(rule) else {
|
||||
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
|
||||
};
|
||||
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
|
||||
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
|
||||
ForwardStatus::Listening => None,
|
||||
});
|
||||
// A switched-off rule reports an error status because it binds
|
||||
// nothing, and that is what it was asked to do.
|
||||
let broken = added_forward(&before, &list)
|
||||
.filter(|added| added.enabled)
|
||||
.and_then(|added| match &added.status {
|
||||
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
|
||||
ForwardStatus::Listening => None,
|
||||
});
|
||||
self.loopback_panel.managed = list;
|
||||
let Some((id, msg)) = broken else {
|
||||
return PlaceOutcome::Placed;
|
||||
@@ -3278,6 +3335,84 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Switch one forward on or off, and carry the choice into the saved host
|
||||
/// the rule came from, if it came from one — so the next connection opens
|
||||
/// the same set of rules instead of the ones that were saved before.
|
||||
pub(crate) fn set_managed_forward_enabled(
|
||||
&mut self,
|
||||
pane_id: u64,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let route = self.forward_route(pane_id, cx);
|
||||
let forward = self
|
||||
.loopback_panel
|
||||
.managed
|
||||
.iter()
|
||||
.find(|m| m.id == forward_id)
|
||||
.cloned();
|
||||
match route.set_enabled(forward_id, enabled) {
|
||||
Ok(list) => {
|
||||
self.loopback_panel.managed = list;
|
||||
if let Some(forward) = forward {
|
||||
self.remember_forward_enabled(pane_id, &forward, enabled, cx);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(
|
||||
t_fmt(L10nKey::ForwardSwitchFailed, &[("error", &e.to_string())]),
|
||||
cx,
|
||||
);
|
||||
// The refusal changed nothing, but the panel may be behind
|
||||
// whatever did change — ask rather than guess.
|
||||
self.loopback_panel.managed = route.list();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn remember_forward_enabled(
|
||||
&mut self,
|
||||
pane_id: u64,
|
||||
forward: &crate::daemon::protocol::ManagedForward,
|
||||
enabled: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let profile_id = self
|
||||
.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.pane.terminals())
|
||||
.find_map(|leaf| {
|
||||
let view = leaf.read(cx);
|
||||
(view.pane_id == pane_id).then(|| view.ssh_spec()?.profile_id.clone())?
|
||||
})
|
||||
.and_then(|id| uuid::Uuid::parse_str(&id).ok());
|
||||
let Some(profile_id) = profile_id else {
|
||||
return;
|
||||
};
|
||||
let saved = cx
|
||||
.global::<Config>()
|
||||
.ssh_profiles
|
||||
.iter()
|
||||
.find(|p| p.id == profile_id)
|
||||
.and_then(|p| crate::ui::forwards::saved_rule_index(&p.forwards, forward, enabled));
|
||||
let Some(idx) = saved else {
|
||||
return;
|
||||
};
|
||||
self.update_config(cx, |cfg| {
|
||||
if let Some(rule) = cfg
|
||||
.ssh_profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.id == profile_id)
|
||||
.and_then(|p| p.forwards.get_mut(idx))
|
||||
{
|
||||
rule.enabled = enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Whatever the panel would let this pane forward, which is a wider set
|
||||
// than "a connected native-ssh pane": a pane in a remote workspace
|
||||
@@ -3484,6 +3619,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,
|
||||
@@ -3797,6 +3940,7 @@ impl Tty7App {
|
||||
spawn.agent_launch_argv.as_deref(),
|
||||
cx,
|
||||
)
|
||||
.or_else(|| spawn.run_on_land.clone())
|
||||
})
|
||||
.flatten();
|
||||
let view = build_terminal_view(parts, font_size, window, cx);
|
||||
@@ -3911,8 +4055,21 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.new_tab_slot(cwd, shell, window, cx);
|
||||
}
|
||||
|
||||
/// [`Self::new_tab_with_cwd`], answering with the pane it opened — `None`
|
||||
/// when nothing opened, so a caller about to type into the new pane has
|
||||
/// nothing to type into rather than whatever was focused before.
|
||||
pub(crate) fn new_tab_slot(
|
||||
&mut self,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<PaneSlot> {
|
||||
if !self.guard_local_spawn(window, cx) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let group = self.spawn_group(cwd.as_deref(), cx);
|
||||
let tab = match new_terminal(
|
||||
@@ -3934,7 +4091,7 @@ impl Tty7App {
|
||||
self.startup_error = Some(gpui::SharedString::from(text.clone()));
|
||||
window.push_notification(text, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Something opened, so whatever the last failure was is stale.
|
||||
@@ -3942,13 +4099,14 @@ impl Tty7App {
|
||||
self.remember_active_pane(window, cx);
|
||||
self.maximized = None;
|
||||
let insert_at = self.new_tab_insert_at(cx);
|
||||
let new_tab = Tab::new(Pane::leaf(tab));
|
||||
let new_tab = Tab::new(Pane::leaf(tab.clone()));
|
||||
group.seat(&new_tab);
|
||||
self.tabs.insert(insert_at, new_tab);
|
||||
self.active = insert_at;
|
||||
self.focus_active(window, cx);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
Some(tab)
|
||||
}
|
||||
|
||||
pub(crate) fn open_native_ssh_tab(
|
||||
@@ -4032,15 +4190,24 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(target) = self
|
||||
self.split_slot(axis, spawn, window, cx);
|
||||
}
|
||||
|
||||
/// [`Self::split_into`], answering with the pane it placed, or `None`
|
||||
/// when nothing was placed.
|
||||
pub(crate) fn split_slot(
|
||||
&mut self,
|
||||
axis: Axis,
|
||||
spawn: Option<SpawnAs>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<PaneSlot> {
|
||||
let target = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))?;
|
||||
if !self.guard_local_spawn(window, cx) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let cwd = target.read(cx).spawnable_cwd();
|
||||
let spawn = match spawn {
|
||||
@@ -4068,7 +4235,7 @@ impl Tty7App {
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4090,22 +4257,23 @@ impl Tty7App {
|
||||
t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
if tab
|
||||
.pane
|
||||
.split_leaf(target.entity_id(), axis, false, new.clone())
|
||||
{
|
||||
self.maximized = None;
|
||||
self.focus_leaf(&new, window, cx);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
}
|
||||
let tab = self.tabs.get_mut(self.active)?;
|
||||
if !tab
|
||||
.pane
|
||||
.split_leaf(target.entity_id(), axis, false, new.clone())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.maximized = None;
|
||||
self.focus_leaf(&new, window, cx);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
Some(new)
|
||||
}
|
||||
|
||||
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -4721,6 +4889,13 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// A tab is woken by being looked at, the way a browser reloads a
|
||||
// discarded tab when it is selected: a tab on screen is always a
|
||||
// running one, so there is no third state to draw. A wake that fails
|
||||
// leaves the tab asleep and the window where it was.
|
||||
if self.tabs.get(index).is_some_and(Tab::is_asleep) && !self.wake_tab(index, window, cx) {
|
||||
return;
|
||||
}
|
||||
if index < self.tabs.len() && index != self.active {
|
||||
self.remember_active_pane(window, cx);
|
||||
// Zoom rides with its tab (#599): stash the outgoing tab's zoom
|
||||
@@ -4753,6 +4928,146 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether tab `index` can be put to sleep right now (#762).
|
||||
///
|
||||
/// Not while it is connecting: a pane still on its way has no id the
|
||||
/// machine knows, so it would be lost rather than stopped. Not with a
|
||||
/// native SSH pane in a remote window either — that pane runs on this
|
||||
/// computer, beyond the reach of the machine asked to stop the tab. Not on
|
||||
/// a machine whose server cannot do it. And not the one tab left awake:
|
||||
/// the window has to have something on screen, and looking at a tab is
|
||||
/// what wakes it.
|
||||
pub(crate) fn can_hibernate_tab(&self, index: usize, cx: &App) -> bool {
|
||||
let Some(tab) = self.tabs.get(index) else {
|
||||
return false;
|
||||
};
|
||||
if tab.is_asleep() || !crate::ui::tree_sync::can_hibernate_on(cx, self.workspace) {
|
||||
return false;
|
||||
}
|
||||
let remote = WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.is_some_and(|w| w.is_remote());
|
||||
let leaves = tab.pane.leaves();
|
||||
let all_hibernatable = !leaves.is_empty()
|
||||
&& leaves.iter().all(|leaf| match leaf {
|
||||
PaneSlot::Ready(view) => !(remote && view.read(cx).ssh_spec().is_some()),
|
||||
PaneSlot::Connecting(_) => false,
|
||||
});
|
||||
all_hibernatable && self.awake_tab_besides(index).is_some()
|
||||
}
|
||||
|
||||
/// The tab to show instead of `index` when that one goes to sleep: the
|
||||
/// awake tab used most recently, the nearest one on a tie.
|
||||
fn awake_tab_besides(&self, index: usize) -> Option<usize> {
|
||||
(0..self.tabs.len())
|
||||
.filter(|&i| i != index && !self.tabs[i].is_asleep())
|
||||
.max_by_key(|&i| {
|
||||
(
|
||||
self.tabs[i].last_used.get(),
|
||||
std::cmp::Reverse(i.abs_diff(index)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Put tab `index` to sleep: stop its panes, keep its place (#762).
|
||||
///
|
||||
/// The window lets go of the panes first and the machine stops them after,
|
||||
/// from the sync this ends with. That order is what keeps a stopped pane
|
||||
/// from reading as one that died: by the time its shell exits there is no
|
||||
/// view left here to take the exit as a reason to close the pane, or the
|
||||
/// tab with it. The machine, for its part, marks the tab before it stops
|
||||
/// anything, so a restore reading the tree in between never takes the
|
||||
/// panes for dead ones and spawns them straight back.
|
||||
pub(crate) fn hibernate_tab(
|
||||
&mut self,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.can_hibernate_tab(index, cx) {
|
||||
return;
|
||||
}
|
||||
if index == self.active {
|
||||
let Some(next) = self.awake_tab_besides(index) else {
|
||||
return;
|
||||
};
|
||||
self.activate(next, window, cx);
|
||||
}
|
||||
self.put_to_sleep(index, window, cx);
|
||||
}
|
||||
|
||||
/// The part of [`Self::hibernate_tab`] that does not ask whether it may:
|
||||
/// the tab lets go of its panes and keeps what a wake needs instead.
|
||||
fn put_to_sleep(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tabs.get(index).filter(|t| !t.is_asleep()) else {
|
||||
return;
|
||||
};
|
||||
let (mut view, home) = tab.label_view(Some(window), cx);
|
||||
view.live = false;
|
||||
view.status = None;
|
||||
let layout = pane_to_session(&tab.pane, cx);
|
||||
let tab = &mut self.tabs[index];
|
||||
let panes = std::mem::replace(&mut tab.pane, Pane::Empty);
|
||||
tab.zoomed = None;
|
||||
tab.last_focused = None;
|
||||
tab.focus_origin.clear();
|
||||
tab.asleep = Some(Asleep { layout, view, home });
|
||||
drop(panes);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Bring a sleeping tab back, through the restore every pane takes after a
|
||||
/// reboot: each pane is spawned in its old one's place, opens on the screen
|
||||
/// that one left behind, and resumes its agent session when it had one.
|
||||
/// Returns whether the tab is awake now; a tab none of whose panes could
|
||||
/// be started stays asleep, and says so.
|
||||
pub(crate) fn wake_tab(
|
||||
&mut self,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(asleep) = self.tabs.get_mut(index).and_then(|t| t.asleep.take()) else {
|
||||
return true;
|
||||
};
|
||||
let pane_ws = self.window_workspace(cx);
|
||||
let layout = redial_native_ssh(&asleep.layout);
|
||||
// No listing: every pane in here was stopped on purpose, so the attach
|
||||
// each one tries first fails and falls through to the fresh spawn —
|
||||
// unless the stop never landed, in which case attaching to the pane
|
||||
// that is still running is exactly right.
|
||||
let pane = session_to_pane(
|
||||
pane_ws.as_ref(),
|
||||
self.workspace,
|
||||
&layout,
|
||||
None,
|
||||
self.font_size,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let tab = &mut self.tabs[index];
|
||||
let Some(pane) = pane else {
|
||||
tab.asleep = Some(asleep);
|
||||
window.push_notification(t(L10nKey::TabWakeFailed), cx);
|
||||
return false;
|
||||
};
|
||||
tab.pane = pane;
|
||||
tab.last_focused = None;
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Wake the tab on screen if it is asleep — for the paths that land on a
|
||||
/// tab without going through [`Self::activate`]: a neighbour taking over
|
||||
/// from a closed tab, a window restored onto a sleeping one.
|
||||
pub(crate) fn wake_active_if_asleep(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.tabs.get(self.active).is_some_and(Tab::is_asleep) {
|
||||
self.wake_tab(self.active, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether tab `index` has a pane zoomed over hidden siblings — what the
|
||||
/// chrome marks so the state is readable without toggling it (#752).
|
||||
///
|
||||
@@ -4857,6 +5172,7 @@ impl Tty7App {
|
||||
} else if index < self.active {
|
||||
self.active -= 1;
|
||||
}
|
||||
self.wake_active_if_asleep(window, cx);
|
||||
self.focus_active(window, cx);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
@@ -5437,6 +5753,31 @@ impl Tty7App {
|
||||
},
|
||||
);
|
||||
|
||||
// Every shell the window's machine has, named alike in every language
|
||||
// so the New Tab menu's "Other Shells…" row can land on exactly these
|
||||
// by typing one word. Listed in the order that menu uses.
|
||||
{
|
||||
let default_shell = self.default_shell_label(cx);
|
||||
let usage = &cx.global::<Config>().shell_frecency;
|
||||
let now = crate::core::config::unix_now();
|
||||
for s in crate::ui::tab_strip::shells_by_frecency(
|
||||
&self.shells.shells,
|
||||
&default_shell,
|
||||
usage,
|
||||
now,
|
||||
) {
|
||||
let mut cmd = Command::new(
|
||||
t_fmt(L10nKey::AppCmdShellTitle, &[("title", &s.label)]),
|
||||
CommandKind::OpenShell(s.label.clone()),
|
||||
)
|
||||
.in_group(CommandGroup::TabsPanes);
|
||||
if s.label == default_shell {
|
||||
cmd = cmd.with_subtitle(t(L10nKey::ShellDefault));
|
||||
}
|
||||
commands.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// Offered only where it would do something. A connection opened from a
|
||||
// saved host has nothing to save, and a pane that is not an SSH one has
|
||||
// no connection at all — either would be a row that quietly did nothing
|
||||
@@ -5495,6 +5836,20 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
for agent in self.offered_agents(cx) {
|
||||
commands.push(
|
||||
Command::new(
|
||||
t_fmt(
|
||||
L10nKey::AppCmdAgentLaunchTitle,
|
||||
&[("name", agent.display_name())],
|
||||
),
|
||||
CommandKind::LaunchAgent(agent),
|
||||
)
|
||||
.with_subtitle(agent.launch_command(&cx.global::<Config>().agent_launch))
|
||||
.in_group(CommandGroup::Agents),
|
||||
);
|
||||
}
|
||||
|
||||
for (i, tab) in self.tabs.iter().enumerate() {
|
||||
if i == self.active {
|
||||
continue;
|
||||
@@ -5678,7 +6033,15 @@ impl Tty7App {
|
||||
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
|
||||
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
|
||||
MarkTabUnread => self.mark_tab_unread(self.active, cx),
|
||||
HibernateTab => self.hibernate_tab(self.active, window, cx),
|
||||
ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx),
|
||||
NewAgentTab => self.new_agent_tab(window, cx),
|
||||
// Picked from the palette with ⌥ held, the way a New Tab menu row
|
||||
// is: a split beside the focused pane instead of a tab.
|
||||
LaunchAgent(agent) => {
|
||||
let at = SpawnWhere::from_modifiers(window.modifiers());
|
||||
self.launch_agent(agent, at, window, cx)
|
||||
}
|
||||
CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx),
|
||||
RenameWorkspace => self.start_workspace_rename(window, cx),
|
||||
OpenSettings => self.toggle_settings(window, cx),
|
||||
@@ -5720,6 +6083,11 @@ impl Tty7App {
|
||||
}
|
||||
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
|
||||
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
|
||||
// ⌥ held as the row is taken splits, as it does on the menu's row.
|
||||
OpenShell(label) => {
|
||||
let at = SpawnWhere::from_modifiers(window.modifiers());
|
||||
self.open_listed_shell(&label, at, window, cx)
|
||||
}
|
||||
EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx),
|
||||
QuickConnect(target) => {
|
||||
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
|
||||
@@ -5864,6 +6232,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)
|
||||
}
|
||||
@@ -8266,6 +8635,31 @@ impl ForwardRoute {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_enabled(
|
||||
&self,
|
||||
forward_id: u64,
|
||||
enabled: bool,
|
||||
) -> anyhow::Result<Vec<crate::daemon::protocol::ManagedForward>> {
|
||||
let Some(req) =
|
||||
self.workspace_op(crate::daemon::protocol::WorkspaceOp::SetForwardEnabled {
|
||||
forward_id,
|
||||
enabled,
|
||||
})
|
||||
else {
|
||||
return crate::terminal::RemoteTerminal::set_forward_enabled(
|
||||
self.pane_id,
|
||||
forward_id,
|
||||
enabled,
|
||||
);
|
||||
};
|
||||
match crate::terminal::RemoteTerminal::on_workspace(req)? {
|
||||
crate::daemon::protocol::DaemonMsg::ForwardList(list) => Ok(list),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unexpected reply to SetForwardEnabled: {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove(
|
||||
&self,
|
||||
forward_id: u64,
|
||||
@@ -8976,6 +9370,9 @@ impl Render for Tty7App {
|
||||
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
|
||||
this.mark_tab_unread(this.active, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &HibernateTab, window, cx| {
|
||||
this.hibernate_tab(this.active, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| {
|
||||
this.fork_active_pane_session(ForkPlacement::NewTab, window, cx)
|
||||
}))
|
||||
@@ -8994,6 +9391,15 @@ impl Render for Tty7App {
|
||||
.on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| {
|
||||
this.copy_agent_session_id(this.active, window, cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &NewAgentTab, window, cx| this.new_agent_tab(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|this, action: &LaunchAgent, window, cx| {
|
||||
this.launch_agent(action.agent, SpawnWhere::NewTab, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &SaveAgentLaunchArgs, window, cx| {
|
||||
this.save_agent_launch_args(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| {
|
||||
this.open_settings_section(SettingsSection::Keybindings, window, cx)
|
||||
}))
|
||||
@@ -9084,10 +9490,15 @@ fn mru_order(stamps: &[u64], active: usize) -> Vec<usize> {
|
||||
fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
|
||||
SessionTab {
|
||||
name: tab.name.clone(),
|
||||
pane: pane_to_session(&tab.pane, cx),
|
||||
pane: match tab.asleep_layout() {
|
||||
Some(layout) => layout.clone(),
|
||||
None => pane_to_session(&tab.pane, cx),
|
||||
},
|
||||
group: tab.group.get(),
|
||||
last_auto: tab.auto_group.borrow().clone(),
|
||||
tree_id: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9262,7 +9673,19 @@ fn tabs_from_session(
|
||||
let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace));
|
||||
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
|
||||
let mut dropped = 0usize;
|
||||
for st in &session.tabs {
|
||||
let home = crate::ui::path_display::home_for_host(
|
||||
cx,
|
||||
workspace.map_or(HostId::LOCAL, |w| w.target.host_id()),
|
||||
);
|
||||
for (index, st) in session.tabs.iter().enumerate() {
|
||||
// Asleep stays asleep: nothing is spawned or attached for it, which is
|
||||
// the point. The one exception is the tab the window opens onto — a
|
||||
// tab on screen is awake, so that one is woken here, by the same
|
||||
// restore every other tab is getting.
|
||||
if st.hibernated && index != session.active {
|
||||
tabs.push(asleep_tab(st, home.clone()));
|
||||
continue;
|
||||
}
|
||||
let Some(pane) = session_to_pane(
|
||||
workspace,
|
||||
owner,
|
||||
@@ -9298,12 +9721,97 @@ fn tabs_from_session(
|
||||
),
|
||||
last_used: std::cell::Cell::new(0),
|
||||
focus_origin: Default::default(),
|
||||
asleep: None,
|
||||
});
|
||||
}
|
||||
let active = session.active.min(tabs.len().saturating_sub(1));
|
||||
(tabs, active, dropped)
|
||||
}
|
||||
|
||||
/// A tab restored asleep: its place, its name and its group, and nothing
|
||||
/// running behind them.
|
||||
fn asleep_tab(st: &SessionTab, home: Option<std::path::PathBuf>) -> Tab {
|
||||
let mut tab = Tab::new(Pane::Empty);
|
||||
tab.name = st.name.clone();
|
||||
tab.group.set(st.group);
|
||||
*tab.auto_group.borrow_mut() = st.last_auto.clone();
|
||||
if let Some(id) = st.tree_id {
|
||||
tab.tree_id.set(id);
|
||||
}
|
||||
tab.asleep = Some(Asleep {
|
||||
layout: st.pane.clone(),
|
||||
view: st
|
||||
.asleep_view
|
||||
.clone()
|
||||
.unwrap_or_else(|| view_of_layout(tab.tree_id.get(), &st.pane)),
|
||||
home,
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
/// What to call a sleeping tab when the tree had nothing better to say: the
|
||||
/// first directory in its layout, and the agent in it if there was one.
|
||||
fn view_of_layout(
|
||||
id: tty7_core::core::machine::TabId,
|
||||
layout: &SessionPane,
|
||||
) -> tty7_core::core::tab_view::TabView {
|
||||
fn leaves<'a>(pane: &'a SessionPane, out: &mut Vec<&'a SessionPane>) {
|
||||
match pane {
|
||||
SessionPane::Leaf { .. } => out.push(pane),
|
||||
SessionPane::Split { a, b, .. } => {
|
||||
leaves(a, out);
|
||||
leaves(b, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
leaves(layout, &mut all);
|
||||
let cwd = all.iter().find_map(|leaf| match leaf {
|
||||
SessionPane::Leaf { cwd, .. } => cwd.as_ref().map(|p| p.display().to_string()),
|
||||
SessionPane::Split { .. } => None,
|
||||
});
|
||||
let agent = all.iter().find_map(|leaf| match leaf {
|
||||
SessionPane::Leaf { agent, .. } => *agent,
|
||||
SessionPane::Split { .. } => None,
|
||||
});
|
||||
tty7_core::core::tab_view::TabView {
|
||||
id,
|
||||
name: None,
|
||||
title: String::new(),
|
||||
osc_title: None,
|
||||
cwd,
|
||||
agent,
|
||||
status: None,
|
||||
live: false,
|
||||
panes: all.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sleeping layout as a wake should read it: a native SSH pane dials its
|
||||
/// host again rather than standing on its old id. That id belongs to a pane
|
||||
/// whose connection is gone, and the restore reading it would put a local
|
||||
/// shell in its place.
|
||||
fn redial_native_ssh(layout: &SessionPane) -> SessionPane {
|
||||
match layout {
|
||||
SessionPane::Leaf {
|
||||
ssh_spec: Some(_), ..
|
||||
} => {
|
||||
let mut leaf = layout.clone();
|
||||
if let SessionPane::Leaf { pane_id, .. } = &mut leaf {
|
||||
*pane_id = None;
|
||||
}
|
||||
leaf
|
||||
}
|
||||
SessionPane::Leaf { .. } => layout.clone(),
|
||||
SessionPane::Split { axis, ratio, a, b } => SessionPane::Split {
|
||||
axis: *axis,
|
||||
ratio: *ratio,
|
||||
a: Box::new(redial_native_ssh(a)),
|
||||
b: Box::new(redial_native_ssh(b)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool {
|
||||
!(window_is_remote && leaf_is_native_ssh)
|
||||
}
|
||||
@@ -9434,6 +9942,7 @@ pub(crate) fn new_terminal(
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
run_on_land: None,
|
||||
owner,
|
||||
font_size,
|
||||
};
|
||||
@@ -9513,6 +10022,14 @@ fn build_terminal_view(
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|app, _view, ev: &crate::terminal::view::AgentDetected, _window, cx| {
|
||||
app.note_agent_detected(ev.0, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
@@ -10643,6 +11160,7 @@ mod tests {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
agent_session_id: Some("sid-abc".to_string()),
|
||||
agent_launch_argv: Some(vec!["claude".to_string()]),
|
||||
run_on_land: None,
|
||||
owner: None,
|
||||
font_size: 14.0,
|
||||
},
|
||||
@@ -11096,6 +11614,7 @@ mod ssh_rebuild_gpui_tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
};
|
||||
app.apply_layout_delta(
|
||||
&LayoutDelta::TabRestructured { tab, pane: None },
|
||||
@@ -11900,6 +12419,7 @@ mod managed_forward_gpui_tests {
|
||||
target_port: 80,
|
||||
description: None,
|
||||
status: ForwardStatus::Listening,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12259,6 +12779,7 @@ mod tab_focus_memory_tests {
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
run_on_land: None,
|
||||
owner: None,
|
||||
font_size: 14.,
|
||||
},
|
||||
@@ -12607,3 +13128,112 @@ mod tab_focus_memory_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hibernate_gpui_tests {
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use crate::core::session::SessionPane;
|
||||
use crate::ui::app::test_window::harness_with_tabs;
|
||||
use crate::ui::pane::Pane;
|
||||
|
||||
fn leaf_pane_id(pane: &SessionPane) -> Option<u64> {
|
||||
match pane {
|
||||
SessionPane::Leaf { pane_id, .. } => *pane_id,
|
||||
SessionPane::Split { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asleep, a tab holds nothing running and still holds its place: the
|
||||
/// pane id the wake restores from, the name it had on screen, and a seat in
|
||||
/// the sync that tells the machine it is asleep rather than gone.
|
||||
#[gpui::test]
|
||||
fn a_sleeping_tab_keeps_its_place_its_pane_and_its_name(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
let pane_id = app.tabs[1]
|
||||
.pane
|
||||
.first_leaf()
|
||||
.and_then(|slot| slot.terminal().cloned())
|
||||
.expect("tab 1 has a pane")
|
||||
.read(cx)
|
||||
.pane_id;
|
||||
let (before, _) = app.tabs[1].label_view(Some(window), cx);
|
||||
|
||||
app.put_to_sleep(1, window, cx);
|
||||
|
||||
assert_eq!(app.tabs.len(), 2, "sleeping is not closing");
|
||||
let tab = &app.tabs[1];
|
||||
assert!(tab.is_asleep());
|
||||
assert!(matches!(tab.pane, Pane::Empty));
|
||||
assert!(tab.pane.terminals().is_empty(), "nothing is left running");
|
||||
assert_eq!(tab.asleep_layout().and_then(leaf_pane_id), Some(pane_id));
|
||||
let (after, _) = tab.label_view(Some(window), cx);
|
||||
assert_eq!(
|
||||
format!("{:?}", after.label()),
|
||||
format!("{:?}", before.label()),
|
||||
"it is called what it was called while awake"
|
||||
);
|
||||
assert!(!after.live);
|
||||
|
||||
let (desired, _, held) = crate::ui::tree_sync::desired_tabs(app, cx);
|
||||
assert!(held.is_empty(), "a sleeping tab is not a tab gone missing");
|
||||
assert_eq!(desired.len(), 2);
|
||||
assert!(!desired[0].hibernated);
|
||||
assert!(desired[1].hibernated);
|
||||
match &desired[1].root {
|
||||
crate::ui::tree_sync::DesiredNode::Leaf { pane, .. } => assert_eq!(*pane, pane_id),
|
||||
other => panic!("expected the one leaf, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Looking at a tab is what wakes it, so the window has to keep one awake
|
||||
/// to look at; the tab shown in a sleeping one's place is the awake tab
|
||||
/// used last.
|
||||
#[gpui::test]
|
||||
fn the_last_awake_tab_has_nothing_to_hand_the_window_to(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.tabs[1].last_used.set(5);
|
||||
app.tabs[2].last_used.set(9);
|
||||
assert_eq!(app.awake_tab_besides(0), Some(2), "the one used last");
|
||||
|
||||
app.put_to_sleep(2, window, cx);
|
||||
assert_eq!(app.awake_tab_besides(0), Some(1));
|
||||
|
||||
app.put_to_sleep(1, window, cx);
|
||||
assert_eq!(app.awake_tab_besides(0), None);
|
||||
assert!(
|
||||
!app.can_hibernate_tab(0, cx),
|
||||
"the last awake tab stays awake"
|
||||
);
|
||||
assert!(
|
||||
!app.can_hibernate_tab(1, cx),
|
||||
"nor is a sleeping tab put to sleep twice"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Closing a sleeping tab closes it like any other, and what Reopen Closed
|
||||
/// Tab gets back is the layout it slept with — not the empty stand-in.
|
||||
#[gpui::test]
|
||||
fn a_sleeping_tab_closes_into_what_it_would_have_woken_as(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.put_to_sleep(1, window, cx);
|
||||
let layout_id = app.tabs[1].asleep_layout().and_then(leaf_pane_id);
|
||||
assert!(layout_id.is_some());
|
||||
|
||||
app.close_tab(1, window, cx);
|
||||
|
||||
assert_eq!(app.tabs.len(), 1);
|
||||
let closed = app.closed.last().expect("the closed tab is remembered");
|
||||
assert_eq!(leaf_pane_id(&closed.pane), layout_id);
|
||||
assert!(!closed.hibernated, "reopening brings it back awake");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"),
|
||||
"icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"),
|
||||
"icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"),
|
||||
"icons/power.svg" => include_bytes!("../../assets/icons/power.svg"),
|
||||
"icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"),
|
||||
"icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"),
|
||||
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
|
||||
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
|
||||
"icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"),
|
||||
@@ -115,6 +118,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(),
|
||||
|
||||
@@ -3363,6 +3363,7 @@ mod selection_gpui_tests {
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
run_on_land: None,
|
||||
owner: None,
|
||||
font_size: 14.0,
|
||||
},
|
||||
|
||||
@@ -569,6 +569,7 @@ mod gpui_tests {
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
run_on_land: None,
|
||||
owner: None,
|
||||
font_size: 14.0,
|
||||
},
|
||||
|
||||
+106
-5
@@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::core::ssh_profile::ForwardRule;
|
||||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule};
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::ui::app::{CONTENT_INSET, Tty7App};
|
||||
@@ -70,6 +71,7 @@ impl ForwardFields {
|
||||
target_host,
|
||||
target_port,
|
||||
description: (!description.is_empty()).then(|| description.to_string()),
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +95,7 @@ impl ForwardFields {
|
||||
// left over from a trip through the advanced form is not something
|
||||
// this rule was given.
|
||||
description: None,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule {
|
||||
target_host: forward.target_host.clone(),
|
||||
target_port: forward.target_port,
|
||||
description: forward.description.clone(),
|
||||
enabled: forward.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of a saved host's rules a live forward was opened from, among
|
||||
/// those not already switched to `enabled` — the one a switch in the panel
|
||||
/// has to be written back to.
|
||||
///
|
||||
/// Matched on the whole mapping, since a live forward carries no reference to
|
||||
/// the rule it came from. A saved rule always names its port, so the port the
|
||||
/// forward bound is the one the rule asked for. A forward added in the panel
|
||||
/// matches nothing and stays as temporary as it always was.
|
||||
pub(crate) fn saved_rule_index(
|
||||
rules: &[ForwardRule],
|
||||
forward: &ManagedForward,
|
||||
enabled: bool,
|
||||
) -> Option<usize> {
|
||||
rules.iter().position(|r| {
|
||||
let saved = crate::ui::ssh_connect::map_forward(r);
|
||||
saved.enabled != enabled
|
||||
&& saved.kind == forward.kind
|
||||
&& saved.bind_host == forward.bind_host
|
||||
&& saved.bind_port == forward.bind_port
|
||||
&& saved.target_host == forward.target_host
|
||||
&& saved.target_port == forward.target_port
|
||||
})
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
pub(crate) fn render_ssh_status_strip(
|
||||
&self,
|
||||
@@ -254,18 +282,23 @@ impl Tty7App {
|
||||
SshForwardKind::Remote => "R",
|
||||
SshForwardKind::Dynamic => "D",
|
||||
};
|
||||
let errored = matches!(forward.status, ForwardStatus::Error(_));
|
||||
// A switched-off forward has an error status only because it binds
|
||||
// nothing; it is drawn faded, not red.
|
||||
let off = !forward.enabled;
|
||||
let errored = !off && matches!(forward.status, ForwardStatus::Error(_));
|
||||
let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") {
|
||||
forward.bind_port.to_string()
|
||||
} else {
|
||||
format!("{}:{}", forward.bind_host, forward.bind_port)
|
||||
};
|
||||
let mapping = match forward.kind {
|
||||
SshForwardKind::Dynamic => "SOCKS".to_string(),
|
||||
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
|
||||
};
|
||||
let tail = match &forward.status {
|
||||
_ if off => mapping,
|
||||
ForwardStatus::Error(msg) => msg.clone(),
|
||||
ForwardStatus::Listening => match forward.kind {
|
||||
SshForwardKind::Dynamic => "SOCKS".to_string(),
|
||||
_ => format!("→ {}:{}", forward.target_host, forward.target_port),
|
||||
},
|
||||
ForwardStatus::Listening => mapping,
|
||||
};
|
||||
let pane_id = forward.pane_id;
|
||||
let forward_id = forward.id;
|
||||
@@ -295,6 +328,7 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap(px(1.))
|
||||
.when(off, |col| col.opacity(0.5))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
@@ -326,6 +360,33 @@ impl Tty7App {
|
||||
)
|
||||
}),
|
||||
)
|
||||
// Always shown, unlike Remove beside it: whether a forward is on is
|
||||
// state to read at a glance, not an action to go looking for.
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(
|
||||
crate::ui::theme::switch(
|
||||
("panel-forward-enabled", forward_id as usize),
|
||||
cx,
|
||||
)
|
||||
.checked(forward.enabled)
|
||||
.xsmall()
|
||||
.tooltip(if off {
|
||||
t(L10nKey::ForwardTooltipTurnOn)
|
||||
} else {
|
||||
t(L10nKey::ForwardTooltipTurnOff)
|
||||
})
|
||||
.on_click(cx.listener(
|
||||
move |this, on: &bool, window, cx| {
|
||||
this.set_managed_forward_enabled(
|
||||
pane_id, forward_id, *on, window, cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
@@ -586,6 +647,7 @@ mod tests {
|
||||
target_port: 80,
|
||||
description: Some("the staging box".to_string()),
|
||||
status: ForwardStatus::Listening,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,4 +780,43 @@ mod tests {
|
||||
"nothing was added, so there is nothing to point at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switched_off_forward_keeps_its_switch_through_an_edit() {
|
||||
let mut off = managed(3, 8080);
|
||||
off.enabled = false;
|
||||
assert!(!rule_of(&off).enabled);
|
||||
assert!(rule_of(&managed(3, 8080)).enabled);
|
||||
}
|
||||
|
||||
/// The panel's switch is written back to the saved host only for the rule
|
||||
/// the forward was opened from, and a rule added in the panel — which no
|
||||
/// saved rule describes — is left as temporary as it always was.
|
||||
#[test]
|
||||
fn a_switch_is_written_back_to_the_saved_rule_it_came_from() {
|
||||
use crate::core::ssh_profile::{ForwardKind, HostPort};
|
||||
let saved = |target: &str, enabled: bool| ForwardRule {
|
||||
kind: ForwardKind::Local,
|
||||
bind: HostPort::new("127.0.0.1", 8080),
|
||||
target: HostPort::new(target, 80),
|
||||
description: "the staging box".to_string(),
|
||||
enabled,
|
||||
};
|
||||
let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)];
|
||||
let live = managed(3, 8080);
|
||||
assert_eq!(saved_rule_index(&rules, &live, false), Some(1));
|
||||
assert_eq!(
|
||||
saved_rule_index(&rules, &live, true),
|
||||
None,
|
||||
"already on: nothing to write"
|
||||
);
|
||||
|
||||
let mut elsewhere = managed(3, 8081);
|
||||
elsewhere.description = None;
|
||||
assert_eq!(saved_rule_index(&rules, &elsewhere, false), None);
|
||||
|
||||
let mut remote = managed(3, 8080);
|
||||
remote.kind = SshForwardKind::Remote;
|
||||
assert_eq!(saved_rule_index(&rules, &remote, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +496,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&tab).as_deref(), Some("build"));
|
||||
@@ -508,6 +510,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
|
||||
@@ -517,6 +521,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
|
||||
@@ -529,6 +535,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: SessionPane::Split {
|
||||
axis: crate::core::session::SessionAxis::Horizontal,
|
||||
ratio: 0.5,
|
||||
@@ -546,6 +554,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(None),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&unnamed), None);
|
||||
@@ -554,6 +564,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(Some("/")),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&root), None);
|
||||
@@ -566,6 +578,8 @@ mod tests {
|
||||
tree_id: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
hibernated: false,
|
||||
asleep_view: None,
|
||||
pane: leaf(None),
|
||||
};
|
||||
let label = closed_tab_label(&tab).unwrap();
|
||||
|
||||
@@ -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",
|
||||
@@ -940,6 +947,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SettingsSearchSidebarGroupingKeywords => {
|
||||
"tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder"
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -1019,6 +1029,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SshEditProfile => "Edit connection…",
|
||||
L10nKey::ForwardTooltipAdd => "Add forward",
|
||||
L10nKey::ForwardTooltipRemove => "Remove",
|
||||
L10nKey::ForwardTooltipTurnOn => "Turn on",
|
||||
L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept",
|
||||
L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}",
|
||||
L10nKey::SettingsFwdEnabled => "Open this rule with the connection",
|
||||
L10nKey::ForwardLocal => "Local",
|
||||
L10nKey::ForwardRemote => "Remote",
|
||||
L10nKey::ForwardDynamic => "Dynamic",
|
||||
@@ -1214,6 +1228,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 => {
|
||||
@@ -1509,9 +1527,15 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory",
|
||||
L10nKey::CmdCopySessionId => "Copy Session ID",
|
||||
L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id",
|
||||
L10nKey::CmdNewAgentTab => "New Agent Tab",
|
||||
L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab",
|
||||
L10nKey::CmdForkSession => "Fork Session",
|
||||
L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab",
|
||||
L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread",
|
||||
L10nKey::CmdHibernateTab => "Hibernate Tab",
|
||||
L10nKey::CmdHibernateTabSubtitle => {
|
||||
"stop its processes to free memory; selecting it wakes it"
|
||||
}
|
||||
L10nKey::CmdClosePaneTab => "Close Pane / Tab",
|
||||
L10nKey::CmdCloseWindow => "Close Window",
|
||||
L10nKey::CmdCloseWindowSubtitle => "shells keep running",
|
||||
@@ -1670,6 +1694,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
"No uncommitted changes in {cwd} (or not a git repository)."
|
||||
}
|
||||
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
|
||||
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
|
||||
L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH",
|
||||
L10nKey::AppNoAgentSeenHere => {
|
||||
"No coding agent has run in this workspace yet — start one by hand once and it will be offered here"
|
||||
}
|
||||
L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}",
|
||||
L10nKey::AppAgentLaunchArgsUnknown => {
|
||||
"{name} did not report the arguments it was started with"
|
||||
}
|
||||
L10nKey::AppCmdShellTitle => "Shell: {title}",
|
||||
L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}",
|
||||
L10nKey::AppPlaceholderDescription => "description",
|
||||
L10nKey::AppPlaceholderSshQuickConnect => "user@host or user@host:port",
|
||||
@@ -1828,6 +1862,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory",
|
||||
L10nKey::AppMenuCopySessionId => "Copy Session ID",
|
||||
L10nKey::AppMenuForkSession => "Fork Session",
|
||||
L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default",
|
||||
L10nKey::AppMenuClosePaneTab => "Close",
|
||||
L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs",
|
||||
L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right",
|
||||
@@ -1886,6 +1921,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::TabMenuLocalShells => "Local",
|
||||
L10nKey::TabMenuAddHost => "Add SSH Host…",
|
||||
L10nKey::TabMenuAllHosts => "All SSH Hosts…",
|
||||
L10nKey::TabMenuLaunchAgent => "Launch Agent…",
|
||||
L10nKey::TabMenuOtherShells => "Other Shells…",
|
||||
L10nKey::TabMenuSplitHint => "Hold {key} to split",
|
||||
L10nKey::TabUnnamedShell => "Shell {n}",
|
||||
L10nKey::ShellDefault => "default",
|
||||
@@ -1912,6 +1949,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SettingsNoneLower => "none",
|
||||
L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool",
|
||||
L10nKey::TabContextMarkUnread => "Mark as Unread",
|
||||
L10nKey::TabContextHibernate => "Hibernate",
|
||||
L10nKey::TabContextWake => "Wake",
|
||||
L10nKey::TabTooltipAsleep => "Hibernated — select to wake",
|
||||
L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => "通知",
|
||||
@@ -1002,6 +1009,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsSearchSidebarGroupingKeywords => {
|
||||
"タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped"
|
||||
}
|
||||
L10nKey::SettingsSearchSshTabTitleKeywords => {
|
||||
"SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin"
|
||||
}
|
||||
L10nKey::SettingsSearchSmartSelectionKeywords => {
|
||||
"ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email"
|
||||
}
|
||||
@@ -1085,6 +1095,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SshEditProfile => "接続を編集…",
|
||||
L10nKey::ForwardTooltipAdd => "フォワードを追加",
|
||||
L10nKey::ForwardTooltipRemove => "削除",
|
||||
L10nKey::ForwardTooltipTurnOn => "オンにする",
|
||||
L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります",
|
||||
L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}",
|
||||
L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く",
|
||||
L10nKey::ForwardLocal => "ローカル",
|
||||
L10nKey::ForwardRemote => "リモート",
|
||||
L10nKey::ForwardDynamic => "ダイナミック",
|
||||
@@ -1288,6 +1302,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} 件のみ表示しています。"
|
||||
}
|
||||
@@ -1573,9 +1591,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー",
|
||||
L10nKey::CmdCopySessionId => "セッション ID をコピー",
|
||||
L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID",
|
||||
L10nKey::CmdNewAgentTab => "新しいエージェントタブ",
|
||||
L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く",
|
||||
L10nKey::CmdForkSession => "セッションをフォーク",
|
||||
L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク",
|
||||
L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク",
|
||||
L10nKey::CmdHibernateTab => "タブを休止",
|
||||
L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰",
|
||||
L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる",
|
||||
L10nKey::CmdCloseWindow => "ウィンドウを閉じる",
|
||||
L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続",
|
||||
@@ -1742,6 +1764,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"{cwd} に未コミットの変更はありません(または git リポジトリではありません)"
|
||||
}
|
||||
L10nKey::AppCmdSshProfileTitle => "SSH: {title}",
|
||||
L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}",
|
||||
L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません",
|
||||
L10nKey::AppNoAgentSeenHere => {
|
||||
"このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます"
|
||||
}
|
||||
L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}",
|
||||
L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません",
|
||||
L10nKey::AppCmdShellTitle => "Shell: {title}",
|
||||
L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}",
|
||||
L10nKey::AppPlaceholderDescription => "説明",
|
||||
L10nKey::AppPlaceholderSshQuickConnect => "user@host または user@host:port",
|
||||
@@ -1904,6 +1934,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー",
|
||||
L10nKey::AppMenuCopySessionId => "セッション ID をコピー",
|
||||
L10nKey::AppMenuForkSession => "セッションをフォーク",
|
||||
L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定",
|
||||
L10nKey::AppMenuClosePaneTab => "閉じる",
|
||||
L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる",
|
||||
L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる",
|
||||
@@ -1962,6 +1993,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TabMenuLocalShells => "ローカル",
|
||||
L10nKey::TabMenuAddHost => "SSH ホストを追加…",
|
||||
L10nKey::TabMenuAllHosts => "すべての SSH ホスト…",
|
||||
L10nKey::TabMenuLaunchAgent => "エージェントを起動…",
|
||||
L10nKey::TabMenuOtherShells => "その他のシェル…",
|
||||
L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割",
|
||||
L10nKey::TabUnnamedShell => "シェル {n}",
|
||||
L10nKey::ShellDefault => "デフォルト",
|
||||
@@ -1988,6 +2021,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsNoneLower => "なし",
|
||||
L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール",
|
||||
L10nKey::TabContextMarkUnread => "未読としてマーク",
|
||||
L10nKey::TabContextHibernate => "休止",
|
||||
L10nKey::TabContextWake => "復帰",
|
||||
L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰",
|
||||
L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -498,6 +498,11 @@ l10n_keys! {
|
||||
SettingsSidebarGroupingDesc,
|
||||
SettingsDiffPreviewFromCounts,
|
||||
SettingsDiffPreviewFromCountsDesc,
|
||||
SettingsSshTabTitle,
|
||||
SettingsSshTabTitleDesc,
|
||||
SettingsSshTabTitleDynamic,
|
||||
SettingsSshTabTitleProfileName,
|
||||
SettingsSshTabTitleHostname,
|
||||
DocumentDock,
|
||||
DocumentFill,
|
||||
SettingsNotifications,
|
||||
@@ -682,6 +687,7 @@ l10n_keys! {
|
||||
SettingsSearchScrollbackKeywords,
|
||||
SettingsSearchShowTrayIconKeywords,
|
||||
SettingsSearchSidebarGroupingKeywords,
|
||||
SettingsSearchSshTabTitleKeywords,
|
||||
SettingsSearchSmartSelectionKeywords,
|
||||
SettingsSearchStartInKeywords,
|
||||
SettingsSearchSyncWithSystemKeywords,
|
||||
@@ -736,6 +742,10 @@ l10n_keys! {
|
||||
SshEditProfile,
|
||||
ForwardTooltipAdd,
|
||||
ForwardTooltipRemove,
|
||||
ForwardTooltipTurnOn,
|
||||
ForwardTooltipTurnOff,
|
||||
ForwardSwitchFailed,
|
||||
SettingsFwdEnabled,
|
||||
ForwardLocal,
|
||||
ForwardRemote,
|
||||
ForwardDynamic,
|
||||
@@ -911,6 +921,10 @@ l10n_keys! {
|
||||
ScmShowLess,
|
||||
ScmCommitNotFound,
|
||||
ScmTooManyChanges,
|
||||
ScmFilterChanges,
|
||||
ScmViewAsTree,
|
||||
ScmViewAsList,
|
||||
ScmNoMatchingChanges,
|
||||
ScmOpenChanges,
|
||||
ScmDiscardAllConfirm,
|
||||
ScmAmendConfirm,
|
||||
@@ -998,6 +1012,7 @@ l10n_keys! {
|
||||
AppMenuCopyWorkingDirectory,
|
||||
AppMenuCopySessionId,
|
||||
AppMenuForkSession,
|
||||
AppMenuSaveAgentLaunchArgs,
|
||||
AppMenuClosePaneTab,
|
||||
AppMenuCloseOtherTabs,
|
||||
AppMenuCloseTabsRight,
|
||||
@@ -1057,6 +1072,8 @@ l10n_keys! {
|
||||
TabMenuLocalShells,
|
||||
TabMenuAddHost,
|
||||
TabMenuAllHosts,
|
||||
TabMenuLaunchAgent,
|
||||
TabMenuOtherShells,
|
||||
TabMenuSplitHint,
|
||||
TabUnnamedShell,
|
||||
ShellDefault,
|
||||
@@ -1076,6 +1093,10 @@ l10n_keys! {
|
||||
TabContextCloseTab,
|
||||
TabContextCloseTabsBelow,
|
||||
TabContextMarkUnread,
|
||||
TabContextHibernate,
|
||||
TabContextWake,
|
||||
TabTooltipAsleep,
|
||||
TabWakeFailed,
|
||||
RemoteStripDisconnected,
|
||||
RemoteStripConnecting,
|
||||
RemoteStripReconnecting,
|
||||
@@ -1222,9 +1243,13 @@ l10n_keys! {
|
||||
CmdCopyWorkingDirectory,
|
||||
CmdCopySessionId,
|
||||
CmdCopySessionIdSubtitle,
|
||||
CmdNewAgentTab,
|
||||
CmdNewAgentTabSubtitle,
|
||||
CmdForkSession,
|
||||
CmdForkSessionSubtitle,
|
||||
CmdMarkTabAsUnread,
|
||||
CmdHibernateTab,
|
||||
CmdHibernateTabSubtitle,
|
||||
CmdClosePaneTab,
|
||||
CmdCloseWindow,
|
||||
CmdCloseWindowSubtitle,
|
||||
@@ -1351,6 +1376,12 @@ l10n_keys! {
|
||||
AppPaneNoKnownDirectory,
|
||||
AppNoUncommittedChanges,
|
||||
AppCmdSshProfileTitle,
|
||||
AppCmdAgentLaunchTitle,
|
||||
AppNoAgentOnPath,
|
||||
AppNoAgentSeenHere,
|
||||
AppAgentLaunchSaved,
|
||||
AppAgentLaunchArgsUnknown,
|
||||
AppCmdShellTitle,
|
||||
AppCmdSwitchToTab,
|
||||
AppPlaceholderDescription,
|
||||
AppPlaceholderSshQuickConnect,
|
||||
@@ -1611,6 +1642,7 @@ mod tests {
|
||||
L10nKey::Ok,
|
||||
// Pure templates: every word in them is a placeholder.
|
||||
L10nKey::AppCmdSshProfileTitle,
|
||||
L10nKey::AppCmdShellTitle,
|
||||
L10nKey::HostOpsError,
|
||||
L10nKey::SftpTransferProgress,
|
||||
// Product names.
|
||||
@@ -1652,6 +1684,9 @@ mod tests {
|
||||
// translation here would be less clear, not more.
|
||||
L10nKey::SettingsShell,
|
||||
L10nKey::CmdGroupAgents,
|
||||
// The quick-launch rows, which the New Tab menu finds by typing
|
||||
// this one word into the palette in every locale.
|
||||
L10nKey::AppCmdAgentLaunchTitle,
|
||||
];
|
||||
|
||||
for &key in KEPT_IN_ENGLISH {
|
||||
|
||||
@@ -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 => "通知",
|
||||
@@ -893,6 +900,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsSearchSidebarGroupingKeywords => {
|
||||
"自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped"
|
||||
}
|
||||
L10nKey::SettingsSearchSshTabTitleKeywords => {
|
||||
"SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin"
|
||||
}
|
||||
L10nKey::SettingsSearchSmartSelectionKeywords => {
|
||||
"智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click"
|
||||
}
|
||||
@@ -974,6 +984,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SshEditProfile => "编辑连接…",
|
||||
L10nKey::ForwardTooltipAdd => "添加转发",
|
||||
L10nKey::ForwardTooltipRemove => "移除",
|
||||
L10nKey::ForwardTooltipTurnOn => "开启",
|
||||
L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留",
|
||||
L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}",
|
||||
L10nKey::SettingsFwdEnabled => "连接时开启这条规则",
|
||||
L10nKey::ForwardLocal => "本地",
|
||||
L10nKey::ForwardRemote => "远程",
|
||||
L10nKey::ForwardDynamic => "动态",
|
||||
@@ -1154,6 +1168,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 => {
|
||||
@@ -1416,9 +1434,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdCopyWorkingDirectory => "复制工作目录",
|
||||
L10nKey::CmdCopySessionId => "复制会话 ID",
|
||||
L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID",
|
||||
L10nKey::CmdNewAgentTab => "新建 Agent 标签页",
|
||||
L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent",
|
||||
L10nKey::CmdForkSession => "Fork 会话",
|
||||
L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页",
|
||||
L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读",
|
||||
L10nKey::CmdHibernateTab => "休眠标签页",
|
||||
L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒",
|
||||
L10nKey::CmdClosePaneTab => "关闭窗格/标签页",
|
||||
L10nKey::CmdCloseWindow => "关闭窗口",
|
||||
L10nKey::CmdCloseWindowSubtitle => "shell 保持运行",
|
||||
@@ -1571,6 +1593,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。",
|
||||
L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。",
|
||||
L10nKey::AppCmdSshProfileTitle => "SSH:{title}",
|
||||
L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}",
|
||||
L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent",
|
||||
L10nKey::AppNoAgentSeenHere => {
|
||||
"此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它"
|
||||
}
|
||||
L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}",
|
||||
L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数",
|
||||
L10nKey::AppCmdShellTitle => "Shell:{title}",
|
||||
L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}",
|
||||
L10nKey::AppPlaceholderDescription => "描述",
|
||||
L10nKey::AppPlaceholderSshQuickConnect => "user@host 或 user@host:port",
|
||||
@@ -1723,6 +1753,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录",
|
||||
L10nKey::AppMenuCopySessionId => "复制会话 ID",
|
||||
L10nKey::AppMenuForkSession => "Fork 会话",
|
||||
L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认",
|
||||
L10nKey::AppMenuClosePaneTab => "关闭",
|
||||
L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页",
|
||||
L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页",
|
||||
@@ -1781,6 +1812,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TabMenuLocalShells => "本地",
|
||||
L10nKey::TabMenuAddHost => "添加 SSH 主机…",
|
||||
L10nKey::TabMenuAllHosts => "所有 SSH 主机…",
|
||||
L10nKey::TabMenuLaunchAgent => "启动 Agent…",
|
||||
L10nKey::TabMenuOtherShells => "其他 Shell…",
|
||||
L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开",
|
||||
L10nKey::TabUnnamedShell => "终端 {n}",
|
||||
L10nKey::ShellDefault => "默认",
|
||||
@@ -1807,6 +1840,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsNoneLower => "无",
|
||||
L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具",
|
||||
L10nKey::TabContextMarkUnread => "标记为未读",
|
||||
L10nKey::TabContextHibernate => "休眠",
|
||||
L10nKey::TabContextWake => "唤醒",
|
||||
L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒",
|
||||
L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
|
||||
}
|
||||
|
||||
pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
let mut bindings = shipped_bindings();
|
||||
// One slot per agent, unbound: the palette's `Agent: …` rows are what these
|
||||
// run, and each takes a key the same way any other action does. Right
|
||||
// after the other agent actions, so the Keybindings page keeps them together.
|
||||
let at = bindings
|
||||
.iter()
|
||||
.position(|(action, _)| *action == "NewAgentTab")
|
||||
.map_or(bindings.len(), |i| i + 1);
|
||||
bindings.splice(
|
||||
at..at,
|
||||
crate::ui::agent_launch::launch_action_names()
|
||||
.iter()
|
||||
.map(|(_, name)| (name.as_str(), "")),
|
||||
);
|
||||
bindings
|
||||
}
|
||||
|
||||
fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
|
||||
("NewWorkspace", "secondary-shift-n"),
|
||||
@@ -325,12 +343,18 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
("CloseTabsToTheRight", ""),
|
||||
("CopyWorkingDirectory", ""),
|
||||
("MarkTabUnread", ""),
|
||||
("HibernateTab", ""),
|
||||
("ForkAgentSession", ""),
|
||||
("ForkAgentSessionRight", ""),
|
||||
("ForkAgentSessionLeft", ""),
|
||||
("ForkAgentSessionDown", ""),
|
||||
("ForkAgentSessionUp", ""),
|
||||
("CopyAgentSessionId", ""),
|
||||
// ⌘⇧A for "agent" on macOS, where nothing else in the table or the
|
||||
// terminal holds it. Off macOS Ctrl+Shift+A is select-all in every
|
||||
// Linux terminal and in this prompt editor's Ctrl+A family, so it ships
|
||||
// unbound there rather than on a chord nobody would guess.
|
||||
("NewAgentTab", per_platform("secondary-shift-a", "")),
|
||||
("StopWorkspace", ""),
|
||||
("DeleteWorkspace", ""),
|
||||
("RenameWorkspace", ""),
|
||||
@@ -578,6 +602,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]),
|
||||
));
|
||||
}
|
||||
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
|
||||
return Some((
|
||||
CommandGroup::Agents,
|
||||
t_fmt(
|
||||
L10nKey::AppCmdAgentLaunchTitle,
|
||||
&[("name", agent.display_name())],
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(n) = action.strip_prefix("SelectWorkspace") {
|
||||
return Some((
|
||||
CommandGroup::Workspaces,
|
||||
@@ -614,6 +647,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
CommandGroup::TabsPanes,
|
||||
t(L10nKey::CmdMarkTabAsUnread).to_string(),
|
||||
),
|
||||
"HibernateTab" => (
|
||||
CommandGroup::TabsPanes,
|
||||
t(L10nKey::CmdHibernateTab).to_string(),
|
||||
),
|
||||
"ReopenClosedTab" => (
|
||||
CommandGroup::TabsPanes,
|
||||
t(L10nKey::CmdReopenClosedTab).to_string(),
|
||||
@@ -826,6 +863,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
CommandGroup::Agents,
|
||||
t(L10nKey::CmdCopySessionId).to_string(),
|
||||
),
|
||||
"NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()),
|
||||
"TogglePalette" => (
|
||||
CommandGroup::Application,
|
||||
t(L10nKey::AppMenuCommandPalette).to_string(),
|
||||
@@ -1343,6 +1381,9 @@ fn action_context(action: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
|
||||
return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None));
|
||||
}
|
||||
Some(match action {
|
||||
"NewTab" => KeyBinding::new(keystroke, NewTab, None),
|
||||
"NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None),
|
||||
@@ -1359,12 +1400,14 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
|
||||
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
|
||||
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
|
||||
"HibernateTab" => KeyBinding::new(keystroke, HibernateTab, None),
|
||||
"ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None),
|
||||
"ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None),
|
||||
"ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None),
|
||||
"ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None),
|
||||
"ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None),
|
||||
"CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None),
|
||||
"NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None),
|
||||
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
|
||||
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
|
||||
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
|
||||
@@ -2315,6 +2358,50 @@ mod tests {
|
||||
let ks = Keystroke::parse("secondary").unwrap();
|
||||
assert_eq!(spec_from_keystroke(&ks), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_agent_tab_ships_a_chord_only_where_one_is_free() {
|
||||
let mut effective: Vec<(String, String)> = default_bindings()
|
||||
.into_iter()
|
||||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||||
.collect();
|
||||
let default = effective
|
||||
.iter()
|
||||
.find(|(action, _)| action == "NewAgentTab")
|
||||
.map(|(_, key)| key.clone())
|
||||
.expect("NewAgentTab has to be listed or it cannot be bound");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(default, "secondary-shift-a");
|
||||
assert_eq!(
|
||||
dispatched(&effective, &default, "Terminal"),
|
||||
vec![NewAgentTab::name_for_type()],
|
||||
"{default} must reach NewAgentTab, and nothing else may answer it"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS");
|
||||
}
|
||||
|
||||
// Each agent's own launch is a slot of its own, and a key bound to it
|
||||
// dispatches that agent.
|
||||
let name = crate::ui::agent_launch::launch_action_name(
|
||||
tty7_core::core::cli_agent::CLIAgent::Codex,
|
||||
);
|
||||
effective
|
||||
.iter_mut()
|
||||
.find(|(action, _)| action == name)
|
||||
.expect("every agent has a keymap slot")
|
||||
.1 = "ctrl-alt-shift-c".to_string();
|
||||
let mut keymap = gpui::Keymap::default();
|
||||
keymap.add_bindings(action_bindings(&effective));
|
||||
let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()];
|
||||
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
|
||||
let hits = keymap.bindings_for_input(&typed, &context).0;
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].action().partial_eq(&LaunchAgent {
|
||||
agent: tty7_core::core::cli_agent::CLIAgent::Codex,
|
||||
}));
|
||||
assert_eq!(action_entry(name).0, CommandGroup::Agents);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -49,6 +49,15 @@ impl LocalLink {
|
||||
link.client.as_ref().filter(|c| c.is_connected()).cloned()
|
||||
}
|
||||
|
||||
/// Whether the local daemon advertised `feature` on its control hello —
|
||||
/// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports)
|
||||
/// for this machine. `false` while the link is down, for the same reason.
|
||||
pub fn supports(cx: &App, feature: &str) -> bool {
|
||||
cx.try_global::<LocalLink>()
|
||||
.and_then(|link| link.client.as_ref())
|
||||
.is_some_and(|c| c.is_connected() && c.hello().has_feature(feature))
|
||||
}
|
||||
|
||||
/// Drops the cached client without waiting for its reader to notice.
|
||||
///
|
||||
/// `ControlClient::is_connected` only flips once the reader sees EOF, so
|
||||
|
||||
@@ -969,6 +969,7 @@ mod tests {
|
||||
a: Box::new(PaneNode::Leaf { pane: 1 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 2 }),
|
||||
},
|
||||
hibernated: false,
|
||||
};
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod agent_launch;
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod code_editor;
|
||||
@@ -27,6 +28,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;
|
||||
|
||||
+26
-2
@@ -35,8 +35,11 @@ pub enum CommandKind {
|
||||
CloseTabsToTheRight,
|
||||
CopyWorkingDirectory,
|
||||
MarkTabUnread,
|
||||
HibernateTab,
|
||||
ForkAgentSession,
|
||||
CopyAgentSessionId,
|
||||
NewAgentTab,
|
||||
LaunchAgent(crate::core::cli_agent::CLIAgent),
|
||||
ResetFontSize,
|
||||
NextPane,
|
||||
PrevPane,
|
||||
@@ -108,6 +111,9 @@ pub enum CommandKind {
|
||||
ActivateTab(usize),
|
||||
ConnectSavedProfile(Uuid),
|
||||
EditSavedProfile(Uuid),
|
||||
/// Open the shell the window's inventory lists under this label, as the
|
||||
/// New Tab menu's row for it would.
|
||||
OpenShell(String),
|
||||
SaveSshSessionAsHost,
|
||||
QuickConnect(String),
|
||||
SaveQuickConnect(String),
|
||||
@@ -145,8 +151,10 @@ impl CommandKind {
|
||||
CloseTabsToTheRight => "close-tabs-right",
|
||||
CopyWorkingDirectory => "copy-cwd",
|
||||
MarkTabUnread => "mark-tab-unread",
|
||||
HibernateTab => "hibernate-tab",
|
||||
ForkAgentSession => "fork-agent-session",
|
||||
CopyAgentSessionId => "copy-agent-session-id",
|
||||
NewAgentTab => "new-agent-tab",
|
||||
ResetFontSize => "reset-font-size",
|
||||
NextPane => "next-pane",
|
||||
PrevPane => "prev-pane",
|
||||
@@ -224,8 +232,10 @@ impl CommandKind {
|
||||
| ActivateTab(_)
|
||||
| ConnectSavedProfile(_)
|
||||
| EditSavedProfile(_)
|
||||
| OpenShell(_)
|
||||
| QuickConnect(_)
|
||||
| SaveQuickConnect(_) => return None,
|
||||
| SaveQuickConnect(_)
|
||||
| LaunchAgent(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,6 +248,12 @@ impl CommandKind {
|
||||
CutText => return inline("secondary-x"),
|
||||
PasteText => return inline("secondary-v"),
|
||||
SelectAllText => return inline("secondary-a"),
|
||||
LaunchAgent(agent) => {
|
||||
return crate::ui::keymap::effective_key(
|
||||
crate::ui::agent_launch::launch_action_name(*agent),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let action = match self {
|
||||
@@ -257,8 +273,10 @@ impl CommandKind {
|
||||
CloseTabsToTheRight => "CloseTabsToTheRight",
|
||||
CopyWorkingDirectory => "CopyWorkingDirectory",
|
||||
MarkTabUnread => "MarkTabUnread",
|
||||
HibernateTab => "HibernateTab",
|
||||
ForkAgentSession => "ForkAgentSession",
|
||||
CopyAgentSessionId => "CopyAgentSessionId",
|
||||
NewAgentTab => "NewAgentTab",
|
||||
ResetFontSize => "ResetFontSize",
|
||||
NextPane => "FocusNextPane",
|
||||
PrevPane => "FocusPrevPane",
|
||||
@@ -340,9 +358,11 @@ impl CommandKind {
|
||||
| ActivateTab(_)
|
||||
| ConnectSavedProfile(_)
|
||||
| EditSavedProfile(_)
|
||||
| OpenShell(_)
|
||||
| SaveSshSessionAsHost
|
||||
| QuickConnect(_)
|
||||
| SaveQuickConnect(_) => return None,
|
||||
| SaveQuickConnect(_)
|
||||
| LaunchAgent(_) => return None,
|
||||
};
|
||||
crate::ui::keymap::effective_key(action, cx)
|
||||
}
|
||||
@@ -480,6 +500,8 @@ impl Command {
|
||||
Command::localized(L10nKey::CmdForkSession, ForkAgentSession)
|
||||
.with_subtitle(t(L10nKey::CmdForkSessionSubtitle)),
|
||||
Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread),
|
||||
Command::localized(L10nKey::CmdHibernateTab, HibernateTab)
|
||||
.with_subtitle(t(L10nKey::CmdHibernateTabSubtitle)),
|
||||
Command::localized(L10nKey::CmdClosePaneTab, ClosePane),
|
||||
Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs),
|
||||
Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight),
|
||||
@@ -590,6 +612,8 @@ impl Command {
|
||||
];
|
||||
|
||||
let agents = [
|
||||
Command::localized(L10nKey::CmdNewAgentTab, NewAgentTab)
|
||||
.with_subtitle(t(L10nKey::CmdNewAgentTabSubtitle)),
|
||||
Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent)
|
||||
.with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)),
|
||||
Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent)
|
||||
|
||||
@@ -20,6 +20,10 @@ pub struct PendingSpawn {
|
||||
pub agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
pub agent_session_id: Option<String>,
|
||||
pub agent_launch_argv: Option<Vec<String>>,
|
||||
/// A command line to type into the shell once the pane exists, for a pane
|
||||
/// opened to run something (a quick-launched agent). Typed only into this
|
||||
/// pane, and only when it is a fresh shell rather than a restored one.
|
||||
pub run_on_land: Option<String>,
|
||||
pub owner: Option<crate::core::session::WorkspaceId>,
|
||||
pub font_size: f32,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
-2
@@ -112,8 +112,11 @@ pub(crate) const ROW_INSET: f32 = 6.;
|
||||
/// Local forwards only, and only those aimed at the far host's own loopback:
|
||||
/// a forward to some third machine happens to carry the same number, and
|
||||
/// pairing it with the port row would claim it leads somewhere it does not.
|
||||
/// Nor does a switched-off forward: it leads nowhere until it is switched back
|
||||
/// on, and its own row is where that switch is.
|
||||
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
|
||||
m.kind == crate::daemon::protocol::SshForwardKind::Local
|
||||
m.enabled
|
||||
&& m.kind == crate::daemon::protocol::SshForwardKind::Local
|
||||
&& m.target_port == port
|
||||
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
|
||||
}
|
||||
@@ -729,6 +732,18 @@ impl Tty7App {
|
||||
input: &gpui::Entity<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()
|
||||
@@ -753,6 +768,8 @@ impl Tty7App {
|
||||
// still reads as one line of chrome.
|
||||
.child(Input::new(input).appearance(false).xsmall().cleanable(true)),
|
||||
)
|
||||
.when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm())))
|
||||
.children(trailing)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1339,8 +1356,26 @@ impl Tty7App {
|
||||
}),
|
||||
);
|
||||
if let Some(f) = forward {
|
||||
tiles_wide += 1;
|
||||
tiles_wide += 2;
|
||||
let forward_id = f.id;
|
||||
// Switched off, the forward stops speaking for this row and
|
||||
// comes out as a row of its own with the switch to turn it
|
||||
// back on.
|
||||
actions = actions.child(
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
Button::new(("panel-port-switch-off", i))
|
||||
.icon(Icon::empty().path("icons/power.svg")),
|
||||
TILE_SIZE_XS,
|
||||
TILE_GLYPH_XS,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded(px(4.))
|
||||
.tooltip(t(L10nKey::ForwardTooltipTurnOff))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx)
|
||||
})),
|
||||
);
|
||||
actions = actions.child(
|
||||
self.info_tile(
|
||||
("panel-port-unforward", i),
|
||||
@@ -1956,6 +1991,7 @@ mod tests {
|
||||
target_port,
|
||||
description: None,
|
||||
status: ForwardStatus::Listening,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1987,6 +2023,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A switched-off forward reaches nothing, so it must not take over the
|
||||
/// port row — its own row is the only place its switch is.
|
||||
#[test]
|
||||
fn a_switched_off_forward_is_not_the_way_to_a_port() {
|
||||
let mut off = forward(SshForwardKind::Local, "localhost", 3000);
|
||||
off.enabled = false;
|
||||
assert!(!forwards_port(&off, 3000));
|
||||
}
|
||||
|
||||
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
|
||||
InfoRow {
|
||||
label: "changes",
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+67
-5
@@ -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_auto_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),
|
||||
@@ -1052,6 +1059,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
|
||||
}
|
||||
@@ -1556,6 +1564,7 @@ pub(crate) struct ForwardRuleForm {
|
||||
pub(crate) target_host: Entity<InputState>,
|
||||
pub(crate) target_port: Entity<InputState>,
|
||||
pub(crate) description: Entity<InputState>,
|
||||
pub(crate) enabled: bool,
|
||||
}
|
||||
|
||||
impl ForwardRuleForm {
|
||||
@@ -1578,6 +1587,7 @@ impl ForwardRuleForm {
|
||||
bind,
|
||||
target,
|
||||
description: val(&self.description),
|
||||
enabled: self.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2021,6 +2031,7 @@ fn seed_forward_row(
|
||||
&rule.description,
|
||||
t(L10nKey::ForwardDescriptionPlaceholder),
|
||||
),
|
||||
enabled: rule.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6229,7 +6240,7 @@ impl Tty7App {
|
||||
let count = form
|
||||
.forwards
|
||||
.iter()
|
||||
.filter(|r| r.collect(cx).is_some())
|
||||
.filter(|r| r.enabled && r.collect(cx).is_some())
|
||||
.count();
|
||||
let summary = match count {
|
||||
0 => t(L10nKey::SettingsNoneSummary).to_string(),
|
||||
@@ -6374,6 +6385,20 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_w(px(80.))
|
||||
.child(Input::new(&row.description).xsmall());
|
||||
let enabled = div().flex_shrink_0().child(
|
||||
crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx)
|
||||
.checked(row.enabled)
|
||||
.xsmall()
|
||||
.tooltip(t(L10nKey::SettingsFwdEnabled))
|
||||
.on_click(cx.listener(move |this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut()
|
||||
&& let Some(r) = f.forwards.get_mut(idx)
|
||||
{
|
||||
r.enabled = *on;
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
);
|
||||
let remove = crate::ui::tab_strip::hit_target(
|
||||
Button::new(("ssh-fwd-remove", idx))
|
||||
.icon(Icon::new(IconName::Close))
|
||||
@@ -6390,6 +6415,7 @@ impl Tty7App {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(enabled)
|
||||
.child(kind_switch)
|
||||
.child(description)
|
||||
.child(remove),
|
||||
@@ -6398,9 +6424,15 @@ impl Tty7App {
|
||||
true => mapping(v_flex().gap_1().items_start()),
|
||||
false => mapping(h_flex().gap_2().items_center()),
|
||||
}),
|
||||
false => mapping(h_flex().gap_2().items_center().child(kind_switch))
|
||||
.child(description)
|
||||
.child(remove),
|
||||
false => mapping(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(enabled)
|
||||
.child(kind_switch),
|
||||
)
|
||||
.child(description)
|
||||
.child(remove),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
@@ -7662,6 +7694,11 @@ impl Tty7App {
|
||||
};
|
||||
let sidebar_diff_preview = cfg.sidebar_diff_preview;
|
||||
let sidebar_auto_grouping = cfg.sidebar_auto_grouping;
|
||||
let ssh_tab_title_idx = match cfg.ssh_tab_title {
|
||||
SshTabTitle::Dynamic => 0,
|
||||
SshTabTitle::ProfileName => 1,
|
||||
SshTabTitle::Hostname => 2,
|
||||
};
|
||||
let notify_idx = match cfg.notify_on_command_finish {
|
||||
NotifyMode::Never => 0,
|
||||
NotifyMode::Unfocused => 1,
|
||||
@@ -7776,6 +7813,25 @@ impl Tty7App {
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
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))
|
||||
@@ -7831,6 +7887,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))
|
||||
|
||||
+123
-1
@@ -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
|
||||
@@ -574,6 +606,7 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule {
|
||||
bind: HostPort::new(rule.bind_host.clone(), rule.bind_port),
|
||||
target: HostPort::new(rule.target_host.clone(), rule.target_port),
|
||||
description: rule.description.clone().unwrap_or_default(),
|
||||
enabled: rule.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +650,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy {
|
||||
SshProxy::None
|
||||
}
|
||||
|
||||
fn map_forward(rule: &ForwardRule) -> SshForwardRule {
|
||||
pub(crate) fn map_forward(rule: &ForwardRule) -> SshForwardRule {
|
||||
SshForwardRule {
|
||||
kind: match rule.kind {
|
||||
ForwardKind::Local => SshForwardKind::Local,
|
||||
@@ -629,6 +662,7 @@ fn map_forward(rule: &ForwardRule) -> SshForwardRule {
|
||||
target_host: rule.target.host.clone(),
|
||||
target_port: rule.target.port,
|
||||
description: (!rule.description.is_empty()).then(|| rule.description.clone()),
|
||||
enabled: rule.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +857,16 @@ mod tests {
|
||||
bind: HostPort::new("localhost", 8080),
|
||||
target: HostPort::new("127.0.0.1", 80),
|
||||
description: "web".into(),
|
||||
enabled: true,
|
||||
}];
|
||||
// A switched-off rule survives the trip through a live spec switched
|
||||
// off, or reopening a host from its connection would turn it back on.
|
||||
p.forwards.push(ForwardRule {
|
||||
bind: HostPort::new("localhost", 8080),
|
||||
target: HostPort::new("10.0.0.7", 80),
|
||||
enabled: false,
|
||||
..ForwardRule::default()
|
||||
});
|
||||
|
||||
let spec = build_native_ssh_spec(&p, &[], &store, true);
|
||||
let back = profile_from_live_spec(&spec);
|
||||
@@ -1061,4 +1104,83 @@ mod tests {
|
||||
SshProxy::Http { .. }
|
||||
));
|
||||
}
|
||||
|
||||
/// #726's three sources under each mode: the saved host's own name, the
|
||||
/// alias for a `~/.ssh/config` host, and the address for a quick connect;
|
||||
/// `Hostname` always the address dialled, and `Dynamic` nothing at all.
|
||||
#[test]
|
||||
fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() {
|
||||
use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName};
|
||||
let store = InMemoryCredentialStore::new();
|
||||
|
||||
let mut saved = profile("prod-web", "10.0.0.5", "deploy");
|
||||
let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
|
||||
let profiles = vec![saved.clone()];
|
||||
assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
|
||||
Some("prod-web")
|
||||
);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
|
||||
Some("10.0.0.5")
|
||||
);
|
||||
// Renamed after it dialled: the tab follows the saved host.
|
||||
saved.name = "prod-api".into();
|
||||
assert_eq!(
|
||||
pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(),
|
||||
Some("prod-api")
|
||||
);
|
||||
// A saved host nobody named reads as its address.
|
||||
saved.name = " ".into();
|
||||
let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(),
|
||||
Some("deploy@10.0.0.5")
|
||||
);
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let ssh = root.path().join(".ssh");
|
||||
std::fs::create_dir_all(&ssh).unwrap();
|
||||
std::fs::write(
|
||||
ssh.join("config"),
|
||||
"Host bastion\n HostName 192.168.1.9\n User ops\n",
|
||||
)
|
||||
.unwrap();
|
||||
let alias = crate::core::ssh_config::resolve_alias_to_profile_from(
|
||||
ssh.join("config"),
|
||||
root.path(),
|
||||
"bastion",
|
||||
)
|
||||
.unwrap();
|
||||
let spec = native_spec_from_transient_profile(
|
||||
&alias.profile,
|
||||
alias.proxy_jump,
|
||||
&store,
|
||||
true,
|
||||
&|_| None,
|
||||
);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
|
||||
Some("bastion")
|
||||
);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
|
||||
Some("192.168.1.9")
|
||||
);
|
||||
|
||||
// What `quick_connect` builds for an address that is no alias.
|
||||
let mut typed = SshProfile::new("10.0.0.7");
|
||||
typed.host = "10.0.0.7".into();
|
||||
typed.user = "root".into();
|
||||
let spec = build_native_ssh_spec(&typed, &profiles, &store, true);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(),
|
||||
Some("10.0.0.7")
|
||||
);
|
||||
assert_eq!(
|
||||
pinned_ssh_title(Hostname, &spec, &profiles).as_deref(),
|
||||
Some("10.0.0.7")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -547,7 +547,8 @@ impl Tty7App {
|
||||
let tab = &self.tabs[i];
|
||||
let is_active = i == active;
|
||||
let ssh_dot = self.tab_ssh_dot(tab, cx);
|
||||
let agent = tab.agent(cx);
|
||||
let asleep = tab.is_asleep();
|
||||
let agent = tab.agent(cx).or_else(|| tab.asleep_agent());
|
||||
let agent_status = tab.agent_status(cx);
|
||||
let agent_unread = tab.agent_unread_count(cx);
|
||||
let git_cwd = git_click(tab, window, cx);
|
||||
@@ -562,6 +563,13 @@ impl Tty7App {
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
// The sleep mark takes the zoom mark's size; a sleeping tab
|
||||
// has no panes on screen to zoom, so the two never share a row.
|
||||
let zoom_extra = if asleep {
|
||||
zoom_extra + row_metrics::ZOOM + row_metrics::GAP
|
||||
} else {
|
||||
zoom_extra
|
||||
};
|
||||
// Elision is measured against this budget so the label and
|
||||
// branch never wrap or overflow into CSS truncation.
|
||||
let label_avail =
|
||||
@@ -973,6 +981,9 @@ impl Tty7App {
|
||||
s.text_color(cx.theme().sidebar_foreground)
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
})
|
||||
// Faded as well as marked, the way the strip's chip is: the
|
||||
// tabs holding nothing should be the quiet ones in the column.
|
||||
.when(asleep, |s| s.opacity(0.6))
|
||||
.when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| {
|
||||
s.opacity(0.75)
|
||||
})
|
||||
@@ -1021,6 +1032,9 @@ impl Tty7App {
|
||||
.when(zoomed, |row| {
|
||||
row.child(self.zoom_mark(("sidebar-zoom", i), cx))
|
||||
})
|
||||
.when(asleep, |row| {
|
||||
row.child(self.sleep_mark(("sidebar-asleep", i), cx))
|
||||
})
|
||||
.child(label_region)
|
||||
.when(show_badges && badge_pos < 9, |row| {
|
||||
row.child(
|
||||
@@ -3991,6 +4005,7 @@ mod fold_tests {
|
||||
group: None,
|
||||
last_auto: Some(AutoKey::Repo(PathBuf::from("/w/alpha"))),
|
||||
root: tty7_core::core::machine::PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
};
|
||||
app.tabs[i] = crate::ui::app::Tab::from_tree(&tree, pane);
|
||||
}
|
||||
|
||||
+413
-29
@@ -13,9 +13,10 @@ use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use crate::core::actions::{
|
||||
CloseActiveTab, CloseOtherTabs, CloseTabsToTheRight, CopyAgentSessionId, CopyWorkingDirectory,
|
||||
ForkAgentSession, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, SelectWorkspace1,
|
||||
SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6,
|
||||
SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette,
|
||||
ForkAgentSession, HibernateTab, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab,
|
||||
SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5,
|
||||
SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight,
|
||||
TogglePalette,
|
||||
};
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::core::shells::DetectedShell;
|
||||
@@ -684,17 +685,27 @@ pub(crate) fn chrome_tile_sized(
|
||||
/// closes the section is where the rest are.
|
||||
const MENU_HOSTS: usize = 6;
|
||||
|
||||
/// How many shells the New Tab menu names, the default among them.
|
||||
///
|
||||
/// The same reasoning as [`MENU_HOSTS`], at a smaller number: a stock macOS box
|
||||
/// reports nine shells and almost nobody opens more than one or two of them.
|
||||
/// The default always leads — it is the answer to "what do I get if I just
|
||||
/// click" — and the rest of the rows go to whatever has actually been opened,
|
||||
/// by frecency. Everything else is one row away, in the palette.
|
||||
const MENU_SHELLS: usize = 3;
|
||||
|
||||
/// How wide the New Tab menu is allowed to get.
|
||||
const MENU_W: Pixels = px(360.);
|
||||
|
||||
/// How tall, before it starts scrolling.
|
||||
///
|
||||
/// Enough for the menu's own full hand — the nine shells a stock macOS box
|
||||
/// reports, both headings, [`MENU_HOSTS`] hosts and the two rows that close the
|
||||
/// list, at the 26px a row occupies — so the shape everyone actually sees
|
||||
/// arrives whole. Past that (a pile of custom shells) it scrolls, and it is
|
||||
/// capped again against the window in [`NewTabMenu::build`], since a menu taller
|
||||
/// than what it hangs off is worse than one that scrolls.
|
||||
/// Both lists are capped — [`MENU_SHELLS`] shells and [`MENU_HOSTS`] hosts —
|
||||
/// so the menu's full hand is a fixed number of rows: those, both headings, the
|
||||
/// row closing each section and the modifier hint, at the 26px a row occupies.
|
||||
/// This leaves room above that for a seam row or two more, so the full hand
|
||||
/// always arrives whole and never scrolls on its own. It is capped again
|
||||
/// against the window in [`NewTabMenu::build`], since a menu taller than what
|
||||
/// it hangs off is worse than one that scrolls.
|
||||
const MENU_H: Pixels = px(560.);
|
||||
|
||||
/// What the row closing the SSH section types into the palette for you.
|
||||
@@ -715,6 +726,14 @@ const MENU_H: Pixels = px(560.);
|
||||
/// and opens the unfiltered command list has made the reader ask twice.
|
||||
const PALETTE_SSH_QUERY: &str = "ssh";
|
||||
|
||||
/// What the row closing the Local section types into the palette for you.
|
||||
///
|
||||
/// Every shell in the window's inventory is a palette command titled
|
||||
/// `Shell: {label}` ([`L10nKey::AppCmdShellTitle`], the same word in every
|
||||
/// language we ship, for the reason [`PALETTE_SSH_QUERY`] gives), so this one
|
||||
/// word lands on exactly the shells, default first and then by frecency.
|
||||
const PALETTE_SHELL_QUERY: &str = "shell";
|
||||
|
||||
/// How this platform spells the key that turns a New Tab row into a split.
|
||||
fn split_modifier() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -733,8 +752,12 @@ fn split_modifier() -> &'static str {
|
||||
/// the window sat still is in the list the next time the `+` is pressed.
|
||||
struct NewTabMenu {
|
||||
app: gpui::WeakEntity<Tty7App>,
|
||||
shells: Vec<(SharedString, ShellSpec)>,
|
||||
/// The shells the menu names, by label — at most [`MENU_SHELLS`].
|
||||
shells: Vec<SharedString>,
|
||||
default_shell: SharedString,
|
||||
/// The inventory holds shells the menu does not name, so the section
|
||||
/// closes with a row into the palette.
|
||||
more_shells: bool,
|
||||
/// Saved host, its display name, and the `user@host:port` beside it —
|
||||
/// empty when the name already says it.
|
||||
hosts: Vec<(uuid::Uuid, SharedString, SharedString)>,
|
||||
@@ -760,21 +783,17 @@ impl NewTabMenu {
|
||||
// host with a descriptive name and a long `user@host` drags every
|
||||
// other row out with it and the menu stops looking like chrome.
|
||||
.max_w(MENU_W)
|
||||
// Shells are whatever this machine has plus whatever the user
|
||||
// added by hand, so the row count has no ceiling. Past the height
|
||||
// Both lists are capped, but a short window is not. Past the height
|
||||
// of the window an un-scrollable menu simply loses its last rows —
|
||||
// and the last rows here are the SSH section.
|
||||
.scrollable(true)
|
||||
// Only the overflow case should scroll, and the default ceiling is
|
||||
// too low to tell the two apart: a stock macOS box has nine shells,
|
||||
// which with both headings, the hosts and the two closing rows
|
||||
// already runs past `PopupMenu`'s built-in 450px. The menu would
|
||||
// arrive scrolled on every machine, with `Local` cut off above.
|
||||
// Our own ceiling rather than `PopupMenu`'s built-in 450px, so the
|
||||
// one that applies is the one [`MENU_H`] reasons about.
|
||||
.max_h(ceiling)
|
||||
.item(PopupMenuItem::label(t(L10nKey::TabMenuLocalShells)));
|
||||
for (label, spec) in &self.shells {
|
||||
let spec = spec.clone();
|
||||
for label in &self.shells {
|
||||
let app = self.app.clone();
|
||||
let open = label.clone();
|
||||
let row = if *label == self.default_shell {
|
||||
let label = label.clone();
|
||||
PopupMenuItem::element(move |_window, cx| {
|
||||
@@ -786,9 +805,7 @@ impl NewTabMenu {
|
||||
menu = menu.item(row.on_click(move |_, window, cx| {
|
||||
let at = SpawnWhere::from_modifiers(window.modifiers());
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.update(cx, |this, cx| {
|
||||
this.open_shell(Some(spec.clone()), at, window, cx)
|
||||
});
|
||||
app.update(cx, |this, cx| this.open_listed_shell(&open, at, window, cx));
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -805,6 +822,22 @@ impl NewTabMenu {
|
||||
},
|
||||
));
|
||||
}
|
||||
// The rest of the inventory is in the palette, already filtered to
|
||||
// it — the same seam the SSH section closes with. Absent when the
|
||||
// rows above are the whole inventory: a row into a list of nothing
|
||||
// new would be one more thing to read for no gain.
|
||||
if self.more_shells {
|
||||
let app = self.app.clone();
|
||||
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuOtherShells)).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.update(cx, |this, cx| {
|
||||
this.open_palette(PALETTE_SHELL_QUERY, window, cx)
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
menu = menu
|
||||
.item(PopupMenuItem::separator())
|
||||
@@ -850,6 +883,19 @@ impl NewTabMenu {
|
||||
});
|
||||
}
|
||||
}));
|
||||
// The agents' seam into the palette, the same way the row above is the
|
||||
// hosts': the menu names none of them, the palette holds them all.
|
||||
let app = self.app.clone();
|
||||
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuLaunchAgent)).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.update(cx, |this, cx| {
|
||||
let query = crate::ui::agent_launch::PALETTE_AGENT_QUERY;
|
||||
this.open_palette(query, window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// The one place ⌥ is spelled out. Nothing else in the app teaches it,
|
||||
// and a modifier nobody is told about is a feature nobody has. No rule
|
||||
@@ -862,6 +908,55 @@ impl NewTabMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shell in the inventory, most likely first: the default, then whatever
|
||||
/// has been opened often and recently, then the rest in the inventory's own
|
||||
/// order. Shared by the palette and the New Tab menu so the same shell leads
|
||||
/// both lists.
|
||||
pub(crate) fn shells_by_frecency<'a>(
|
||||
shells: &'a [DetectedShell],
|
||||
default: &str,
|
||||
usage: &std::collections::HashMap<String, crate::core::config::ProfileUsage>,
|
||||
now: u64,
|
||||
) -> Vec<&'a DetectedShell> {
|
||||
let score = |s: &DetectedShell| usage.get(&s.label).map_or(0.0, |u| u.score(now));
|
||||
let mut sorted: Vec<&DetectedShell> = shells.iter().collect();
|
||||
// Stable, so shells nobody has opened keep the order the inventory chose.
|
||||
sorted.sort_by(|a, b| {
|
||||
(b.label == default)
|
||||
.cmp(&(a.label == default))
|
||||
.then_with(|| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
});
|
||||
sorted
|
||||
}
|
||||
|
||||
/// The shells the menu names, off the front of [`shells_by_frecency`]'s list:
|
||||
/// its head — the default, whenever the inventory has it — and then only
|
||||
/// shells that have actually been opened, [`MENU_SHELLS`] rows in all.
|
||||
///
|
||||
/// A shell nobody has opened is not named just because there is room: a menu
|
||||
/// that filled its spare rows with `csh` and `tcsh` would be the nine-row list
|
||||
/// again, only shorter.
|
||||
fn menu_shells<'a>(
|
||||
sorted: &[&'a DetectedShell],
|
||||
default: &str,
|
||||
usage: &std::collections::HashMap<String, crate::core::config::ProfileUsage>,
|
||||
now: u64,
|
||||
) -> Vec<&'a DetectedShell> {
|
||||
sorted
|
||||
.iter()
|
||||
.take(MENU_SHELLS)
|
||||
.enumerate()
|
||||
.filter(|(i, s)| {
|
||||
*i == 0 || s.label == default || usage.get(&s.label).is_some_and(|u| u.score(now) > 0.0)
|
||||
})
|
||||
.map(|(_, s)| *s)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The hosts the menu names, in the order they were handed over — frecency,
|
||||
/// so the ones that fit are the ones actually used.
|
||||
///
|
||||
@@ -1485,6 +1580,24 @@ impl Tty7App {
|
||||
/// Drawn in the tab entry rather than on the pane so it reads from either
|
||||
/// tab surface, and so it says something about the tabs you are *not*
|
||||
/// looking at — the zoom outlives a switch away from them.
|
||||
/// The mark a sleeping tab carries (#762), in the slot the zoom mark uses:
|
||||
/// leading, beside the other state marks, where the pointer that comes to
|
||||
/// read it does not cover it.
|
||||
pub(crate) fn sleep_mark(&self, id: impl Into<gpui::ElementId>, cx: &App) -> gpui::AnyElement {
|
||||
let tip = SharedString::from(t(L10nKey::TabTooltipAsleep));
|
||||
div()
|
||||
.id(id)
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(16.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(IconName::Moon).size(px(11.)))
|
||||
.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn zoom_mark(&self, id: impl Into<gpui::ElementId>, cx: &App) -> gpui::AnyElement {
|
||||
let tip = chord_tooltip(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx);
|
||||
div()
|
||||
@@ -1582,19 +1695,50 @@ impl Tty7App {
|
||||
/// What the menu offers, read off the app as the menu opens — the builder
|
||||
/// runs on the popup's own entity, so the rows carry a weak handle back.
|
||||
fn new_tab_menu_rows(&self, app: gpui::WeakEntity<Self>, cx: &App) -> NewTabMenu {
|
||||
let default_shell = self.default_shell_label(cx);
|
||||
let usage = &cx.global::<Config>().shell_frecency;
|
||||
let now = crate::core::config::unix_now();
|
||||
let sorted = shells_by_frecency(&self.shells.shells, &default_shell, usage, now);
|
||||
let shells = menu_shells(&sorted, &default_shell, usage, now);
|
||||
NewTabMenu {
|
||||
app,
|
||||
shells: self
|
||||
.shells
|
||||
.shells
|
||||
.iter()
|
||||
.map(|s| (SharedString::from(s.label.clone()), shell_spec(s)))
|
||||
more_shells: shells.len() < sorted.len(),
|
||||
shells: shells
|
||||
.into_iter()
|
||||
.map(|s| SharedString::from(s.label.clone()))
|
||||
.collect(),
|
||||
default_shell: SharedString::from(self.default_shell_label(cx)),
|
||||
default_shell: SharedString::from(default_shell),
|
||||
hosts: menu_hosts(crate::ui::ssh_connect::ssh_profiles_by_frecency(cx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the inventory's shell by the label it is listed under, wherever
|
||||
/// `at` says — the New Tab menu's rows and the palette's `Shell:` commands
|
||||
/// both land here, so both count toward the frecency that orders them.
|
||||
pub(crate) fn open_listed_shell(
|
||||
&mut self,
|
||||
label: &str,
|
||||
at: SpawnWhere,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(spec) = self
|
||||
.shells
|
||||
.shells
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.map(shell_spec)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.update_config(cx, |cfg| {
|
||||
let entry = cfg.shell_frecency.entry(label.to_string()).or_default();
|
||||
entry.count = entry.count.saturating_add(1);
|
||||
entry.last_used = crate::core::config::unix_now();
|
||||
});
|
||||
self.open_shell(Some(spec), at, window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn tab_context_menu(
|
||||
menu: PopupMenu,
|
||||
index: usize,
|
||||
@@ -1646,6 +1790,34 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep and wake (#762). Waking is also what selecting the tab does;
|
||||
// the item is here for waking one without leaving the tab on screen.
|
||||
// Hibernate is offered only where the machine can do it, and greyed
|
||||
// out for a tab it cannot be done to right now — the last one awake,
|
||||
// or one still connecting — rather than vanishing from under the
|
||||
// pointer that came looking for it.
|
||||
if tab.is_some_and(|t| t.is_asleep()) {
|
||||
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabContextWake)).on_click({
|
||||
let app = app.clone();
|
||||
move |_, window, cx| {
|
||||
let _ = app.update(cx, |this, cx| this.wake_tab(index, window, cx));
|
||||
}
|
||||
}));
|
||||
} else if crate::ui::tree_sync::can_hibernate_on(cx, this.workspace) {
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(t(L10nKey::TabContextHibernate))
|
||||
.action(Box::new(HibernateTab))
|
||||
.disabled(!this.can_hibernate_tab(index, cx))
|
||||
.on_click({
|
||||
let app = app.clone();
|
||||
move |_, window, cx| {
|
||||
let _ =
|
||||
app.update(cx, |this, cx| this.hibernate_tab(index, window, cx));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Where this tab sits, and where it could be put instead.
|
||||
//
|
||||
// Laid out flat rather than behind a "Move to Group ▸" submenu: there
|
||||
@@ -1945,7 +2117,8 @@ impl Tty7App {
|
||||
let label = self.tab_label(tab, i, Some(window), cx);
|
||||
let full_title = self.tab_title_tooltip(tab, i, Some(window), cx);
|
||||
let ssh_dot = self.tab_ssh_dot(tab, cx);
|
||||
let agent = tab.agent(cx);
|
||||
let asleep = tab.is_asleep();
|
||||
let agent = tab.agent(cx).or_else(|| tab.asleep_agent());
|
||||
let agent_status = tab.agent_status(cx);
|
||||
let agent_unread = tab.agent_unread_count(cx);
|
||||
let zoomed = self.tab_is_zoomed(i);
|
||||
@@ -2025,6 +2198,9 @@ impl Tty7App {
|
||||
s.text_color(cx.theme().muted_foreground)
|
||||
.hover(|s| s.bg(cx.theme().muted))
|
||||
})
|
||||
// Faded as well as marked: a row of chips is read at a glance,
|
||||
// and the tabs holding nothing should be the quiet ones.
|
||||
.when(asleep, |s| s.opacity(0.6))
|
||||
.when(dragged, |s| s.opacity(0.75))
|
||||
.child(
|
||||
canvas(
|
||||
@@ -2083,6 +2259,9 @@ impl Tty7App {
|
||||
.when(zoomed, |chip| {
|
||||
chip.child(self.zoom_mark(("tab-zoom", i), cx))
|
||||
})
|
||||
.when(asleep, |chip| {
|
||||
chip.child(self.sleep_mark(("tab-asleep", i), cx))
|
||||
})
|
||||
.child(label_region)
|
||||
.when(show_badges && i < 9, |chip| {
|
||||
chip.child(
|
||||
@@ -2421,6 +2600,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)]
|
||||
@@ -3276,4 +3533,131 @@ mod tests {
|
||||
assert_eq!(label_of(&root, 0, Some(home())), "/");
|
||||
assert_eq!(tooltip_of(&root, 0, Some(home())), None);
|
||||
}
|
||||
|
||||
fn inventory(labels: &[&str]) -> Vec<DetectedShell> {
|
||||
labels
|
||||
.iter()
|
||||
.map(|l| DetectedShell {
|
||||
label: l.to_string(),
|
||||
program: format!("/bin/{l}"),
|
||||
args: Vec::new(),
|
||||
args_are_tty7_defaults: true,
|
||||
user_authored: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn used(
|
||||
entries: &[(&str, u32, u64)],
|
||||
) -> std::collections::HashMap<String, crate::core::config::ProfileUsage> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(l, count, last_used)| {
|
||||
(
|
||||
l.to_string(),
|
||||
crate::core::config::ProfileUsage {
|
||||
count: *count,
|
||||
last_used: *last_used,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn labels(shells: &[&DetectedShell]) -> Vec<String> {
|
||||
shells.iter().map(|s| s.label.clone()).collect()
|
||||
}
|
||||
|
||||
const STOCK_MAC: &[&str] = &["zsh", "bash", "sh", "csh", "tcsh", "ksh", "dash"];
|
||||
|
||||
#[test]
|
||||
fn the_new_tab_menu_names_the_default_and_what_has_been_opened_by_frecency() {
|
||||
let now = 100_000_000u64;
|
||||
let day = 86_400u64;
|
||||
let shells = inventory(STOCK_MAC);
|
||||
// `bash` used often and lately; `dash` used more but a year ago;
|
||||
// `ksh` once, two months back; and the default opened too.
|
||||
let usage = used(&[
|
||||
("zsh", 50, now),
|
||||
("ksh", 1, now - 60 * day),
|
||||
("bash", 9, now - day),
|
||||
("dash", 20, now - 365 * day),
|
||||
]);
|
||||
let sorted = shells_by_frecency(&shells, "zsh", &usage, now);
|
||||
assert_eq!(
|
||||
labels(&sorted),
|
||||
["zsh", "bash", "dash", "ksh", "sh", "csh", "tcsh"],
|
||||
"default first, then by frecency, then the inventory's own order"
|
||||
);
|
||||
let menu = menu_shells(&sorted, "zsh", &usage, now);
|
||||
assert_eq!(menu.len(), MENU_SHELLS);
|
||||
assert_eq!(labels(&menu), ["zsh", "bash", "dash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_shell_leads_the_menu_even_when_it_has_never_been_opened() {
|
||||
let now = 100_000_000u64;
|
||||
let shells = inventory(STOCK_MAC);
|
||||
let usage = used(&[("tcsh", 3, now), ("sh", 7, now), ("ksh", 5, now)]);
|
||||
let sorted = shells_by_frecency(&shells, "bash", &usage, now);
|
||||
let menu = menu_shells(&sorted, "bash", &usage, now);
|
||||
assert_eq!(labels(&menu), ["bash", "sh", "ksh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_nobody_has_opened_is_not_named_just_because_there_is_room() {
|
||||
// A fresh install: nothing has been opened, so the menu names the
|
||||
// default alone and leaves the rest to the palette.
|
||||
let now = 100_000_000u64;
|
||||
let shells = inventory(STOCK_MAC);
|
||||
let none = used(&[]);
|
||||
let sorted = shells_by_frecency(&shells, "zsh", &none, now);
|
||||
assert_eq!(labels(&menu_shells(&sorted, "zsh", &none, now)), ["zsh"]);
|
||||
|
||||
// A usage record at zero counts as never opened.
|
||||
let zeroed = used(&[("bash", 0, now)]);
|
||||
let sorted = shells_by_frecency(&shells, "zsh", &zeroed, now);
|
||||
assert_eq!(labels(&menu_shells(&sorted, "zsh", &zeroed, now)), ["zsh"]);
|
||||
|
||||
// An inventory that does not list the default still offers its head,
|
||||
// which is the shell a new tab opens with.
|
||||
let sorted = shells_by_frecency(&shells, "fish", &none, now);
|
||||
assert_eq!(labels(&menu_shells(&sorted, "fish", &none, now)), ["zsh"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_shells_is_offered_only_when_the_inventory_holds_more_than_the_menu_names() {
|
||||
let now = 100_000_000u64;
|
||||
let more = |inv: &[&str], usage: &[(&str, u32, u64)]| {
|
||||
let shells = inventory(inv);
|
||||
let usage = used(usage);
|
||||
let default = inv.first().copied().unwrap_or("");
|
||||
let sorted = shells_by_frecency(&shells, default, &usage, now);
|
||||
menu_shells(&sorted, default, &usage, now).len() < sorted.len()
|
||||
};
|
||||
assert!(more(STOCK_MAC, &[]), "a stock box names one of seven");
|
||||
assert!(
|
||||
!more(&["pwsh"], &[]),
|
||||
"the default alone is the whole inventory"
|
||||
);
|
||||
assert!(
|
||||
!more(&["zsh", "bash"], &[("bash", 1, now)]),
|
||||
"every shell is already a row"
|
||||
);
|
||||
assert!(
|
||||
!more(
|
||||
&["zsh", "bash", "fish"],
|
||||
&[("bash", 1, now), ("fish", 2, now)]
|
||||
),
|
||||
"three shells, all named"
|
||||
);
|
||||
assert!(
|
||||
more(&["zsh", "bash", "fish"], &[("bash", 1, now)]),
|
||||
"`fish` has never been opened, so it is left to the palette"
|
||||
);
|
||||
assert!(
|
||||
!more(&[], &[]),
|
||||
"no inventory: the fallback row, nothing more"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+370
-2
@@ -51,6 +51,18 @@ fn classify_tree_link(client: Option<Arc<ControlClient>>) -> TreeLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine holding `client_ws` can put one of its tabs to sleep.
|
||||
/// A machine that cannot would take the mark and leave the shells running, or
|
||||
/// refuse it outright, so a window never offers to on its behalf.
|
||||
pub(crate) fn can_hibernate_on(cx: &App, client_ws: WorkspaceId) -> bool {
|
||||
let feature = tty7_core::daemon::control::feature::TAB_HIBERNATE;
|
||||
let host = WorkspaceStore::host_of(cx, client_ws);
|
||||
match host.is_local() {
|
||||
true => crate::ui::local_link::LocalLink::supports(cx, feature),
|
||||
false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature),
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId {
|
||||
WorkspaceStore::all(cx)
|
||||
.get(client_ws)
|
||||
@@ -66,6 +78,9 @@ pub(crate) struct DesiredTab {
|
||||
pub group: Option<GroupId>,
|
||||
pub last_auto: Option<AutoKey>,
|
||||
pub root: DesiredNode,
|
||||
/// The tab is asleep, and `root` is the layout it will wake into — the
|
||||
/// same pane ids the machine already holds, none of them running.
|
||||
pub hibernated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -121,7 +136,15 @@ pub(crate) fn desired_tabs(
|
||||
let mut active = None;
|
||||
let mut held = Vec::new();
|
||||
for (index, tab) in app.tabs.iter().enumerate() {
|
||||
let Some(root) = desired_node(&tab.pane, remote, cx) else {
|
||||
// A sleeping tab has nothing on screen to read its layout off, so it
|
||||
// is read off what it will wake into: the same panes, by the same ids,
|
||||
// which is what keeps the diff below from taking it for a tab that
|
||||
// lost them all.
|
||||
let root = match tab.asleep_layout() {
|
||||
Some(layout) => desired_node_from_session(layout, remote),
|
||||
None => desired_node(&tab.pane, remote, cx),
|
||||
};
|
||||
let Some(root) = root else {
|
||||
if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) {
|
||||
held.push(tab.tree_id.get());
|
||||
}
|
||||
@@ -141,11 +164,66 @@ pub(crate) fn desired_tabs(
|
||||
group: tab.group.get(),
|
||||
last_auto: tab.auto_group.borrow().clone(),
|
||||
root,
|
||||
hibernated: tab.asleep_layout().is_some(),
|
||||
});
|
||||
}
|
||||
(out, active, held)
|
||||
}
|
||||
|
||||
/// [`desired_node`] for a layout that is only a record — a sleeping tab's.
|
||||
/// A leaf with no id is one the machine never had, and a native SSH leaf in a
|
||||
/// remote window lives on this client rather than on the window's machine;
|
||||
/// neither is the machine's to hold, exactly as for a live tab.
|
||||
fn desired_node_from_session(layout: &SessionPane, remote_window: bool) -> Option<DesiredNode> {
|
||||
match layout {
|
||||
SessionPane::Leaf {
|
||||
cwd,
|
||||
pane_id,
|
||||
shell,
|
||||
ssh_spec,
|
||||
agent,
|
||||
agent_session_id,
|
||||
agent_launch_argv,
|
||||
} => {
|
||||
let pane = (*pane_id)?;
|
||||
if remote_window && ssh_spec.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(DesiredNode::Leaf {
|
||||
pane,
|
||||
seed: PaneSeed {
|
||||
pane,
|
||||
cwd: cwd.as_ref().map(|p| p.to_string_lossy().into_owned()),
|
||||
ssh_spec: ssh_spec.clone(),
|
||||
agent: agent.map(|agent| AgentFacts {
|
||||
agent,
|
||||
session_id: agent_session_id.clone(),
|
||||
launch_argv: agent_launch_argv.clone(),
|
||||
status: None,
|
||||
}),
|
||||
shell: shell.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
SessionPane::Split { axis, ratio, a, b } => {
|
||||
let left = desired_node_from_session(a, remote_window);
|
||||
let right = desired_node_from_session(b, remote_window);
|
||||
match (left, right) {
|
||||
(Some(a), Some(b)) => Some(DesiredNode::Split {
|
||||
axis: match axis {
|
||||
crate::core::session::SessionAxis::Horizontal => TreeAxis::Horizontal,
|
||||
crate::core::session::SessionAxis::Vertical => TreeAxis::Vertical,
|
||||
},
|
||||
ratio: *ratio,
|
||||
a: Box::new(a),
|
||||
b: Box::new(b),
|
||||
}),
|
||||
(one, other) => one.or(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool {
|
||||
match pane {
|
||||
Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(),
|
||||
@@ -512,6 +590,13 @@ fn create_tab(
|
||||
last_auto: want.last_auto.clone(),
|
||||
});
|
||||
}
|
||||
if want.hibernated {
|
||||
ops.push(ControlRequest::TabSetHibernated {
|
||||
workspace,
|
||||
tab: want.id,
|
||||
hibernated: true,
|
||||
});
|
||||
}
|
||||
mirror.tabs.insert(
|
||||
index.min(mirror.tabs.len()),
|
||||
TreeTab {
|
||||
@@ -520,6 +605,7 @@ fn create_tab(
|
||||
group: want.group,
|
||||
last_auto: want.last_auto.clone(),
|
||||
root,
|
||||
hibernated: want.hibernated,
|
||||
},
|
||||
);
|
||||
mirror.active = Some(want.id);
|
||||
@@ -579,6 +665,18 @@ fn reconcile_tab(
|
||||
last_auto: want.last_auto.clone(),
|
||||
});
|
||||
}
|
||||
// Ahead of anything structural. Going to sleep changes nothing else
|
||||
// about the tab; waking swaps every pane for its successor, which may
|
||||
// take a close-and-recreate below — and the mark has to come off the
|
||||
// tab that exists now, before that one is gone.
|
||||
if tab.hibernated != want.hibernated {
|
||||
tab.hibernated = want.hibernated;
|
||||
ops.push(ControlRequest::TabSetHibernated {
|
||||
workspace,
|
||||
tab: want.id,
|
||||
hibernated: want.hibernated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let desired_root = want.root.to_pane_node();
|
||||
@@ -1685,15 +1783,19 @@ pub(crate) fn session_from_tree(
|
||||
ws: &tty7_core::core::machine::Workspace,
|
||||
panes: &[PaneRecord],
|
||||
) -> Session {
|
||||
let views = tty7_core::core::tab_view::tab_views_of(ws, panes);
|
||||
let tabs: Vec<SessionTab> = ws
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| SessionTab {
|
||||
.zip(views)
|
||||
.map(|(tab, view)| SessionTab {
|
||||
name: tab.name.clone(),
|
||||
tree_id: Some(tab.id),
|
||||
group: tab.group,
|
||||
last_auto: tab.last_auto.clone(),
|
||||
pane: session_pane_from_node(&tab.root, panes),
|
||||
hibernated: tab.hibernated,
|
||||
asleep_view: tab.hibernated.then_some(view),
|
||||
})
|
||||
.collect();
|
||||
let active = ws
|
||||
@@ -2761,6 +2863,13 @@ impl Tty7App {
|
||||
}
|
||||
true
|
||||
}
|
||||
// A tab arriving asleep, or one that someone else put to sleep or
|
||||
// woke, is not a shape to graft onto this window: building it here
|
||||
// would spawn what is meant to stay stopped, or leave running what
|
||||
// was just stopped. Saying the delta did not apply re-pulls the
|
||||
// tree, and the restore that follows is the one path that already
|
||||
// knows what a sleeping tab is.
|
||||
LayoutDelta::TabCreated { tab, .. } if tab.hibernated => false,
|
||||
LayoutDelta::TabCreated { at, tab } => {
|
||||
self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx)
|
||||
}
|
||||
@@ -2772,6 +2881,7 @@ impl Tty7App {
|
||||
.and_then(|id| index_of(&self.tabs, id))
|
||||
.unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1)));
|
||||
self.maximized = None;
|
||||
self.wake_active_if_asleep(window, cx);
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
true
|
||||
@@ -2819,6 +2929,7 @@ impl Tty7App {
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) {
|
||||
Some(index) if tab.hibernated || self.tabs[index].is_asleep() => false,
|
||||
Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx),
|
||||
None => false,
|
||||
},
|
||||
@@ -2845,6 +2956,7 @@ impl Tty7App {
|
||||
}
|
||||
self.maximized = None;
|
||||
self.active = index;
|
||||
self.wake_active_if_asleep(window, cx);
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
|
||||
@@ -3578,6 +3690,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
};
|
||||
|
||||
assert!(
|
||||
@@ -4214,6 +4327,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
},
|
||||
TreeTab {
|
||||
id: failed,
|
||||
@@ -4221,6 +4335,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 2 },
|
||||
hibernated: false,
|
||||
},
|
||||
],
|
||||
active: Some(put_up),
|
||||
@@ -4307,6 +4422,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 11 },
|
||||
hibernated: false,
|
||||
},
|
||||
TreeTab {
|
||||
id: theirs.1,
|
||||
@@ -4314,6 +4430,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 12 },
|
||||
hibernated: false,
|
||||
},
|
||||
],
|
||||
active: Some(theirs.0),
|
||||
@@ -4450,6 +4567,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root,
|
||||
hibernated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5117,6 +5235,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
};
|
||||
assert!(apply_to_mirror(
|
||||
&mut watcher,
|
||||
@@ -5143,6 +5262,7 @@ mod tests {
|
||||
a: Box::new(PaneNode::Leaf { pane: 1 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 2 }),
|
||||
},
|
||||
hibernated: false,
|
||||
},
|
||||
pane: None,
|
||||
},
|
||||
@@ -5213,6 +5333,7 @@ mod tests {
|
||||
a: Box::new(PaneNode::Leaf { pane: 1 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 2 }),
|
||||
},
|
||||
hibernated: false,
|
||||
}],
|
||||
active_tab: Some(tab_id),
|
||||
..Default::default()
|
||||
@@ -5309,6 +5430,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 7 },
|
||||
hibernated: false,
|
||||
}],
|
||||
active_tab: Some(tab_id),
|
||||
..Default::default()
|
||||
@@ -5339,6 +5461,7 @@ mod tests {
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
}],
|
||||
active_tab: Some(TabId::new()),
|
||||
..Default::default()
|
||||
@@ -5368,4 +5491,249 @@ mod tests {
|
||||
"got {ops:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn asleep(id: TabId, root: DesiredNode) -> DesiredTab {
|
||||
DesiredTab {
|
||||
hibernated: true,
|
||||
..tab(id, root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Going to sleep changes nothing about the tab's shape — the same panes,
|
||||
/// by the same ids — so the only thing the machine hears is the mark. A
|
||||
/// tab that lost its panes would have been a close, and the close would
|
||||
/// have taken every record the wake needs with it.
|
||||
#[test]
|
||||
fn a_tab_going_to_sleep_says_so_and_nothing_else() {
|
||||
let ws = WorkspaceId::new();
|
||||
let id = TabId::new();
|
||||
let mut mirror = WsMirror::default();
|
||||
let root = || split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2));
|
||||
diff(
|
||||
ws,
|
||||
&mut mirror,
|
||||
&[tab(id, root())],
|
||||
Some(id),
|
||||
SyncScope::Full,
|
||||
&[],
|
||||
);
|
||||
|
||||
let ops = diff(
|
||||
ws,
|
||||
&mut mirror,
|
||||
&[asleep(id, root())],
|
||||
Some(id),
|
||||
SyncScope::Full,
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
ops,
|
||||
vec![ControlRequest::TabSetHibernated {
|
||||
workspace: ws,
|
||||
tab: id,
|
||||
hibernated: true,
|
||||
}]
|
||||
);
|
||||
assert!(mirror.tabs[0].hibernated);
|
||||
assert!(
|
||||
diff(
|
||||
ws,
|
||||
&mut mirror,
|
||||
&[asleep(id, root())],
|
||||
Some(id),
|
||||
SyncScope::Full,
|
||||
&[]
|
||||
)
|
||||
.is_empty(),
|
||||
"a tab that stays asleep is not told again"
|
||||
);
|
||||
}
|
||||
|
||||
/// Waking swaps each stopped pane for its successor. The mark comes off
|
||||
/// first, while the tab it belongs to is certainly still the one the
|
||||
/// machine has.
|
||||
#[test]
|
||||
fn a_waking_tab_clears_its_mark_before_its_panes_are_replaced() {
|
||||
let ws = WorkspaceId::new();
|
||||
let id = TabId::new();
|
||||
let mut mirror = WsMirror::default();
|
||||
diff(
|
||||
ws,
|
||||
&mut mirror,
|
||||
&[asleep(id, leaf(1))],
|
||||
Some(id),
|
||||
SyncScope::Full,
|
||||
&[],
|
||||
);
|
||||
|
||||
let want = vec![tab(id, leaf(9))];
|
||||
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
|
||||
assert_eq!(
|
||||
ops,
|
||||
vec![
|
||||
ControlRequest::TabSetHibernated {
|
||||
workspace: ws,
|
||||
tab: id,
|
||||
hibernated: false,
|
||||
},
|
||||
ControlRequest::PaneReplace {
|
||||
workspace: ws,
|
||||
old: 1,
|
||||
new: seed(9),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(!mirror.tabs[0].hibernated);
|
||||
assert_converged(&mirror, &want);
|
||||
}
|
||||
|
||||
/// A window writing a sleeping tab onto a machine that lost it — a store
|
||||
/// wiped under a remote workspace — writes it back asleep, not as a tab
|
||||
/// the next restore would spawn.
|
||||
#[test]
|
||||
fn a_tab_created_asleep_is_marked_as_it_is_created() {
|
||||
let ws = WorkspaceId::new();
|
||||
let id = TabId::new();
|
||||
let mut mirror = WsMirror::default();
|
||||
let ops = diff(
|
||||
ws,
|
||||
&mut mirror,
|
||||
&[asleep(id, leaf(4))],
|
||||
Some(id),
|
||||
SyncScope::Full,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
matches!(ops.first(), Some(ControlRequest::TabCreate { .. })),
|
||||
"{ops:?}"
|
||||
);
|
||||
assert!(
|
||||
ops.contains(&ControlRequest::TabSetHibernated {
|
||||
workspace: ws,
|
||||
tab: id,
|
||||
hibernated: true,
|
||||
}),
|
||||
"{ops:?}"
|
||||
);
|
||||
assert!(mirror.tabs[0].hibernated);
|
||||
}
|
||||
|
||||
/// The window reads a sleeping tab's layout off the record it kept, and
|
||||
/// has to hand the machine the very tab the machine already holds: same
|
||||
/// ids, same seeds, same shape.
|
||||
#[test]
|
||||
fn a_sleeping_layout_reads_back_as_the_tree_it_came_from() {
|
||||
use crate::core::session::SessionAxis;
|
||||
let leaf_of = |pane: u64, cwd: &str| SessionPane::Leaf {
|
||||
cwd: Some(std::path::PathBuf::from(cwd)),
|
||||
pane_id: Some(pane),
|
||||
shell: None,
|
||||
ssh_spec: None,
|
||||
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
|
||||
agent_session_id: Some(format!("sess-{pane}")),
|
||||
agent_launch_argv: None,
|
||||
};
|
||||
let layout = SessionPane::Split {
|
||||
axis: SessionAxis::Horizontal,
|
||||
ratio: 0.3,
|
||||
a: Box::new(leaf_of(3, "/a")),
|
||||
b: Box::new(leaf_of(4, "/b")),
|
||||
};
|
||||
let node = desired_node_from_session(&layout, false).expect("both leaves have ids");
|
||||
assert_eq!(
|
||||
node.to_pane_node(),
|
||||
PaneNode::Split {
|
||||
axis: TreeAxis::Horizontal,
|
||||
ratio: 0.3,
|
||||
a: Box::new(PaneNode::Leaf { pane: 3 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 4 }),
|
||||
}
|
||||
);
|
||||
let seed = node.seed_of(4).expect("pane 4 is a leaf");
|
||||
assert_eq!(seed.cwd.as_deref(), Some("/b"));
|
||||
assert_eq!(
|
||||
seed.agent.as_ref().and_then(|a| a.session_id.as_deref()),
|
||||
Some("sess-4"),
|
||||
"the session a wake resumes stays on the record"
|
||||
);
|
||||
|
||||
// A leaf the machine never had is left out, as for a live tab.
|
||||
let unknown = SessionPane::Split {
|
||||
axis: SessionAxis::Vertical,
|
||||
ratio: 0.5,
|
||||
a: Box::new(leaf_of(3, "/a")),
|
||||
b: Box::new(SessionPane::Leaf {
|
||||
cwd: None,
|
||||
pane_id: None,
|
||||
shell: None,
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
}),
|
||||
};
|
||||
assert_eq!(
|
||||
desired_node_from_session(&unknown, false).map(|n| n.to_pane_node()),
|
||||
Some(PaneNode::Leaf { pane: 3 })
|
||||
);
|
||||
}
|
||||
|
||||
/// A restore reads the mark off the tree: the sleeping tab keeps its pane
|
||||
/// ids — they are what the wake restores from — and comes with a name read
|
||||
/// off the tree's records, since nothing in it will be running to say one.
|
||||
#[test]
|
||||
fn a_restore_brings_a_sleeping_tab_back_asleep_and_named() {
|
||||
let (awake, sleeping) = (TabId::new(), TabId::new());
|
||||
let ws = tty7_core::core::machine::Workspace {
|
||||
tabs: vec![
|
||||
TreeTab {
|
||||
id: awake,
|
||||
name: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
hibernated: false,
|
||||
},
|
||||
TreeTab {
|
||||
id: sleeping,
|
||||
name: None,
|
||||
group: None,
|
||||
last_auto: None,
|
||||
root: PaneNode::Leaf { pane: 2 },
|
||||
hibernated: true,
|
||||
},
|
||||
],
|
||||
active_tab: Some(awake),
|
||||
..Default::default()
|
||||
};
|
||||
let panes = vec![
|
||||
PaneRecord {
|
||||
cwd: Some("/work".into()),
|
||||
live: true,
|
||||
..PaneRecord::new(1)
|
||||
},
|
||||
PaneRecord {
|
||||
cwd: Some("/work/api".into()),
|
||||
osc_title: Some("fixing the switcher".into()),
|
||||
live: false,
|
||||
..PaneRecord::new(2)
|
||||
},
|
||||
];
|
||||
|
||||
let session = session_from_tree(&ws, &panes);
|
||||
assert!(!session.tabs[0].hibernated);
|
||||
assert!(session.tabs[0].asleep_view.is_none());
|
||||
let tab = &session.tabs[1];
|
||||
assert!(tab.hibernated);
|
||||
match &tab.pane {
|
||||
SessionPane::Leaf { pane_id, cwd, .. } => {
|
||||
assert_eq!(*pane_id, Some(2));
|
||||
assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api")));
|
||||
}
|
||||
_ => panic!("leaf"),
|
||||
}
|
||||
let view = tab.asleep_view.as_ref().expect("a sleeping tab is named");
|
||||
assert_eq!(view.osc_title.as_deref(), Some("fixing the switcher"));
|
||||
assert!(!view.live);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user