mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked .rs files with `uncomment` 3.5.1. It parses each file with tree-sitter instead of matching text, so comment-like content inside string literals is left alone: the JavaScript plugin source embedded in agent_hooks.rs raw strings keeps its own `//` lines. Left alone: Cargo.toml comments and the shell scripts under scripts/. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,3 @@
|
||||
//! Build script — Windows-only: embed the app icon into the `.exe`.
|
||||
//!
|
||||
//! On Windows the taskbar / window / Explorer icon comes from an icon *resource*
|
||||
//! compiled into the executable; there's no equivalent of macOS's `.app` bundle
|
||||
//! (which gets its icon from `tty7.icns` via `.github/scripts/bundle.sh`). So we
|
||||
//! compile `assets/favicon.ico` (a multi-res 16–256px ICO) into the binary here.
|
||||
//!
|
||||
//! On every other platform this is a no-op.
|
||||
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -14,8 +5,6 @@ fn main() {
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("assets/favicon.ico");
|
||||
if let Err(e) = res.compile() {
|
||||
// Don't fail the build just because the resource compiler is missing;
|
||||
// the app still runs, it just falls back to the default Windows icon.
|
||||
println!("cargo:warning=failed to embed Windows icon: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,8 @@
|
||||
//! Crash log — the panic message the OS crash reporter throws away.
|
||||
//!
|
||||
//! Most tty7 panics happen inside a gpui input callback, and those callbacks are
|
||||
//! `extern "C"`: the panic can't unwind across them, so the runtime aborts. What
|
||||
//! macOS then records is the *abort* — `panic_cannot_unwind` on top of
|
||||
//! `handle_key_event` — with no message, no `file:line`, and the original frames
|
||||
//! already unwound away. Reports like that are undiagnosable, and the GUI has no
|
||||
//! logger and no terminal to print to.
|
||||
//!
|
||||
//! So we write the two lines that matter (message + location, plus a backtrace)
|
||||
//! to `crash.log` in the config dir before the process goes down.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Rewrite the log once it passes this, so a panic loop can't grow it forever.
|
||||
const MAX_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Install the panic hook for this process. `role` labels the records, since the
|
||||
/// GUI and the daemon it spawns share one config dir. Chains to the previously
|
||||
/// installed hook, so the usual stderr output still happens when there's a
|
||||
/// terminal to see it.
|
||||
pub fn install(role: &'static str) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
@@ -28,16 +11,12 @@ pub fn install(role: &'static str) {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Append one record. Every step is best-effort: a panic handler that panics
|
||||
/// (or fails loudly) is worse than one that loses a log line.
|
||||
fn record(role: &str, info: &std::panic::PanicHookInfo<'_>) {
|
||||
let Some(path) = log_path() else {
|
||||
return;
|
||||
};
|
||||
let thread = std::thread::current();
|
||||
let mut record = String::new();
|
||||
// `info` renders as "panicked at <file:line:col>:\n<message>" — the exact
|
||||
// pair the crash report is missing.
|
||||
let _ = write!(
|
||||
record,
|
||||
"\n=== {} {} v{} pid {} thread {:?}\n{info}\n{}\n",
|
||||
@@ -72,8 +51,6 @@ fn log_path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("crash.log")
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM:SS UTC` from the epoch seconds, so a record can be lined up
|
||||
/// against an OS crash report without pulling in a date crate.
|
||||
fn utc_timestamp() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -89,7 +66,6 @@ fn utc_timestamp() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch → (y, m, d).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
@@ -107,12 +83,8 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
mod tests {
|
||||
use super::{civil_from_days, install, log_path};
|
||||
|
||||
/// The whole point of the hook: after a panic, the message and its location
|
||||
/// are on disk. `catch_unwind` stands in for the abort — the hook runs
|
||||
/// before either outcome.
|
||||
#[test]
|
||||
fn a_panic_lands_in_the_crash_log() {
|
||||
// Same pinned temp dir the config tests use (set-once, first call wins).
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
@@ -124,8 +96,6 @@ mod tests {
|
||||
|
||||
let body = std::fs::read_to_string(&path).expect("the hook wrote a record");
|
||||
assert!(body.contains("crash-log probe"), "message: {body}");
|
||||
// Bare file name: `panic!`'s location carries the platform's own
|
||||
// separator (`src\core\crash.rs` on Windows).
|
||||
assert!(body.contains("crash.rs:"), "location: {body}");
|
||||
assert!(body.contains("test v"), "role + version: {body}");
|
||||
}
|
||||
@@ -134,7 +104,6 @@ mod tests {
|
||||
fn civil_from_days_matches_known_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_660), (2026, 7, 26));
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||
assert_eq!(civil_from_days(19_783), (2024, 3, 1));
|
||||
}
|
||||
|
||||
@@ -1,80 +1,25 @@
|
||||
//! Git, the way every part of tty7 reads it: one shell-out per field, always
|
||||
//! `git -C <cwd>`, always `GIT_OPTIONAL_LOCKS=0`.
|
||||
//!
|
||||
//! This is the shared bottom layer, not a feature: the sidebar's branch/diff
|
||||
//! line, the diff overlay, and (from the remote-workspace work on) the server
|
||||
//! side all go through the same [`git`] invocation, so a read tty7 performs can
|
||||
//! never take `index.lock` and fight a real git command the user is running.
|
||||
//! Deliberately shell-out simple — blocking, one process per question; callers
|
||||
//! run it on a background thread so nothing UI-facing waits on a slow repo.
|
||||
//!
|
||||
//! The caching layer that fans one probe out to every pane in a repo is *not*
|
||||
//! here: it is a gpui global, so it stays in the GUI crate
|
||||
//! (`terminal::git_status::GitStatusCache`).
|
||||
|
||||
use std::io::{self, Read as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::host::{Host, Output};
|
||||
|
||||
/// A repo's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
|
||||
/// and untracked files don't contribute a line count.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GitStatus {
|
||||
/// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD
|
||||
/// is detached. Never empty.
|
||||
pub branch: String,
|
||||
/// Lines added across the working tree vs `HEAD`.
|
||||
pub added: u32,
|
||||
/// Lines removed across the working tree vs `HEAD`.
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// One raw probe result, before it's folded into the cache: which work tree
|
||||
/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the
|
||||
/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) —
|
||||
/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the
|
||||
/// previous numbers instead of pretending the tree went clean.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RepoSnapshot {
|
||||
/// The work tree root (`git rev-parse --show-toplevel`) — the cache key
|
||||
/// every pane inside this work tree shares. For a linked worktree this is
|
||||
/// the worktree's own directory, not the main checkout's.
|
||||
pub root: PathBuf,
|
||||
/// The *repository* the work tree belongs to: the main checkout's root
|
||||
/// when `root` is a linked worktree, otherwise `root` itself. The
|
||||
/// sidebar's grouping key — every worktree of one repo shares it, while
|
||||
/// branch/diff state stays per work tree under `root`.
|
||||
pub home: PathBuf,
|
||||
pub branch: String,
|
||||
pub counts: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Probe the git snapshot for `cwd` on `host`, or `None` when it isn't inside a
|
||||
/// git work tree (or the path is gone, or the host can't be reached).
|
||||
/// Blocking — the GUI calls it through `ui::host_ops`, never on the UI thread.
|
||||
///
|
||||
/// Three invocations, and deliberately not fewer. The first `rev-parse` answers
|
||||
/// every *path* question at once: the work-tree root (which doubles as the "is
|
||||
/// this a git repo" gate — it fails outside a work tree) plus the
|
||||
/// git-dir/common-dir pair that tells a linked worktree from a main checkout.
|
||||
/// Asking those separately cost two process spawns per probe, which mattered
|
||||
/// once probes stopped being rare: they now also fire on window activation and
|
||||
/// on an agent's tool calls, across every pane. The branch cannot join them —
|
||||
/// `symbolic-ref` is what names a branch before the first commit exists, and
|
||||
/// folding `--abbrev-ref HEAD` into the `rev-parse` would make the whole
|
||||
/// invocation fail on an unborn branch and lose the paths with it.
|
||||
///
|
||||
/// On a remote host each of the three is a round trip; the throttle and the
|
||||
/// in-flight dedup in the GUI's `GitStatusCache` are what keep that from being
|
||||
/// three per pane per trigger.
|
||||
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
// No `exists` pre-check: a vanished cwd already fails `Host::git` with
|
||||
// `NotFound`, which lands as `None` here — the same answer, one round trip
|
||||
// cheaper.
|
||||
let paths = git(
|
||||
host,
|
||||
cwd,
|
||||
@@ -88,9 +33,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
// A git old enough to reject `--path-format` fails the whole invocation
|
||||
// above, so reaching here means the two dirs are present — but degrade to
|
||||
// "main checkout" rather than trusting that, same as the old code did.
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(host, cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
@@ -101,13 +43,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository "home" every checkout of one repo shares, from the work-tree
|
||||
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
|
||||
/// (its git dir differs from the common git dir) the main work tree's root —
|
||||
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
|
||||
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
|
||||
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
|
||||
/// the bare directory itself — still one shared key.
|
||||
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
@@ -122,26 +57,18 @@ fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> Pa
|
||||
}
|
||||
}
|
||||
|
||||
/// The current branch name, or a short sha for a detached HEAD. Shared with
|
||||
/// `terminal::git_diff` in the GUI crate, which fronts its overlay with the
|
||||
/// same branch label the sidebar row shows.
|
||||
pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option<String> {
|
||||
// On a branch — even before the first commit — `symbolic-ref` names it.
|
||||
if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
|
||||
let name = out.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
// Detached HEAD (or a rebase/bisect): fall back to the short commit sha.
|
||||
let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?;
|
||||
let sha = sha.trim();
|
||||
(!sha.is_empty()).then(|| sha.to_string())
|
||||
}
|
||||
|
||||
/// Sum added/removed lines across the working tree vs `HEAD` from
|
||||
/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
|
||||
/// `None` when the invocation itself failed — the caller keeps old counts.
|
||||
fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> {
|
||||
let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?;
|
||||
let mut added = 0u32;
|
||||
@@ -158,19 +85,6 @@ fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> {
|
||||
Some((added, removed))
|
||||
}
|
||||
|
||||
/// Run `git -C <cwd> <args>` on `host` and return stdout on success, `None` on
|
||||
/// a non-zero exit or a git that never ran.
|
||||
///
|
||||
/// The projection of [`git_output`] the snapshot readers want: they treat "git
|
||||
/// said no" and "git could not be asked" identically — a failed probe leaves
|
||||
/// the previous snapshot standing rather than blanking the branch line — where
|
||||
/// callers like `core::worktree` need the two kept apart, and the stderr with
|
||||
/// them. Non-UTF-8 stdout is `None`: there is nothing sensible to parse out of
|
||||
/// it.
|
||||
///
|
||||
/// Which machine's `git` runs is the host's business; the *invariants* of the
|
||||
/// invocation are [`git_output`]'s, and every implementation of
|
||||
/// [`Host::git`] owes them.
|
||||
pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = host.git(cwd, args).ok()?;
|
||||
if !out.success() {
|
||||
@@ -179,35 +93,6 @@ pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
String::from_utf8(out.stdout).ok()
|
||||
}
|
||||
|
||||
/// The full result of `git -C <cwd> <args>` — exit code, stdout *and* stderr —
|
||||
/// under the invariants every git invocation in tty7 shares.
|
||||
///
|
||||
/// This is the bottom layer [`git`] is a projection of, and the one
|
||||
/// [`crate::host::Host::git`] exposes: a `Host` has to answer for a remote
|
||||
/// machine's git too, where "non-zero exit" and "git never ran" are genuinely
|
||||
/// different outcomes and the caller needs stderr to say which. `Ok` means the
|
||||
/// process ran (the exit code is in [`Output::status`]); `Err` means it could
|
||||
/// not be run at all.
|
||||
///
|
||||
/// The invariants, all of them load-bearing:
|
||||
///
|
||||
/// - **`-C <cwd>`**, never `Command::current_dir` — the working directory of
|
||||
/// this process is not a thing a GUI with many panes can meaningfully set.
|
||||
/// - **`GIT_OPTIONAL_LOCKS=0`**, so a background read can never take
|
||||
/// `index.lock` and lose a race against a real git command the user is
|
||||
/// running. It only suppresses *optional* locks; writes still lock normally.
|
||||
/// - **stdin nulled**, so a misconfigured credential helper or a prompt-happy
|
||||
/// subcommand fails immediately instead of hanging a background thread
|
||||
/// forever on a terminal nobody is attached to.
|
||||
/// - **`GIT_DIR` / `GIT_WORK_TREE` removed**, so a tty7 launched from inside a
|
||||
/// git hook (or any shell that exported them) can't have that ambient
|
||||
/// repository silently override the `-C` we just passed.
|
||||
/// - **`hide_console` on Windows**, so probing a repo doesn't flash a console
|
||||
/// window.
|
||||
///
|
||||
/// A `cwd` that doesn't exist is [`io::ErrorKind::NotFound`] rather than git's
|
||||
/// own exit 128: "the directory is gone" is not a question about the repository,
|
||||
/// and callers (and the remote `Host` contract) distinguish the two.
|
||||
pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
if !cwd.exists() {
|
||||
return Err(io::Error::new(
|
||||
@@ -233,21 +118,6 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
})
|
||||
}
|
||||
|
||||
/// [`git_output`], but handing stdout to `on_chunk` as it arrives instead of
|
||||
/// buffering it whole.
|
||||
///
|
||||
/// Every invariant [`git_output`] documents holds here too — this is the same
|
||||
/// invocation, only read differently. What it buys is peak memory: `git diff
|
||||
/// HEAD` on a large work tree prints tens of megabytes, and the caller keeps a
|
||||
/// small fraction of it, so materialising the whole thing first is pure cost.
|
||||
///
|
||||
/// Chunks are byte slices, not lines: a line can straddle two of them and
|
||||
/// splitting is the caller's business (see [`LineSplitter`]). `on_chunk`
|
||||
/// returning `false` stops the read early — the child's stdout is dropped, git
|
||||
/// gets `EPIPE` and exits rather than blocking forever on a full pipe.
|
||||
///
|
||||
/// Returns git's exit status once it has been reaped, `Err` if it could not be
|
||||
/// run at all — the same split [`git_output`] makes.
|
||||
pub fn git_stream(
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
@@ -268,18 +138,13 @@ pub fn git_stream(
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// Dropped on the floor rather than piped: nothing reads it here, and an
|
||||
// unread pipe is how you deadlock a child that decides to be chatty.
|
||||
.stderr(Stdio::null());
|
||||
let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?;
|
||||
// Taken so the pipe can be closed before `wait`.
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("git stdout was not piped"))?;
|
||||
|
||||
// 64 KiB: large enough that a multi-megabyte diff is a few hundred reads,
|
||||
// small enough to stay a rounding error next to the parsed structure.
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut read_err = None;
|
||||
loop {
|
||||
@@ -305,55 +170,19 @@ pub fn git_stream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Reassembles a byte stream into lines across chunk boundaries.
|
||||
///
|
||||
/// Chunked transports — a pipe read, a wire frame — cut wherever they happen
|
||||
/// to fill a buffer, so a diff line routinely spans two chunks. This holds the
|
||||
/// partial tail and emits only whole lines, with the trailing `\n`/`\r`
|
||||
/// stripped; [`finish`](Self::finish) releases a last line that had no
|
||||
/// terminator.
|
||||
///
|
||||
/// Invalid UTF-8 is replaced rather than fatal. The buffered path drops the
|
||||
/// entire output on one bad byte, which for a diff of a latin-1 file means
|
||||
/// showing nothing at all; a replacement character in one line is the better
|
||||
/// answer.
|
||||
///
|
||||
/// Bounded by [`MAX_LINE`], which is what makes "streaming" a claim about peak
|
||||
/// memory rather than only about allocation count — see that constant.
|
||||
#[derive(Default)]
|
||||
pub struct LineSplitter {
|
||||
tail: Vec<u8>,
|
||||
/// Bytes of the line in progress that were dropped for being past
|
||||
/// [`MAX_LINE`]. Reported in the emitted line rather than swallowed.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
/// Ceiling on one reassembled line.
|
||||
///
|
||||
/// Without it "incremental" bounds the number of allocations but not the size
|
||||
/// of any of them: a line is only complete at its `\n`, so a work tree holding
|
||||
/// a minified bundle — one `+` line of many megabytes — accumulates that whole
|
||||
/// line in `tail` before anything is emitted, on both ends of a remote link and
|
||||
/// in the server's outgoing batch. That is the same peak the buffered read was
|
||||
/// replaced to avoid, reached through the one input shape nobody bounds.
|
||||
///
|
||||
/// A megabyte is far past anything a diff viewer can show (the overlay
|
||||
/// truncates a *cell* long before this) and far past any line git prints about
|
||||
/// its own state, so nothing legitimate is cut. What is cut says so in the line
|
||||
/// itself: silent truncation is how a rendered diff quietly stops matching the
|
||||
/// file.
|
||||
pub const MAX_LINE: usize = 1024 * 1024;
|
||||
|
||||
impl LineSplitter {
|
||||
/// Feed a chunk, calling `on_line` for every complete line it finishes.
|
||||
pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) {
|
||||
let mut rest = chunk;
|
||||
while let Some(nl) = rest.iter().position(|b| *b == b'\n') {
|
||||
let (line, after) = rest.split_at(nl);
|
||||
// The common case by far — a whole line inside this chunk, with
|
||||
// nothing held over — hands out a borrow of the chunk itself. Worth
|
||||
// keeping as its own arm: it is one branch per line against a copy
|
||||
// per line, on a path that runs ninety thousand times.
|
||||
if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE {
|
||||
on_line(&trim_cr(line));
|
||||
} else {
|
||||
@@ -367,14 +196,12 @@ impl LineSplitter {
|
||||
self.keep(rest);
|
||||
}
|
||||
|
||||
/// Emit whatever is left when the stream ends without a final newline.
|
||||
pub fn finish(self, mut on_line: impl FnMut(&str)) {
|
||||
if !self.tail.is_empty() || self.dropped > 0 {
|
||||
emit(&self.tail, self.dropped, &mut on_line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append what still fits under [`MAX_LINE`], counting the rest as dropped.
|
||||
fn keep(&mut self, bytes: &[u8]) {
|
||||
let room = MAX_LINE.saturating_sub(self.tail.len());
|
||||
let take = room.min(bytes.len());
|
||||
@@ -383,14 +210,11 @@ impl LineSplitter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand one reassembled line to the caller, saying so when it was cut.
|
||||
fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) {
|
||||
if dropped == 0 {
|
||||
on_line(&trim_cr(line));
|
||||
return;
|
||||
}
|
||||
// No `trim_cr` on a cut line: its last byte is one from the middle of the
|
||||
// real line, and a `\r` that lands there is content, not a terminator.
|
||||
let mut text = String::from_utf8_lossy(line).into_owned();
|
||||
text.push_str(&format!(
|
||||
" …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]"
|
||||
@@ -398,7 +222,6 @@ fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) {
|
||||
on_line(&text);
|
||||
}
|
||||
|
||||
/// Drop a trailing `\r` (git on Windows) and decode lossily.
|
||||
fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> {
|
||||
let line = match line.last() {
|
||||
Some(b'\r') => &line[..line.len() - 1],
|
||||
@@ -411,15 +234,10 @@ fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The host these tests probe through: this machine, which is what the GUI
|
||||
/// hands `probe` for a local pane.
|
||||
fn h() -> crate::host::SharedHost {
|
||||
crate::host::local::LocalHost::new()
|
||||
}
|
||||
|
||||
/// A line split across two chunks is rejoined, not cut in half — the whole
|
||||
/// reason `LineSplitter` exists, since a 64 KiB read boundary lands mid-line
|
||||
/// on essentially every large diff.
|
||||
#[test]
|
||||
fn line_splitter_rejoins_across_chunks() {
|
||||
let mut split = LineSplitter::default();
|
||||
@@ -431,9 +249,6 @@ mod tests {
|
||||
assert_eq!(got, ["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
/// A final line with no terminator still arrives, and `\r\n` endings are
|
||||
/// normalised — git on Windows writes them and the parser must not see the
|
||||
/// carriage return as content.
|
||||
#[test]
|
||||
fn line_splitter_handles_crlf_and_a_missing_final_newline() {
|
||||
let mut split = LineSplitter::default();
|
||||
@@ -443,9 +258,6 @@ mod tests {
|
||||
assert_eq!(got, ["one", "two", "three"]);
|
||||
}
|
||||
|
||||
/// Invalid UTF-8 is replaced, not fatal: the buffered path drops the entire
|
||||
/// output on one bad byte, which for a diff of a latin-1 file means showing
|
||||
/// nothing at all.
|
||||
#[test]
|
||||
fn line_splitter_replaces_invalid_utf8() {
|
||||
let mut split = LineSplitter::default();
|
||||
@@ -456,20 +268,12 @@ mod tests {
|
||||
assert!(got[0].starts_with("caf"), "{:?}", got[0]);
|
||||
}
|
||||
|
||||
/// A line past [`MAX_LINE`] is cut rather than accumulated, and says so.
|
||||
///
|
||||
/// This is what makes the streaming read's memory claim true: a line is only
|
||||
/// complete at its `\n`, so without a cap one minified-bundle line rebuilds
|
||||
/// the whole-output peak that streaming exists to remove. The lines around
|
||||
/// it are untouched, and the byte count in the notice is the *dropped*
|
||||
/// remainder, so the reader can tell how much is missing.
|
||||
#[test]
|
||||
fn line_splitter_caps_one_absurd_line() {
|
||||
let mut split = LineSplitter::default();
|
||||
let mut got = Vec::new();
|
||||
let huge = vec![b'x'; MAX_LINE + 5_000];
|
||||
split.push(b"before\n", |l| got.push(l.to_string()));
|
||||
// Fed in pieces, so the cap has to hold across chunk boundaries too.
|
||||
for piece in huge.chunks(64 * 1024) {
|
||||
split.push(piece, |l| got.push(l.to_string()));
|
||||
}
|
||||
@@ -499,8 +303,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A cut line with no trailing newline still comes out of `finish`, rather
|
||||
/// than being dropped along with the bytes past the cap.
|
||||
#[test]
|
||||
fn line_splitter_caps_a_final_unterminated_line() {
|
||||
let mut split = LineSplitter::default();
|
||||
@@ -511,14 +313,12 @@ mod tests {
|
||||
assert!(got[0].contains("7 more bytes"), "{}", got[0]);
|
||||
}
|
||||
|
||||
/// The streaming read and the buffered one must agree line for line —
|
||||
/// overriding `git_lines` is an optimisation, never a behaviour change.
|
||||
#[test]
|
||||
fn streaming_and_buffered_reads_agree() {
|
||||
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let args = ["log", "--oneline", "-n", "40"];
|
||||
let Ok(code) = git_stream(here, &args, |_| true) else {
|
||||
return; // no git, or not a work tree — nothing to compare
|
||||
return;
|
||||
};
|
||||
if code != Some(0) {
|
||||
return;
|
||||
@@ -541,7 +341,6 @@ mod tests {
|
||||
assert!(!streamed.is_empty(), "this repo has commits");
|
||||
}
|
||||
|
||||
/// A tmp path that is not a git repo yields no snapshot (and never panics).
|
||||
#[test]
|
||||
fn non_repo_is_none() {
|
||||
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
|
||||
@@ -549,14 +348,11 @@ mod tests {
|
||||
assert_eq!(probe(&*h(), &dir), None);
|
||||
}
|
||||
|
||||
/// A path that doesn't exist is `None`, not a panic.
|
||||
#[test]
|
||||
fn missing_path_is_none() {
|
||||
assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None);
|
||||
}
|
||||
|
||||
/// This repo (the crate root is inside the tty7 work tree) reports a branch
|
||||
/// and a root, exercising the real `git` probe end-to-end.
|
||||
#[test]
|
||||
fn own_repo_has_a_branch_and_root() {
|
||||
let here = env!("CARGO_MANIFEST_DIR");
|
||||
@@ -564,34 +360,23 @@ mod tests {
|
||||
assert!(!snap.branch.is_empty());
|
||||
assert!(Path::new(here).starts_with(&snap.root));
|
||||
}
|
||||
// If the crate is built outside a work tree (e.g. a vendored tarball),
|
||||
// `None` is the correct answer and the assertions above are skipped.
|
||||
}
|
||||
/// The four shapes `repo_home` has to tell apart, straight from the
|
||||
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
|
||||
#[test]
|
||||
fn repo_home_resolves_worktree_layouts() {
|
||||
let root = Path::new("/repo/.wt/feat");
|
||||
|
||||
// A main checkout: the two dirs agree, so the work tree is its own home.
|
||||
assert_eq!(
|
||||
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A linked worktree: the common dir is the main checkout's `.git`, so
|
||||
// the home is that `.git`'s parent — the main work tree.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A bare repo with worktrees hanging off it: no `.git` component to
|
||||
// strip, so the bare dir itself is the shared key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
|
||||
PathBuf::from("/bare.git")
|
||||
);
|
||||
// A git too old (or too odd) to answer both: degrade to the work tree
|
||||
// rather than guessing a grouping key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git"), None),
|
||||
root.to_path_buf()
|
||||
|
||||
@@ -1,47 +1,20 @@
|
||||
//! The `.gitignore` chain a directory listing is scored against.
|
||||
//!
|
||||
//! One matcher is compiled per directory that has a `.gitignore`, cached by
|
||||
//! that directory's path, and a path is scored by walking the chain from the
|
||||
//! tree root down to the path's own parent — **the deepest match wins**, so a
|
||||
//! nested `.gitignore`'s whitelist (`!pattern`) can un-ignore what an ancestor
|
||||
//! ignored, which is what git itself does.
|
||||
//!
|
||||
//! Lives in `tty7-core` rather than beside the file tree because the answer has
|
||||
//! to be identical on both sides of a remote workspace: the GUI dims ignored
|
||||
//! entries for a local tree, and the server has to dim exactly the same ones
|
||||
//! for a remote tree. One implementation, no drift.
|
||||
//!
|
||||
//! Compiling is lazy and cached (including the negative case — a directory with
|
||||
//! no `.gitignore` caches as `None`), so a chain that is carried across
|
||||
//! listings pays for each directory once. `Arc`, so a chain can be cloned onto
|
||||
//! a background thread and its compiled matchers shared rather than rebuilt.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use ignore::gitignore::Gitignore;
|
||||
|
||||
/// Compiled `.gitignore` matchers, keyed by the directory each came from
|
||||
/// (`None` = that directory has no `.gitignore`).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct GitignoreChain {
|
||||
matchers: HashMap<PathBuf, Option<Arc<Gitignore>>>,
|
||||
}
|
||||
|
||||
impl GitignoreChain {
|
||||
/// Walk the `.gitignore` chain from `root` down to `path`'s directory and
|
||||
/// report whether `path` ends up ignored; the deepest match wins
|
||||
/// (whitelist `!patterns` un-ignore).
|
||||
///
|
||||
/// `is_dir` matters because gitignore patterns can be directory-only
|
||||
/// (`build/`). Paths outside `root` simply score against nothing.
|
||||
pub fn is_ignored(&mut self, path: &Path, is_dir: bool, root: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let mut state = false;
|
||||
// Ancestor chain root → parent, in order.
|
||||
let mut chain: Vec<&Path> = parent
|
||||
.ancestors()
|
||||
.take_while(|a| a.starts_with(root))
|
||||
@@ -72,25 +45,18 @@ impl GitignoreChain {
|
||||
state
|
||||
}
|
||||
|
||||
/// Fold another chain's compiled matchers in — how a background listing
|
||||
/// hands back the ones it had to compile so the next listing re-uses them.
|
||||
pub fn absorb(&mut self, other: Self) {
|
||||
self.matchers.extend(other.matchers);
|
||||
}
|
||||
|
||||
/// Drop every compiled matcher, so the next scoring recompiles from disk.
|
||||
/// The invalidation a `.gitignore` edit triggers.
|
||||
pub fn clear(&mut self) {
|
||||
self.matchers.clear();
|
||||
}
|
||||
|
||||
/// How many directories have been scored (and so cached) so far — the
|
||||
/// negative entries for directories without a `.gitignore` included.
|
||||
pub fn len(&self) -> usize {
|
||||
self.matchers.len()
|
||||
}
|
||||
|
||||
/// Whether nothing has been compiled or cached yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.matchers.is_empty()
|
||||
}
|
||||
@@ -100,7 +66,6 @@ impl GitignoreChain {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a `.gitignore` into `dir` (creating it) with the given patterns.
|
||||
fn write_ignore(dir: &Path, body: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(".gitignore"), body).unwrap();
|
||||
@@ -114,8 +79,6 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// The deepest `.gitignore` wins, so a nested whitelist un-ignores what the
|
||||
/// root ignored — the rule the file tree's dimming depends on.
|
||||
#[test]
|
||||
fn the_deepest_match_wins() {
|
||||
let root = scratch("deepest");
|
||||
@@ -131,8 +94,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A directory-only pattern (`build/`) matches the directory, not a file of
|
||||
/// the same name — which is why scoring takes `is_dir`.
|
||||
#[test]
|
||||
fn directory_only_patterns_need_is_dir() {
|
||||
let root = scratch("dironly");
|
||||
@@ -145,8 +106,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `clear` forces a recompile, so an edited `.gitignore` takes effect;
|
||||
/// without it the cached matcher would answer from the old patterns.
|
||||
#[test]
|
||||
fn clear_lets_an_edited_gitignore_take_effect() {
|
||||
let root = scratch("clear");
|
||||
|
||||
@@ -1,52 +1,18 @@
|
||||
//! The *naming* half of the SSH credential vault: how a keychain entry is
|
||||
//! addressed, and the secret-free pointer that `config.json` persists.
|
||||
//!
|
||||
//! Secrets (passwords, private-key passphrases) live only in the platform secret
|
||||
//! store — never in `config.json`. A profile persists at most a [`CredentialRef`],
|
||||
//! which *names* a keychain entry but carries no secret. Per PRD §7.2 entries are
|
||||
//! keyed by **endpoint**, not by profile:
|
||||
//!
|
||||
//! - passwords → service `tty7-ssh`, account `<user>@<host>:<port>`
|
||||
//! - key passphrases → service `tty7-ssh-key`, account `<sha512-hex of key file>`
|
||||
//!
|
||||
//! Endpoint keying lets a QuickConnect (which has no profile) still "remember" a
|
||||
//! password, lets several profiles pointing at one endpoint share one credential,
|
||||
//! and means changing a password touches exactly one entry.
|
||||
//!
|
||||
//! **The store itself is not here.** The `CredentialStore` trait, its OS-keychain
|
||||
//! backend and the in-memory test double live in the GUI crate
|
||||
//! (`tty7::core::keychain`), because nothing in this crate reads or writes a
|
||||
//! secret: the daemon receives already-resolved secrets on the wire (see
|
||||
//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on
|
||||
//! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's
|
||||
//! manifest is what keeps a static `tty7-server` from linking the whole
|
||||
//! `zbus`/`secret-service` stack it can never use.
|
||||
//!
|
||||
//! What has to stay is exactly what `Config` needs to parse `config.json`
|
||||
//! identically on the server: the account-naming scheme and [`CredentialRef`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha512};
|
||||
|
||||
/// Keychain service name for endpoint passwords.
|
||||
pub const SERVICE_PASSWORD: &str = "tty7-ssh";
|
||||
/// Keychain service name for private-key passphrases.
|
||||
pub const SERVICE_KEY_PASSPHRASE: &str = "tty7-ssh-key";
|
||||
|
||||
/// Which kind of secret a [`CredentialRef`] points at. The kind selects the
|
||||
/// keychain *service*; the ref's `account` selects the entry within it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CredentialKind {
|
||||
/// An endpoint password (`tty7-ssh` service, `user@host:port` account).
|
||||
#[default]
|
||||
Password,
|
||||
/// A private-key passphrase (`tty7-ssh-key` service, key-sha512-hex account).
|
||||
KeyPassphrase,
|
||||
}
|
||||
|
||||
impl CredentialKind {
|
||||
/// The keychain service name this kind stores under.
|
||||
pub fn service(self) -> &'static str {
|
||||
match self {
|
||||
CredentialKind::Password => SERVICE_PASSWORD,
|
||||
@@ -55,17 +21,11 @@ impl CredentialKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A persisted, secret-free pointer to a keychain entry. This is the only
|
||||
/// credential-related thing that ever lands in `config.json`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CredentialRef {
|
||||
/// Whether this names a password or a key passphrase.
|
||||
#[serde(deserialize_with = "crate::core::config::de_lenient")]
|
||||
pub kind: CredentialKind,
|
||||
/// The keychain "account": `user@host:port` for [`CredentialKind::Password`],
|
||||
/// or the sha512-hex of the key-file contents for
|
||||
/// [`CredentialKind::KeyPassphrase`].
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
@@ -79,7 +39,6 @@ impl Default for CredentialRef {
|
||||
}
|
||||
|
||||
impl CredentialRef {
|
||||
/// Reference the password entry for an endpoint.
|
||||
pub fn password(user: &str, host: &str, port: u16) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::Password,
|
||||
@@ -87,8 +46,6 @@ impl CredentialRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference the passphrase entry for a private key, given the sha512-hex of
|
||||
/// its file contents (see [`key_account_from_contents`]).
|
||||
pub fn key_passphrase(key_sha512_hex: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: CredentialKind::KeyPassphrase,
|
||||
@@ -96,28 +53,17 @@ impl CredentialRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keychain service this ref resolves under.
|
||||
pub fn service(&self) -> &'static str {
|
||||
self.kind.service()
|
||||
}
|
||||
}
|
||||
|
||||
/// The endpoint account string used to key a password entry: `user@host:port`.
|
||||
pub fn endpoint_account(user: &str, host: &str, port: u16) -> String {
|
||||
format!("{user}@{host}:{port}")
|
||||
}
|
||||
|
||||
/// The account string used to key a private-key passphrase entry: the lowercase
|
||||
/// sha512-hex digest of the key file's raw contents. Endpoint-independent, so the
|
||||
/// same encrypted key reused across hosts shares one stored passphrase.
|
||||
///
|
||||
/// Only the GUI calls this — it is the side that reads the key file — but the
|
||||
/// account *name* is part of the persisted config contract, the same as
|
||||
/// [`endpoint_account`], so both halves of PRD §7.2's keying scheme stay in one
|
||||
/// place rather than drifting apart across the crate boundary.
|
||||
pub fn key_account_from_contents(key_bytes: &[u8]) -> String {
|
||||
let digest = Sha512::digest(key_bytes);
|
||||
// Lowercase hex, no separators.
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
@@ -141,7 +87,6 @@ mod tests {
|
||||
"deploy@10.0.0.5:2222"
|
||||
);
|
||||
|
||||
// sha512 hex is 128 chars, lowercase, and deterministic.
|
||||
let a = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
let b = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n");
|
||||
assert_eq!(a, b);
|
||||
@@ -163,13 +108,11 @@ mod tests {
|
||||
fn credential_ref_round_trips_and_hides_secret() {
|
||||
let cref = CredentialRef::password("deploy", "10.0.0.5", 22);
|
||||
let json = serde_json::to_string(&cref).unwrap();
|
||||
// Only kind + account are serialized — never a secret.
|
||||
assert!(json.contains("deploy@10.0.0.5:22"));
|
||||
assert!(json.contains("password"));
|
||||
let back: CredentialRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, cref);
|
||||
|
||||
// A bad `kind` value falls back to the default rather than failing the parse.
|
||||
let lenient: CredentialRef =
|
||||
serde_json::from_str(r#"{"kind":"bogus","account":"x"}"#).unwrap();
|
||||
assert_eq!(lenient.kind, CredentialKind::Password);
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
//! File logger — the `log::` records that otherwise go nowhere.
|
||||
//!
|
||||
//! tty7 depends on the `log` facade but shipped no backend, so every
|
||||
//! `log::info!` / `log::warn!` in the tree was compiled in and then discarded.
|
||||
//! That is survivable for the GUI, which can put a failure on screen. It is not
|
||||
//! survivable for the **daemon**: [`crate::daemon::spawn`] detaches it with its
|
||||
//! stdio pointed at `/dev/null`, so a remote install that refused, a connection
|
||||
//! that dropped, or a pane that died left no trace anywhere — the only artifact
|
||||
//! the process could produce was `crash.log`, and only if it panicked.
|
||||
//!
|
||||
//! So: one append-only file next to `crash.log`, same size cap and same
|
||||
//! best-effort discipline. Logging must never be the reason something fails.
|
||||
//!
|
||||
//! ## Level
|
||||
//!
|
||||
//! `TTY7_LOG` (or `RUST_LOG`) sets it — `off` / `error` / `warn` / `info` /
|
||||
//! `debug` / `trace`. **Default `off`**: this writes to a user's disk forever,
|
||||
//! and a terminal that logs by default is a terminal that fills a disk while
|
||||
//! nobody is watching. Ask for it when diagnosing, which is also the only time
|
||||
//! the records are worth anything.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
/// Rewrite the log once it passes this. Same cap as `crash.log`, larger because
|
||||
/// a debug session produces many small lines rather than a few big backtraces.
|
||||
const MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
struct FileLogger {
|
||||
role: &'static str,
|
||||
path: PathBuf,
|
||||
/// Serializes writes so two threads cannot interleave halves of a line.
|
||||
/// Contended only while logging is on, which is not the default.
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
@@ -63,15 +38,6 @@ impl Log for FileLogger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger for this process, if the environment asks for one.
|
||||
///
|
||||
/// `role` labels the records, since the GUI and the daemon it spawns share one
|
||||
/// config dir and therefore one log file — the same convention `crash.log`
|
||||
/// uses, and the reason a line can be attributed at all.
|
||||
///
|
||||
/// Idempotent and silent on failure: a second call, a missing config dir, or a
|
||||
/// read-only disk all leave the process running with no logger, which is
|
||||
/// exactly what it had before.
|
||||
pub fn install(role: &'static str) {
|
||||
let level = level_from_env();
|
||||
if level == LevelFilter::Off {
|
||||
@@ -80,10 +46,6 @@ pub fn install(role: &'static str) {
|
||||
let Some(path) = log_path() else {
|
||||
return;
|
||||
};
|
||||
// A `static` rather than `set_boxed_logger`, which needs `log`'s `std`
|
||||
// feature — not enabled here, and not worth enabling for one allocation
|
||||
// that lives for the whole process anyway. `OnceLock` is also what makes a
|
||||
// second call harmless.
|
||||
static LOGGER: OnceLock<FileLogger> = OnceLock::new();
|
||||
let logger = LOGGER.get_or_init(|| FileLogger {
|
||||
role,
|
||||
@@ -95,12 +57,6 @@ pub fn install(role: &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `TTY7_LOG` first, then `RUST_LOG` — the former so turning on tty7's logging
|
||||
/// does not also turn on every library that reads `RUST_LOG`.
|
||||
///
|
||||
/// Only a bare level is understood, not `RUST_LOG`'s per-module syntax: a
|
||||
/// half-supported filter language is worse than an obvious one, because
|
||||
/// `TTY7_LOG=tty7_core::daemon=debug` would silently mean "off".
|
||||
fn level_from_env() -> LevelFilter {
|
||||
let raw = std::env::var("TTY7_LOG")
|
||||
.or_else(|_| std::env::var("RUST_LOG"))
|
||||
@@ -139,9 +95,6 @@ fn log_path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("tty7.log")
|
||||
}
|
||||
|
||||
/// `HH:MM:SS.mmm` — the time of day, which is what you compare against "I
|
||||
/// clicked it just now". The date is in `crash.log`'s records and in the file's
|
||||
/// own mtime; repeating it on every line would cost more than it tells.
|
||||
fn timestamp() -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -161,18 +114,11 @@ mod tests {
|
||||
use super::*;
|
||||
use log::Level;
|
||||
|
||||
/// The default has to be `Off`. A terminal that logs to disk unasked fills
|
||||
/// a disk on a machine nobody is watching — and the daemon outlives every
|
||||
/// window, so there is no session boundary to bound it.
|
||||
#[test]
|
||||
fn logging_is_off_unless_asked_for() {
|
||||
// Not via the environment: mutating it is `unsafe` in edition 2024 and
|
||||
// races every other test in the binary. The parser is the whole
|
||||
// decision, so it is what gets tested.
|
||||
assert_eq!(parse_level(""), LevelFilter::Off);
|
||||
assert_eq!(parse_level(" "), LevelFilter::Off);
|
||||
assert_eq!(parse_level("nonsense"), LevelFilter::Off);
|
||||
// `RUST_LOG`'s per-module syntax is deliberately *not* half-supported.
|
||||
assert_eq!(parse_level("tty7_core::daemon=debug"), LevelFilter::Off);
|
||||
}
|
||||
|
||||
@@ -185,8 +131,6 @@ mod tests {
|
||||
assert_eq!(parse_level("TRACE"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
/// A run away log must not grow without bound: past the cap the file is
|
||||
/// rewritten rather than appended to.
|
||||
#[test]
|
||||
fn the_file_is_rewritten_once_it_passes_the_cap() {
|
||||
let path = std::env::temp_dir().join(format!("tty7-logfile-{}.log", std::process::id()));
|
||||
@@ -205,9 +149,6 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Records name which process wrote them: the GUI and the daemon it spawns
|
||||
/// share one config dir, so an unattributed line is ambiguous exactly when
|
||||
/// it matters (which side dropped the connection?).
|
||||
#[test]
|
||||
fn a_record_names_its_role_and_target() {
|
||||
let path = std::env::temp_dir().join(format!("tty7-logrec-{}.log", std::process::id()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,13 @@
|
||||
//! Domain core: the configuration model, session persistence, the streaming
|
||||
//! OSC tokenizer shared by the daemon- and client-side output scanners, and the
|
||||
//! shell / agent / git knowledge the daemon and the GUI have to share.
|
||||
//!
|
||||
//! These modules are framework-light and depend on neither `ui` nor `terminal`
|
||||
//! — the dependency arrow always points *inward* to here. That is what let them
|
||||
//! lift out of the GUI binary into this crate without untangling view code.
|
||||
//!
|
||||
//! The GUI crate re-exports this module as `crate::core`, adding its own
|
||||
//! gpui-facing modules (`actions`, `update`, …) and thin gpui layers over
|
||||
//! `config`, `session` and `window_state`, so call sites there are unchanged.
|
||||
|
||||
pub mod agent_hooks;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
pub mod git;
|
||||
pub mod gitignore;
|
||||
pub mod logfile;
|
||||
pub mod machine;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
#[allow(dead_code)]
|
||||
pub mod keychain;
|
||||
pub mod logfile;
|
||||
pub mod machine;
|
||||
pub mod osc;
|
||||
pub mod proc;
|
||||
pub mod session;
|
||||
|
||||
@@ -1,56 +1,19 @@
|
||||
//! Streaming OSC (Operating System Command) extractor.
|
||||
//!
|
||||
//! The one implementation of OSC wire framing, shared by both byte-stream
|
||||
//! consumers: the daemon-side cwd/prompt sniffer (`daemon::pane`, OSC 7/133)
|
||||
//! and the client-side notification scanner (`terminal::remote`, OSC 9/777).
|
||||
//! The framing rules — `ESC ]` opens, `BEL` or `ESC \` (ST) terminates, a bare
|
||||
//! `ESC ]` inside an unterminated sequence re-opens a fresh one, oversized
|
||||
//! payloads are abandoned — are subtle enough that both sites needed the same
|
||||
//! resync bugfix when each carried its own copy. Keeping the state machine
|
||||
//! here means a framing change can't silently apply to one consumer and not
|
||||
//! the other.
|
||||
//!
|
||||
//! This is deliberately *not* a full VT parser (the grid has an
|
||||
//! `ansi::Processor` for that). It tracks just enough state to hand complete
|
||||
//! payloads of the OSC identifiers a consumer cares about to its callback,
|
||||
//! bailing out cheaply on any other OSC (e.g. a multi-megabyte OSC 52
|
||||
//! clipboard write) without buffering it.
|
||||
|
||||
/// Cap on how many bytes of a single OSC payload we'll buffer before giving up
|
||||
/// on it — a guard against an unterminated or absurdly long sequence growing
|
||||
/// the buffer without bound. Real cwd/prompt/notification payloads are far
|
||||
/// shorter.
|
||||
const MAX_PAYLOAD: usize = 8192;
|
||||
|
||||
/// A streaming tokenizer for the OSC sequences whose identifiers are listed in
|
||||
/// `ids`. Feed it raw output bytes; it invokes a callback with each complete
|
||||
/// payload. State persists across `feed` calls, so a sequence split over
|
||||
/// multiple reads is still recognized.
|
||||
pub struct OscTokenizer {
|
||||
/// OSC identifiers (the digits before the first `;`) the consumer wants
|
||||
/// buffered and delivered; every other OSC is discarded unbuffered.
|
||||
ids: &'static [&'static [u8]],
|
||||
/// Payload bytes accumulated after `ESC ]` while the identifier can still
|
||||
/// match `ids`. Cleared whenever a sequence finishes or is abandoned.
|
||||
buf: Vec<u8>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
enum State {
|
||||
/// Not inside an escape sequence.
|
||||
#[default]
|
||||
Ground,
|
||||
/// Saw `ESC` in ground state; a following `]` opens an OSC.
|
||||
Esc,
|
||||
/// Inside an OSC whose identifier still matches (a prefix of) `ids`;
|
||||
/// buffering the payload.
|
||||
Osc,
|
||||
/// Saw `ESC` while buffering an OSC — a following `\` is the `ST` terminator.
|
||||
OscEsc,
|
||||
/// Inside an OSC we've decided to ignore; discard bytes until the terminator.
|
||||
Ignore,
|
||||
/// Saw `ESC` while ignoring an OSC — a following `\` is the `ST` terminator.
|
||||
IgnoreEsc,
|
||||
}
|
||||
|
||||
@@ -63,22 +26,11 @@ impl OscTokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one chunk of output; invoke `on_payload` with the complete payload
|
||||
/// (identifier included, terminator excluded — e.g. `7;file://…`) of every
|
||||
/// interesting OSC that completes within the chunk.
|
||||
///
|
||||
/// The tokenizer sits on the full-throughput output stream (both the
|
||||
/// daemon's PTY reader and the client's socket reader run it over every
|
||||
/// byte), so the two states that dominate real streams — `Ground` between
|
||||
/// sequences, `Ignore` inside a discarded OSC (e.g. a multi-MB OSC 52) —
|
||||
/// skip ahead with SIMD `memchr` instead of stepping per byte. Everything
|
||||
/// else is rare enough to stay a plain per-byte state machine.
|
||||
pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) {
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match self.state {
|
||||
State::Ground => {
|
||||
// Nothing before the next ESC can matter.
|
||||
let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else {
|
||||
return;
|
||||
};
|
||||
@@ -87,8 +39,6 @@ impl OscTokenizer {
|
||||
continue;
|
||||
}
|
||||
State::Ignore => {
|
||||
// Only BEL (terminates) or ESC (may terminate or fork) can
|
||||
// end a discarded payload.
|
||||
let Some(off) = memchr::memchr2(0x07, 0x1b, &bytes[i..]) else {
|
||||
return;
|
||||
};
|
||||
@@ -104,23 +54,20 @@ impl OscTokenizer {
|
||||
}
|
||||
let b = bytes[i];
|
||||
match self.state {
|
||||
// Handled by the skip-ahead arms above.
|
||||
State::Ground | State::Ignore => unreachable!(),
|
||||
State::Esc => match b {
|
||||
b']' => {
|
||||
self.buf.clear();
|
||||
self.state = State::Osc;
|
||||
}
|
||||
0x1b => {} // a run of ESCs; keep waiting for the next byte
|
||||
0x1b => {}
|
||||
_ => self.state = State::Ground,
|
||||
},
|
||||
State::Osc => match b {
|
||||
0x07 => self.finish(&mut on_payload), // BEL terminator
|
||||
0x07 => self.finish(&mut on_payload),
|
||||
0x1b => self.state = State::OscEsc,
|
||||
_ => {
|
||||
self.buf.push(b);
|
||||
// Abandon as soon as the identifier can't be one of
|
||||
// `ids`, or the payload grows unreasonably large.
|
||||
if self.buf.len() > MAX_PAYLOAD || !self.identifier_could_match() {
|
||||
self.buf.clear();
|
||||
self.state = State::Ignore;
|
||||
@@ -128,28 +75,20 @@ impl OscTokenizer {
|
||||
}
|
||||
},
|
||||
State::OscEsc => match b {
|
||||
b'\\' => self.finish(&mut on_payload), // ST terminator
|
||||
0x1b => {} // another ESC: stay poised for the `\`
|
||||
// The ESC began a *new* OSC, aborting this unterminated one.
|
||||
// Re-open a fresh OSC instead of dropping the `]` into
|
||||
// Ground — otherwise a well-formed sequence directly
|
||||
// following an unterminated one would be silently lost.
|
||||
b'\\' => self.finish(&mut on_payload),
|
||||
0x1b => {}
|
||||
b']' => {
|
||||
self.buf.clear();
|
||||
self.state = State::Osc;
|
||||
}
|
||||
_ => {
|
||||
// ESC began some other (non-OSC) escape: abandon this OSC.
|
||||
self.buf.clear();
|
||||
self.state = State::Ground;
|
||||
}
|
||||
},
|
||||
State::IgnoreEsc => match b {
|
||||
b'\\' => self.state = State::Ground,
|
||||
0x1b => {} // stay, another ESC
|
||||
// Same resync as `OscEsc`: the ESC opened a new OSC — scan
|
||||
// it rather than missing the sequence that follows an
|
||||
// unterminated, ignored one (e.g. a title OSC).
|
||||
0x1b => {}
|
||||
b']' => {
|
||||
self.buf.clear();
|
||||
self.state = State::Osc;
|
||||
@@ -161,9 +100,6 @@ impl OscTokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the identifier accumulated so far can still become one of `ids`.
|
||||
/// Before the first `;` it is a prefix being built up; once the `;` arrives
|
||||
/// it must match exactly.
|
||||
fn identifier_could_match(&self) -> bool {
|
||||
match self.buf.iter().position(|&b| b == b';') {
|
||||
Some(pos) => self.ids.iter().any(|&id| id == &self.buf[..pos]),
|
||||
@@ -171,7 +107,6 @@ impl OscTokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete, interesting OSC payload arrived: hand it to the consumer.
|
||||
fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) {
|
||||
on_payload(&self.buf);
|
||||
self.buf.clear();
|
||||
@@ -179,23 +114,8 @@ impl OscTokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done`
|
||||
/// or `777;notify;Title;Body`) into a `(title, body)` desktop notification, or
|
||||
/// `None` if it isn't one. Shared by the client's notification toaster
|
||||
/// (`terminal::remote`) and the daemon's agent-status sniffer (`daemon::pane`),
|
||||
/// so ConEmu's OSC 9 subcommand quirks are handled in exactly one place.
|
||||
///
|
||||
/// tty7's own agent-event sentinel (`777;notify;tty7://cli-agent;{json}` — see
|
||||
/// [`crate::core::cli_agent::AGENT_EVENT_SENTINEL`]) parses as a notification
|
||||
/// *shape*, but it is machine-to-machine traffic: callers that surface toasts
|
||||
/// must check for it first (via [`crate::core::cli_agent::parse_agent_event`])
|
||||
/// rather than showing the raw JSON to the user.
|
||||
pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
// OSC 9 ; <text> — iTerm2 / growl style; title-less, body is the text.
|
||||
if let Some(rest) = payload.strip_prefix(b"9;") {
|
||||
// ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress,
|
||||
// `9;9;<cwd>`, …); those aren't notifications, so skip a `<digit>;`/`<digit>`
|
||||
// leading field. A real message rarely starts with a bare single digit.
|
||||
let first = rest.split(|&b| b == b';').next().unwrap_or(rest);
|
||||
if first.len() == 1 && first[0].is_ascii_digit() {
|
||||
return None;
|
||||
@@ -203,15 +123,12 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
let body = String::from_utf8_lossy(rest).into_owned();
|
||||
return (!body.is_empty()).then_some((None, body));
|
||||
}
|
||||
// OSC 777 ; notify ; <title> ; <body> — urxvt style.
|
||||
if let Some(rest) = payload.strip_prefix(b"777;notify;") {
|
||||
let mut parts = rest.splitn(2, |&b| b == b';');
|
||||
let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned();
|
||||
let second = parts
|
||||
.next()
|
||||
.map(|b| String::from_utf8_lossy(b).into_owned());
|
||||
// With both fields present it's title + body; with only one it's a body-only
|
||||
// notification (some senders omit the title).
|
||||
let (title, body) = match second {
|
||||
Some(body) if !body.is_empty() => (Some(first), body),
|
||||
_ => (None, first),
|
||||
@@ -225,7 +142,6 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Run a tokenizer for `ids` over the chunks and collect delivered payloads.
|
||||
fn collect(ids: &'static [&'static [u8]], chunks: &[&[u8]]) -> Vec<Vec<u8>> {
|
||||
let mut tok = OscTokenizer::new(ids);
|
||||
let mut out = Vec::new();
|
||||
@@ -249,7 +165,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sequence_split_across_reads_is_reassembled() {
|
||||
// Torn mid-payload and between the ESC and its ST backslash.
|
||||
assert_eq!(
|
||||
collect(&[b"7"], &[b"\x1b]7;file:", b"//h/x", b"\x07"]),
|
||||
vec![b"7;file://h/x".to_vec()]
|
||||
@@ -262,8 +177,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn uninteresting_identifiers_are_skipped_and_state_recovers() {
|
||||
// OSC 0 (title) and OSC 52 (clipboard) are not in `ids`: nothing is
|
||||
// delivered, and an interesting OSC right after is still caught.
|
||||
assert_eq!(
|
||||
collect(
|
||||
&[b"9"],
|
||||
@@ -275,10 +188,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resyncs_on_new_osc_after_an_unterminated_one() {
|
||||
// Regression (fixed independently in both pre-extraction copies): the
|
||||
// ESC that aborts an unterminated OSC may itself open the next one; the
|
||||
// `]` must re-open a fresh OSC rather than fall into Ground. Covers
|
||||
// both the buffering path and the ignore path.
|
||||
assert_eq!(
|
||||
collect(&[b"9"], &[b"\x1b]9;dropped\x1b]9;kept\x07"]),
|
||||
vec![b"9;kept".to_vec()]
|
||||
@@ -291,22 +200,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identifier_prefix_matching_buffers_only_possible_ids() {
|
||||
// `77` is a prefix of `777` but `78` can no longer match: only the
|
||||
// former's completed sequence is delivered.
|
||||
let ids: &'static [&'static [u8]] = &[b"777"];
|
||||
assert_eq!(
|
||||
collect(ids, &[b"\x1b]78;x\x07\x1b]777;y\x07"]),
|
||||
vec![b"777;y".to_vec()]
|
||||
);
|
||||
// After the `;` the identifier must match exactly: `77;` is not `777`.
|
||||
assert_eq!(collect(ids, &[b"\x1b]77;x\x07"]), Vec::<Vec<u8>>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_payload_is_abandoned_not_truncated() {
|
||||
// A payload past the cap is dropped entirely (delivering a truncated
|
||||
// cwd or notification would be worse than delivering none), and the
|
||||
// stream recovers for the next sequence.
|
||||
let mut big = b"\x1b]9;".to_vec();
|
||||
big.extend(std::iter::repeat_n(b'x', MAX_PAYLOAD + 1));
|
||||
big.extend_from_slice(b"\x07\x1b]9;next\x07");
|
||||
@@ -315,8 +218,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn byte_at_a_time_delivery_reassembles_every_state_transition() {
|
||||
// The harshest tearing: one byte per `feed` call, crossing every state
|
||||
// boundary (ESC/], identifier, payload, ESC/\ terminator) between reads.
|
||||
let stream = b"\x1b]0;title\x07\x1b]133;A\x1b\\plain\x1b]7;file://h/x\x07";
|
||||
let chunks: Vec<&[u8]> = stream.chunks(1).collect();
|
||||
assert_eq!(
|
||||
@@ -327,8 +228,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ignored_sequence_split_across_reads_still_recovers() {
|
||||
// An uninteresting OSC torn across chunks must keep being discarded
|
||||
// (state persists across `feed`s), and the next interesting one lands.
|
||||
assert_eq!(
|
||||
collect(
|
||||
&[b"9"],
|
||||
@@ -340,12 +239,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() {
|
||||
// ESC ESC ] still opens an OSC (the last ESC wins).
|
||||
assert_eq!(
|
||||
collect(&[b"9"], &[b"\x1b\x1b]9;ok\x07"]),
|
||||
vec![b"9;ok".to_vec()]
|
||||
);
|
||||
// An ESC inside an OSC followed by a non-OSC escape abandons cleanly.
|
||||
assert_eq!(
|
||||
collect(&[b"9"], &[b"\x1b]9;half\x1b[0m\x1b]9;whole\x07"]),
|
||||
vec![b"9;whole".to_vec()]
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
//! One place for the Windows subprocess flag every helper shell-out needs.
|
||||
//!
|
||||
//! tty7 is a GUI process with no console of its own, so launching a console
|
||||
//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh
|
||||
//! console for it — a black window that pops up and vanishes. That is invisible
|
||||
//! on a one-off invocation and very visible on the git-status probe, which runs
|
||||
//! four `git` calls every time a pane's cwd changes or a command ends.
|
||||
//!
|
||||
//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are
|
||||
//! unaffected, so output capture keeps working. Every non-PTY `Command` in the
|
||||
//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the
|
||||
//! async flavor) before it runs. PTY children are not in scope — the daemon
|
||||
//! owns those and passes its own flags (see [`crate::daemon::spawn`]).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Suppress the console window Windows would otherwise allocate for a console
|
||||
/// subsystem child. No-op on Unix, so callers stay `cfg`-free.
|
||||
pub fn hide_console(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -28,9 +12,6 @@ pub fn hide_console(cmd: &mut Command) -> &mut Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's
|
||||
/// builder is a distinct type with its own `creation_flags`, not a `Deref` to
|
||||
/// the std one.
|
||||
pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
|
||||
@@ -1,70 +1,31 @@
|
||||
//! The client's workspace bookkeeping: the in-memory [`Session`] shape a
|
||||
//! window is built from, and the persisted [`WindowView`] entries — pure view
|
||||
//! state, because the layout itself lives in each machine's daemon-owned tree
|
||||
//! (`core::machine`).
|
||||
//!
|
||||
//! [`Session`] / [`SessionTab`] / [`SessionPane`] mirror the live `Pane` tree
|
||||
//! without GPUI types. They are **not persisted any more**: the window builder
|
||||
//! consumes them, the tree hydration produces them, and the closed-tab stack
|
||||
//! holds them, all in memory.
|
||||
//!
|
||||
//! [`WindowViews`] is the file — `~/.config/tty7/views.json`, alongside
|
||||
//! `config.json`. All IO is best-effort: a missing/corrupt file just means "no
|
||||
//! views to restore", and write failures are logged rather than fatal — the
|
||||
//! app must never crash or stall over view bookkeeping.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::daemon::protocol::NativeSshSpec;
|
||||
|
||||
/// Split orientation, mirroring `gpui::Axis` (which isn't `Serialize`).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum SessionAxis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// A serializable mirror of one tab's `Pane` tree.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SessionPane {
|
||||
/// A single terminal, restored in `cwd` (or the default dir if `None`).
|
||||
Leaf {
|
||||
#[serde(default)]
|
||||
cwd: Option<PathBuf>,
|
||||
/// Daemon pane id this leaf was mirroring. On restore we re-`attach` to
|
||||
/// it when the daemon still has it alive (process + scrollback intact),
|
||||
/// else fall back to spawning a fresh shell in `cwd`. `None` for sessions
|
||||
/// written by an older build (they just spawn fresh).
|
||||
#[serde(default)]
|
||||
pane_id: Option<u64>,
|
||||
/// The native-SSH spec this leaf ran, **with secrets stripped**
|
||||
/// ([`NativeSshSpec::without_secrets`]). Persisted so a *dead* native-SSH
|
||||
/// pane can be respawned (reconnected) on restore rather than falling back
|
||||
/// to a local shell — the reconnection UX itself is WS6's. A live pane
|
||||
/// reattaches for free and needs none of this. `None` for local panes and
|
||||
/// for sessions written before this field existed.
|
||||
#[serde(default)]
|
||||
ssh_spec: Option<Box<NativeSshSpec>>,
|
||||
/// The coding agent this leaf was running at save time, plus its native
|
||||
/// session id (from the agent's own `session-start` event). When the
|
||||
/// pane can't re-attach on restore, these drive the cmux-style resume:
|
||||
/// the fresh shell is handed the agent's resume command
|
||||
/// (`claude --resume <id>`, …) so the conversation continues. `None`
|
||||
/// for panes without an agent, agents without hooks, or old sessions.
|
||||
#[serde(default)]
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
#[serde(default)]
|
||||
agent_session_id: Option<String>,
|
||||
/// The argv the agent was launched with, as the daemon observed it —
|
||||
/// lets the resume command carry the user's launch flags
|
||||
/// (`--dangerously-skip-permissions`, …) instead of resuming bare.
|
||||
/// `None` for old sessions or when nothing was captured.
|
||||
#[serde(default)]
|
||||
agent_launch_argv: Option<Vec<String>>,
|
||||
},
|
||||
/// A split of two subtrees along `axis`, with `a` taking `ratio` of space.
|
||||
Split {
|
||||
axis: SessionAxis,
|
||||
#[serde(default = "default_ratio")]
|
||||
@@ -78,47 +39,17 @@ fn default_ratio() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
/// A serializable mirror of one tab: its pane tree plus an optional user-set
|
||||
/// name (from "Rename Tab"). A missing `name` falls back to the title-derived
|
||||
/// label at render time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionTab {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub pane: SessionPane,
|
||||
/// The tab's last-known sidebar repo group (its repository home — the
|
||||
/// main checkout's root, shared by all its linked worktrees), so a
|
||||
/// restored session renders grouped immediately instead of starting flat
|
||||
/// and reshuffling as git probes land. `None` = Scratch / never resolved.
|
||||
///
|
||||
/// **A bare path, and that is sound.** A path alone cannot say *which*
|
||||
/// machine it is on, and [`HostId`](crate::host::HostId) — which could —
|
||||
/// is deliberately not persistable. The qualifier is not missing, it is
|
||||
/// factored out: a tab always belongs to exactly one workspace, a
|
||||
/// workspace names exactly one machine in [`WindowView::host`], and a
|
||||
/// window shows exactly one workspace — mixing local and remote tabs in one
|
||||
/// window is the thing tty7 never does. So the fully-qualified group key
|
||||
/// is `(view.host_id(), tab.sidebar_group)`, with the host half
|
||||
/// stored once per workspace instead of once per tab. Two machines whose
|
||||
/// repos share a root path can only collide inside one window, which the
|
||||
/// model does not permit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sidebar_group: Option<std::path::PathBuf>,
|
||||
/// The tab's identity in the daemon's machine tree, when this session was
|
||||
/// derived *from* that tree — so a window rebuilt from it addresses the
|
||||
/// daemon's tabs rather than minting new ids and churning them. **Never
|
||||
/// persisted**: the tree is the authority on its own ids, and a stale one
|
||||
/// written to disk would collide with a tab the daemon has since reused it
|
||||
/// for. `None` (every other source) mints a fresh id.
|
||||
#[serde(skip)]
|
||||
pub tree_id: Option<crate::core::machine::TabId>,
|
||||
}
|
||||
|
||||
/// One workspace's contents: the open tabs and which one was active.
|
||||
///
|
||||
/// This is the unit a single window displays — the in-memory shape a window
|
||||
/// is built from and lowered into, never persisted (the machine's tree is the
|
||||
/// layout's home).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Session {
|
||||
@@ -126,9 +57,6 @@ pub struct Session {
|
||||
pub tabs: Vec<SessionTab>,
|
||||
}
|
||||
|
||||
/// Stable identity for a workspace, minted once when it is first created and
|
||||
/// carried across restarts. Windows are transient views; *this* is what the
|
||||
/// workspace picker reopens and what a window handle maps back to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkspaceId(uuid::Uuid);
|
||||
@@ -138,8 +66,6 @@ impl WorkspaceId {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// A stable numeric key for gpui element ids, which need something
|
||||
/// hashable and cheap rather than a freshly formatted string each frame.
|
||||
pub fn element_key(&self) -> u64 {
|
||||
self.0.as_u64_pair().0
|
||||
}
|
||||
@@ -160,78 +86,34 @@ impl std::fmt::Display for WorkspaceId {
|
||||
impl std::str::FromStr for WorkspaceId {
|
||||
type Err = uuid::Error;
|
||||
|
||||
/// The inverse of `Display`, for the places a workspace id crosses a
|
||||
/// string-keyed boundary (the control dialect's attach verbs, which
|
||||
/// predate the typed tree) and has to come back out as itself.
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
s.parse().map(WorkspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote references
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The machine a remote workspace lives on, named the way the user already
|
||||
/// named it.
|
||||
///
|
||||
/// **This is a pointer, never a configuration.** It is a hard rule that a
|
||||
/// machine is configured once and that remote workspaces reuse what is already
|
||||
/// there — the profile's keys, its jump host, its `ProxyCommand` — so this type
|
||||
/// has exactly one job: say *which* existing entry to connect through. The
|
||||
/// three variants are the three places an SSH target can already have been
|
||||
/// spelled out in tty7 today.
|
||||
///
|
||||
/// | Variant | Where it came from | Connection key |
|
||||
/// |---|---|---|
|
||||
/// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:<uuid>` |
|
||||
/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:<alias>` |
|
||||
/// | [`Direct`](RemoteTarget::Direct) | A typed `user@host:port` (QuickConnect) | `ssh-direct:<user>@<host>:<port>` |
|
||||
/// | [`Wsl`](RemoteTarget::Wsl) | A distribution installed on this computer, as `wsl.exe -l -q` names it | `wsl:<distro>` |
|
||||
///
|
||||
/// Persisted, unlike [`HostId`](crate::host::HostId): this is what survives a
|
||||
/// restart, and the id is derived from it at connect time.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RemoteTarget {
|
||||
/// A saved SSH profile, referenced by [`SshProfile::id`](crate::core::ssh_profile::SshProfile::id).
|
||||
Profile { id: uuid::Uuid },
|
||||
/// A `Host` alias from `~/.ssh/config`. Kept verbatim — OpenSSH matches
|
||||
/// alias names case-sensitively, so folding case here would point at a
|
||||
/// different stanza than `ssh <alias>` would.
|
||||
Alias { alias: String },
|
||||
/// A target typed straight in, as `parse_quick_connect` understands it.
|
||||
Profile {
|
||||
id: uuid::Uuid,
|
||||
},
|
||||
Alias {
|
||||
alias: String,
|
||||
},
|
||||
Direct {
|
||||
/// The login user. Empty means "whatever this client's SSH would use",
|
||||
/// which is a *different* connection key than a spelled-out user — see
|
||||
/// [`RemoteTarget::connection_key`].
|
||||
#[serde(default)]
|
||||
user: String,
|
||||
/// Hostname or IP, lowercased (DNS is case-insensitive).
|
||||
host: String,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
port: u16,
|
||||
},
|
||||
/// A WSL distribution, named exactly as `wsl -d` takes it.
|
||||
///
|
||||
/// **The one machine that is configured zero times**: it is reached by
|
||||
/// spawning `wsl.exe`, so there is no address, no credential and no host
|
||||
/// key to spell out anywhere. The picker
|
||||
/// (`ui::remote_connect::available_hosts`) therefore offers every
|
||||
/// distribution installed on this computer rather than reading a store.
|
||||
Wsl { distro: String },
|
||||
/// A `tty7-server --stdio` child process on *this* machine — the workspace
|
||||
/// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio),
|
||||
/// and the only way to exercise a real remote workspace end to end without
|
||||
/// an sshd.
|
||||
///
|
||||
/// **Never offered by the picker.** It is reachable only when
|
||||
/// `TTY7_LOCAL_STDIO_SERVER` names a server binary, which is how the
|
||||
/// end-to-end tests and a developer's `dev-verify` run stand a machine up.
|
||||
/// It grants no authority the socket did not already have: a pane's
|
||||
/// `ClientMsg::Spawn` already runs an arbitrary program as this user over
|
||||
/// that same user-private socket.
|
||||
LocalStdio { program: String, args: Vec<String> },
|
||||
Wsl {
|
||||
distro: String,
|
||||
},
|
||||
LocalStdio {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
@@ -239,11 +121,6 @@ fn default_ssh_port() -> u16 {
|
||||
}
|
||||
|
||||
impl RemoteTarget {
|
||||
/// A `user@host:port` target, normalized.
|
||||
///
|
||||
/// The host is lowercased here *and* in [`connection_key`](Self::connection_key)
|
||||
/// — here so two equal targets compare equal, there so a hand-edited
|
||||
/// `views.json` with `Box.Local` still derives the same id as `box.local`.
|
||||
pub fn direct(user: impl Into<String>, host: impl Into<String>, port: u16) -> RemoteTarget {
|
||||
RemoteTarget::Direct {
|
||||
user: user.into(),
|
||||
@@ -252,15 +129,6 @@ impl RemoteTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `[ssh://]user@host[:port]` into a [`Direct`](RemoteTarget::Direct)
|
||||
/// target.
|
||||
///
|
||||
/// Deliberately delegates to
|
||||
/// [`parse_quick_connect`](crate::core::ssh_profile::parse_quick_connect)
|
||||
/// rather than parsing again: "the same string the connection manager
|
||||
/// already accepts" is the whole promise of this variant, and a second
|
||||
/// parser would be a second opinion about IPv6 brackets and `@` in
|
||||
/// usernames. `None` for anything that parser rejects.
|
||||
pub fn parse_direct(input: &str) -> Option<RemoteTarget> {
|
||||
let q = crate::core::ssh_profile::parse_quick_connect(input)?;
|
||||
let port = q.port_or_default();
|
||||
@@ -271,17 +139,6 @@ impl RemoteTarget {
|
||||
))
|
||||
}
|
||||
|
||||
/// The canonical connection string this target hashes to.
|
||||
///
|
||||
/// **Contains no workspace id.** Several workspaces on one box share a key,
|
||||
/// and therefore share a [`HostId`](crate::host::HostId) and the one SSH
|
||||
/// connection underneath it — the granularity the whole design assumes.
|
||||
///
|
||||
/// One conservative case worth knowing: `me@box` and a bare `box` are
|
||||
/// different keys even when the client's SSH would resolve them to the same
|
||||
/// login. That costs a second connection, never a wrong one; merging them
|
||||
/// would require resolving `~/.ssh/config` here, and getting *that* wrong
|
||||
/// would point two machines at one cache.
|
||||
pub fn connection_key(&self) -> String {
|
||||
match self {
|
||||
RemoteTarget::Profile { id } => format!("ssh-profile:{id}"),
|
||||
@@ -296,22 +153,6 @@ impl RemoteTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this machine is reached over SSH.
|
||||
///
|
||||
/// The question "Restart Server" asks, and the answer
|
||||
/// [`router::restart_server`](crate::daemon::router) already gives: it routes
|
||||
/// the action for SSH machines and refuses the other two. A `LocalStdio`
|
||||
/// machine is a child process per connection, so there is nothing there to
|
||||
/// stop and start; a WSL distribution's server is started by this client,
|
||||
/// which makes "stop it and reconnect" the whole of the verb and not
|
||||
/// something a routed action has to carry out. Asked here rather than
|
||||
/// re-spelled at each call site, so the UI that offers the verb and the
|
||||
/// router that carries it out cannot disagree about who has it.
|
||||
///
|
||||
/// Spelled out variant by variant rather than as a `matches!` of the three
|
||||
/// that say yes: this gates an action that ends every session on a machine,
|
||||
/// and a new [`RemoteTarget`] must not inherit an answer to that by falling
|
||||
/// off the end of a pattern. The compiler asks instead.
|
||||
pub fn is_ssh(&self) -> bool {
|
||||
match self {
|
||||
RemoteTarget::Profile { .. }
|
||||
@@ -321,23 +162,12 @@ impl RemoteTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-process id this target resolves to.
|
||||
///
|
||||
/// This is the **only** bridge between the persisted world and the runtime
|
||||
/// one: `RemoteRef` is what survives a restart, `HostId` is what the
|
||||
/// in-memory tables key on, and this function is how you get from the first
|
||||
/// to the second. There is deliberately no inverse — an id is a hash, and a
|
||||
/// structure that wanted to persist "which host" must persist a
|
||||
/// [`RemoteTarget`].
|
||||
pub fn host_id(&self) -> crate::host::HostId {
|
||||
crate::host::HostId::from_connection_key(&self.connection_key())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteTarget {
|
||||
/// A label for a status bar or a picker row. A profile shows as its uuid
|
||||
/// because the name lives in the profile store, which this type
|
||||
/// deliberately does not reach into.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RemoteTarget::Profile { id } => write!(f, "{id}"),
|
||||
@@ -353,8 +183,6 @@ impl std::fmt::Display for RemoteTarget {
|
||||
Ok(())
|
||||
}
|
||||
RemoteTarget::Wsl { distro } => write!(f, "wsl:{distro}"),
|
||||
// The path, not the argv: this is a status-bar label, and the
|
||||
// arguments are `--stdio` boilerplate that says nothing useful.
|
||||
RemoteTarget::LocalStdio { program, .. } => {
|
||||
let name = std::path::Path::new(program)
|
||||
.file_name()
|
||||
@@ -366,19 +194,9 @@ impl std::fmt::Display for RemoteTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace that lives on another machine: which machine, and which
|
||||
/// workspace over there.
|
||||
///
|
||||
/// The `workspace` id is the **remote's**, minted once and then used as the
|
||||
/// workspace's id in that machine's daemon-owned tree
|
||||
/// ([`crate::core::machine`]). A client-side [`WindowView`] carrying one of
|
||||
/// these is a *view*, not the record: the layout lives on the remote, which
|
||||
/// owns it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RemoteRef {
|
||||
/// Which machine, in terms of a configuration that already exists.
|
||||
pub target: RemoteTarget,
|
||||
/// The workspace's id **on that machine**.
|
||||
pub workspace: WorkspaceId,
|
||||
}
|
||||
|
||||
@@ -387,66 +205,29 @@ impl RemoteRef {
|
||||
RemoteRef { target, workspace }
|
||||
}
|
||||
|
||||
/// The id of the machine this points at. Two refs to different workspaces
|
||||
/// on one box answer the same id.
|
||||
pub fn host_id(&self) -> crate::host::HostId {
|
||||
self.target.host_id()
|
||||
}
|
||||
|
||||
/// The wire key for this workspace — the form the string-keyed control
|
||||
/// verbs (the attach family) and the `ControlEvent::Layout` events carry.
|
||||
pub fn store_key(&self) -> String {
|
||||
self.workspace.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One workspace's **view state** on this client: which workspace (and on
|
||||
/// which machine), where its window last was, whether it was on screen, and
|
||||
/// when it was last focused. The layout itself lives in the machine's tree —
|
||||
/// this entry is deliberately only what the tree cannot know, the facts about
|
||||
/// *this client's windows*. Closing a window is a *detach*: the panes keep
|
||||
/// running in the daemon and the entry stays here with `open: false`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowView {
|
||||
#[serde(default)]
|
||||
pub id: WorkspaceId,
|
||||
/// Geometry this workspace's window last occupied, so reopening it lands
|
||||
/// where the user left it rather than at the shared default. `None` for a
|
||||
/// workspace that has never been on screen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub window: Option<crate::core::window_state::WindowState>,
|
||||
/// Whether a window was showing this workspace at quit. Launch reopens
|
||||
/// exactly one of the `open` ones; the rest wait in the picker.
|
||||
#[serde(default)]
|
||||
pub open: bool,
|
||||
/// Unix seconds when this workspace was last focused, for "2 minutes ago"
|
||||
/// in the picker and for ordering it. 0 == never recorded.
|
||||
///
|
||||
/// The machine's tree keeps its own recency; this copy exists because
|
||||
/// launch has to order entries before any tree has been pulled.
|
||||
#[serde(default)]
|
||||
pub last_active: u64,
|
||||
/// The machine this workspace's panes and files live on. `None` means this
|
||||
/// one. A `Some` entry keeps its own client-side `id` (the window
|
||||
/// registry's handle) while `host.workspace` names the workspace on that
|
||||
/// machine — see [`RemoteRef`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<RemoteRef>,
|
||||
/// What this workspace was *called* the last time its machine answered, and
|
||||
/// the path it was about — the picker's two lines.
|
||||
///
|
||||
/// **A render hint, never an authority.** The machine's tree owns both (it
|
||||
/// derives them from the tabs' repo groups and its panes' cwds), and
|
||||
/// whenever the tree answers, the tree wins. This copy exists because the
|
||||
/// picker's whole job is choosing among machines that are *not* answering:
|
||||
/// a laptop that has been shut since Friday still has to be listed as
|
||||
/// "tty7 — ~/repo/tty7" rather than as "Untitled" with a blank subtitle,
|
||||
/// which is a row nobody can act on. Stamped on every save (and on the way
|
||||
/// out, when a window closes), so what is on file is the last thing the
|
||||
/// user actually saw.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// The subject path behind [`label`](Self::label) — see there.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
@@ -466,14 +247,10 @@ impl Default for WindowView {
|
||||
}
|
||||
|
||||
impl WindowView {
|
||||
/// Stamp this workspace as just-focused.
|
||||
pub fn touch(&mut self) {
|
||||
self.last_active = now_secs();
|
||||
}
|
||||
|
||||
// ----- the local / remote split ----------------------------------------
|
||||
|
||||
/// A client-side entry for a workspace that lives on another machine.
|
||||
pub fn on_remote(host: RemoteRef) -> WindowView {
|
||||
WindowView {
|
||||
host: Some(host),
|
||||
@@ -481,19 +258,10 @@ impl WindowView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this workspace lives on another machine.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
self.host.is_some()
|
||||
}
|
||||
|
||||
/// The id of the machine this workspace's panes are on.
|
||||
///
|
||||
/// This is the qualifier that turns a bare path or a bare `pane_id` into
|
||||
/// something globally meaningful: `pane_id` is unique only within one remote
|
||||
/// server, so the client's pane identity is `(host_id, pane_id)`, and a
|
||||
/// repo root is unique only within one machine, so a sidebar group key is
|
||||
/// `(host_id, sidebar_group)`. Storing it once per workspace rather than
|
||||
/// once per pane is exactly what the one-window-one-machine rule buys.
|
||||
pub fn host_id(&self) -> crate::host::HostId {
|
||||
match &self.host {
|
||||
Some(r) => r.host_id(),
|
||||
@@ -502,8 +270,6 @@ impl WindowView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole `views.json`: every workspace tty7 knows about, plus which one
|
||||
/// had focus at quit.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WindowViews {
|
||||
@@ -513,9 +279,6 @@ pub struct WindowViews {
|
||||
}
|
||||
|
||||
impl WindowViews {
|
||||
/// Load every saved view. Returns `None` when the file is absent or
|
||||
/// unreadable (normal first run), and `None` with a warning when it fails
|
||||
/// to parse — never panics.
|
||||
pub fn load() -> Option<Self> {
|
||||
let path = Self::path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
@@ -536,44 +299,10 @@ impl WindowViews {
|
||||
self.views.iter_mut().find(|w| w.id == id)
|
||||
}
|
||||
|
||||
/// The workspaces that had a window at the last quit, in their saved order.
|
||||
///
|
||||
/// Note that launch does **not** restore all of these — see
|
||||
/// [`workspace_to_restore`](Self::workspace_to_restore). They are still the
|
||||
/// set that matters here, because every one of them is holding live daemon
|
||||
/// panes and none of them may be forgotten.
|
||||
pub fn open_views(&self) -> impl Iterator<Item = &WindowView> {
|
||||
self.views.iter().filter(|w| w.open)
|
||||
}
|
||||
|
||||
/// The one workspace launch comes up on: whichever the user was last in.
|
||||
///
|
||||
/// Deliberately one, not all of them. Restoring every window that existed
|
||||
/// at quit means a four-window session costs four windows, four daemon
|
||||
/// attaches and four layout restores before the user has said what they
|
||||
/// want to do — and in practice they came back for *one* of them. The
|
||||
/// others are not lost by any measure that matters: their panes never
|
||||
/// stopped running in the daemon, and the switcher lists them a click away.
|
||||
///
|
||||
/// Three answers, in order:
|
||||
///
|
||||
/// 1. [`active`](Self::active) while it is still open — written on every
|
||||
/// focus change, so it names the window that had the user's attention
|
||||
/// last.
|
||||
/// 2. the most recently active *open* workspace, for a store written by a
|
||||
/// build that did not track focus, or one whose active workspace was
|
||||
/// closed before quitting.
|
||||
/// 3. the most recently active workspace of any kind, open or not.
|
||||
///
|
||||
/// That last one is why closing every window and quitting still comes back
|
||||
/// somewhere. Closing a window here is a *detach*: the panes keep running in
|
||||
/// the daemon, so the workspace behind them is every bit as much "where the
|
||||
/// user left off" as one that still had a window — and `close_window`
|
||||
/// touches it on the way out, which makes the most recent of them the one
|
||||
/// closed last. Only the explicit *Close Workspace* drops an entry from the
|
||||
/// file, and that is the one gesture that means "I am done with this".
|
||||
///
|
||||
/// `None` therefore means one thing: no workspaces at all, i.e. a first run.
|
||||
pub fn workspace_to_restore(&self) -> Option<WorkspaceId> {
|
||||
let focused = self
|
||||
.active
|
||||
@@ -592,9 +321,6 @@ impl WindowViews {
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist as JSON, creating the parent directory if needed. Any
|
||||
/// IO/serialization error is logged and swallowed — the app must never
|
||||
/// crash or stall over view bookkeeping.
|
||||
pub fn save(&self) {
|
||||
let Some(path) = Self::path() else {
|
||||
return;
|
||||
@@ -617,18 +343,11 @@ impl WindowViews {
|
||||
}
|
||||
}
|
||||
|
||||
/// `~/.config/tty7/views.json`, alongside `config.json`.
|
||||
///
|
||||
/// A fresh name, not `session.json`: that file's document embedded whole
|
||||
/// layouts, this one is pure view state, and the migration policy for the
|
||||
/// tree refactor is deliberately none — an old file is simply ignored.
|
||||
fn path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("views.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch, or 0 if the clock is before it (which only a
|
||||
/// badly misconfigured machine reports — "never active" is a fine reading).
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -636,11 +355,6 @@ fn now_secs() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Helpers for every test that touches the on-disk `views.json`. The
|
||||
/// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so
|
||||
/// the file is process-wide too — any test that reads or writes it must hold
|
||||
/// [`lock_session_file`] across the whole read/write sequence, or parallel
|
||||
/// tests clobber each other's file.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use std::path::PathBuf;
|
||||
@@ -648,16 +362,10 @@ pub(crate) mod test_support {
|
||||
|
||||
static SESSION_FILE: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Serialize access to the shared `views.json`.
|
||||
pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> {
|
||||
// A poisoned lock just means another test failed mid-sequence; every
|
||||
// holder rewrites the file from scratch, so the state is still sound.
|
||||
SESSION_FILE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Pin the process config dir at a shared temp location so `save`/`load`
|
||||
/// (which resolve `views.json` under it) never touch the real `~/.config`.
|
||||
/// `set_config_dir` is first-call-wins; every caller computes the same path.
|
||||
pub(crate) fn pin_config_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
@@ -710,9 +418,6 @@ mod tests {
|
||||
assert_eq!(loaded.active, Some(id));
|
||||
}
|
||||
|
||||
/// The migration policy for the tree refactor is deliberately none: an old
|
||||
/// `session.json` (whatever its shape) is not read, and a `views.json`
|
||||
/// missing every field still decodes rather than erroring a launch.
|
||||
#[test]
|
||||
fn an_empty_or_partial_file_decodes_to_defaults() {
|
||||
let empty: WindowViews = serde_json::from_str("{}").unwrap();
|
||||
@@ -723,11 +428,6 @@ mod tests {
|
||||
assert!(!partial.views[0].is_remote());
|
||||
}
|
||||
|
||||
// ── Remote references ───────────────────────────────────────────────────
|
||||
|
||||
/// The four key formats of the connection key, verbatim. These strings are a
|
||||
/// wire contract in all but name: change one and every workspace on that
|
||||
/// machine gets a different `HostId` than the connection pool minted.
|
||||
#[test]
|
||||
fn connection_keys_match_the_contract_table() {
|
||||
let uuid = uuid::Uuid::parse_str("6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01").unwrap();
|
||||
@@ -759,11 +459,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Which machines can be told to restart their server. The two that cannot
|
||||
/// are not an omission: their server is this client's own doing, so there is
|
||||
/// nothing on the far side to stop and start, and the router refuses the
|
||||
/// action for exactly the same reason. A new variant has to answer this
|
||||
/// question rather than inherit an answer.
|
||||
#[test]
|
||||
fn only_ssh_machines_have_a_server_to_restart() {
|
||||
assert!(
|
||||
@@ -798,8 +493,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn direct_targets_normalize_and_reuse_the_quick_connect_parser() {
|
||||
// The port defaults to 22, the scheme is optional, and the host folds
|
||||
// case — all of it the connection manager's existing behaviour.
|
||||
assert_eq!(
|
||||
RemoteTarget::parse_direct("ssh://me@Box.Local"),
|
||||
Some(RemoteTarget::direct("me", "box.local", 22))
|
||||
@@ -808,7 +501,6 @@ mod tests {
|
||||
RemoteTarget::parse_direct("me@box.local:2222"),
|
||||
Some(RemoteTarget::direct("me", "box.local", 2222))
|
||||
);
|
||||
// A hand-edited file with an uppercase host still derives one id.
|
||||
let shouty = RemoteTarget::Direct {
|
||||
user: "me".into(),
|
||||
host: "BOX.LOCAL".into(),
|
||||
@@ -818,11 +510,8 @@ mod tests {
|
||||
shouty.host_id(),
|
||||
RemoteTarget::direct("me", "box.local", 22).host_id()
|
||||
);
|
||||
// Rejected inputs stay rejected rather than becoming a half-target.
|
||||
assert_eq!(RemoteTarget::parse_direct(""), None);
|
||||
assert_eq!(RemoteTarget::parse_direct("me@box:0"), None);
|
||||
// An alias is *not* case-folded: `ssh Devbox` and `ssh devbox` match
|
||||
// different stanzas, and so must these.
|
||||
assert_ne!(
|
||||
RemoteTarget::Alias {
|
||||
alias: "Devbox".into()
|
||||
@@ -835,13 +524,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The dev-only `--stdio` target is a *machine*, not a variation on local:
|
||||
/// its key is distinct, its id is not [`HostId::LOCAL`](crate::host::HostId::LOCAL),
|
||||
/// and two different server binaries are two different machines.
|
||||
///
|
||||
/// That last part matters because everything keyed by `HostId` — the
|
||||
/// connection pool, the git-status cache, the auth queue — would otherwise
|
||||
/// merge two servers that share nothing.
|
||||
#[test]
|
||||
fn a_local_stdio_target_is_its_own_machine() {
|
||||
let a = RemoteTarget::LocalStdio {
|
||||
@@ -858,13 +540,9 @@ mod tests {
|
||||
!a.host_id().is_local(),
|
||||
"a routed target is never the local host"
|
||||
);
|
||||
// The label is the binary's name, not the argv: the flags say nothing a
|
||||
// status bar can use.
|
||||
assert_eq!(a.to_string(), "local:tty7-server");
|
||||
}
|
||||
|
||||
/// The granularity the connection pool depends on: one box, one id, however
|
||||
/// many workspaces — and never `HostId::LOCAL`.
|
||||
#[test]
|
||||
fn views_on_one_box_share_a_host_id() {
|
||||
let target = RemoteTarget::Alias {
|
||||
@@ -879,11 +557,9 @@ mod tests {
|
||||
assert_eq!(a.host_id(), b.host_id(), "same machine, one HostId");
|
||||
assert!(!a.host_id().is_local());
|
||||
|
||||
// A different machine is a different id.
|
||||
let other = remote_view("other");
|
||||
assert_ne!(a.host_id(), other.host_id());
|
||||
|
||||
// And the local shape answers LOCAL, with nothing derived.
|
||||
assert_eq!(view().host_id(), crate::host::HostId::LOCAL);
|
||||
assert_eq!(
|
||||
a.host.as_ref().unwrap().store_key(),
|
||||
@@ -891,8 +567,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Launch ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn open_views_partition_by_flag() {
|
||||
let mut open_one = view();
|
||||
@@ -910,12 +584,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Launch restores exactly one window, and it is the one the user was in.
|
||||
///
|
||||
/// Pinned because the two inputs disagree on purpose: `active` is written on
|
||||
/// every focus change, so it is the truth even when some *other* window saw
|
||||
/// more recent activity (an agent finishing a build touches `last_active`
|
||||
/// without anybody looking at it).
|
||||
#[test]
|
||||
fn launch_restores_the_focused_workspace_not_the_most_recently_touched() {
|
||||
let mut focused = view();
|
||||
@@ -937,16 +605,12 @@ mod tests {
|
||||
"the others stay open in the store — launch detaches them, this does not"
|
||||
);
|
||||
|
||||
// No focus recorded (or it named a workspace that was closed first):
|
||||
// recency is the fallback, not a coin toss.
|
||||
let all = WindowViews {
|
||||
active: None,
|
||||
..all
|
||||
};
|
||||
assert_eq!(all.workspace_to_restore(), Some(busier_id));
|
||||
|
||||
// `active` pointing at a *detached* workspace must not resurrect it —
|
||||
// the user closed that window on purpose.
|
||||
let mut closed = view();
|
||||
closed.open = false;
|
||||
let closed_id = closed.id;
|
||||
@@ -959,10 +623,6 @@ mod tests {
|
||||
};
|
||||
assert_eq!(all.workspace_to_restore(), Some(open_id));
|
||||
|
||||
// Nothing open at all — the user closed every window before quitting.
|
||||
// Launch still comes back to the one closed last, because a detached
|
||||
// workspace's panes are still running and `close_window` touches it on
|
||||
// the way out.
|
||||
let mut first_closed = view();
|
||||
first_closed.open = false;
|
||||
first_closed.last_active = 100;
|
||||
@@ -976,25 +636,15 @@ mod tests {
|
||||
};
|
||||
assert_eq!(all.workspace_to_restore(), Some(closed_last_id));
|
||||
|
||||
// A stale `active` naming a workspace that is gone from the file does
|
||||
// not stop the fallback from answering.
|
||||
let all = WindowViews {
|
||||
active: Some(WorkspaceId::new()),
|
||||
..all
|
||||
};
|
||||
assert_eq!(all.workspace_to_restore(), Some(closed_last_id));
|
||||
|
||||
// The only `None` left is a genuine first run.
|
||||
assert_eq!(WindowViews::default().workspace_to_restore(), None);
|
||||
}
|
||||
|
||||
/// An open workspace outranks a detached one even when the detached one saw
|
||||
/// activity more recently — the fallback is for when *nothing* is open, not
|
||||
/// a recency race across the two states.
|
||||
///
|
||||
/// Without this, a background agent touching a detached workspace after the
|
||||
/// user's last keystroke would have launch reopen that one instead of the
|
||||
/// window that was actually on screen at quit.
|
||||
#[test]
|
||||
fn an_open_workspace_outranks_a_more_recently_touched_detached_one() {
|
||||
let mut open_one = view();
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
//! Shell discovery: enumerate the shells installed on this machine so the UI
|
||||
//! can offer them in the new-tab dropdown, and resolve the platform default.
|
||||
//!
|
||||
//! Rather than asking the user to type a program path into config, probe the
|
||||
//! well-known install locations up front and present what actually exists.
|
||||
//!
|
||||
//! - **Unix**: `/etc/shells` is the system's own inventory — parse it, keep the
|
||||
//! entries that exist, dedupe by basename (the same shell often appears as
|
||||
//! both `/bin/zsh` and `/usr/local/bin/zsh`). The login shell (`$SHELL`) is
|
||||
//! seeded first so it wins its dedupe slot and leads the list. Package
|
||||
//! managers don't register what they install there (Homebrew only *suggests*
|
||||
//! adding fish to `/etc/shells`), so a curated set of well-known shells is
|
||||
//! then probed on `PATH` as the catch-all.
|
||||
//! - **Windows**: there is no inventory file, so probe each shell's known
|
||||
//! homes: PowerShell 7 across its six-ish install roots, Windows PowerShell
|
||||
//! in System32, cmd via `%ComSpec%`, Git Bash under the Git install, and WSL
|
||||
//! distributions via `wsl.exe -l -q`.
|
||||
//!
|
||||
//! Everything effectful (filesystem, env, spawning `wsl.exe`) stays in thin
|
||||
//! wrappers; the parsing/selection logic is pure functions with unit tests.
|
||||
//! Discovery can take a beat (WSL enumeration spawns a process), so callers
|
||||
//! run [`detect_shells`] off the UI thread.
|
||||
|
||||
use std::path::Path;
|
||||
// The probe helpers below build candidate paths; they're Windows-only code.
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One launchable shell surfaced in the new-tab dropdown. `program` + `args`
|
||||
/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a
|
||||
/// bare name resolved via `PATH` or an absolute path, plus launch arguments.
|
||||
///
|
||||
/// Serializable because the dropdown of a **remote** workspace's window lists
|
||||
/// the shells of the machine that workspace lives on, not this one's: the list
|
||||
/// crosses the control dialect as [`ShellInventory`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DetectedShell {
|
||||
/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`.
|
||||
pub label: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -53,29 +21,12 @@ impl DetectedShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one machine can launch: its shells, plus which of them a plain new tab
|
||||
/// lands on. The unit the new-tab dropdown is built from.
|
||||
///
|
||||
/// Both halves have to come from the *same* machine. A remote workspace's
|
||||
/// window that listed this computer's shells would offer `/bin/zsh` on a box
|
||||
/// whose zsh is at `/usr/bin/zsh` — a picker whose every entry fails to spawn.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShellInventory {
|
||||
pub shells: Vec<DetectedShell>,
|
||||
/// Short name of the shell a *default* spawn resolves to (`zsh`,
|
||||
/// `PowerShell 7`), for the menu's `default` tag.
|
||||
pub default_name: String,
|
||||
}
|
||||
|
||||
/// This machine's [`ShellInventory`], honoring the `shell` override in the
|
||||
/// config file *this process* reads.
|
||||
///
|
||||
/// The config lookup goes through [`crate::core::config::shell_command`] rather
|
||||
/// than a GPUI global on purpose: the remote `tty7-server` answers this on the
|
||||
/// far side of an SSH connection with no GUI in the process, and the override
|
||||
/// that matters there is the one in *its* `config.json`.
|
||||
///
|
||||
/// Runs filesystem probes — call off the UI thread.
|
||||
pub fn inventory() -> ShellInventory {
|
||||
let configured = crate::core::config::shell_command();
|
||||
ShellInventory {
|
||||
@@ -84,9 +35,6 @@ pub fn inventory() -> ShellInventory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate the shells installed on this machine, best-effort. Order is
|
||||
/// meaningful: the entry most likely to be the user's default comes first.
|
||||
/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread.
|
||||
pub fn detect_shells() -> Vec<DetectedShell> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -98,9 +46,6 @@ pub fn detect_shells() -> Vec<DetectedShell> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The short display name of the shell a *default* spawn resolves to: the
|
||||
/// config override when set, otherwise the platform default (`$SHELL` on Unix,
|
||||
/// the probed PowerShell on Windows). Drives the "Default (zsh)" menu label.
|
||||
pub fn default_shell_name(configured: Option<&str>) -> String {
|
||||
let program = match configured {
|
||||
Some(p) if !p.trim().is_empty() => p.to_string(),
|
||||
@@ -118,9 +63,6 @@ pub fn default_shell_name(configured: Option<&str>) -> String {
|
||||
basename(&program)
|
||||
}
|
||||
|
||||
/// The last path component of `program`, lowercased on Windows and stripped of
|
||||
/// a trailing `.exe` — `C:\...\pwsh.exe` and `/usr/local/bin/fish` both reduce
|
||||
/// to their bare shell name for labels and dedupe keys.
|
||||
fn basename(program: &str) -> String {
|
||||
let base = Path::new(program)
|
||||
.file_name()
|
||||
@@ -134,12 +76,6 @@ fn basename(program: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse `/etc/shells` content: one absolute path per line, `#` comments and
|
||||
/// blank lines skipped. Pure — the caller supplies the file content.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn parse_etc_shells(content: &str) -> Vec<String> {
|
||||
content
|
||||
@@ -150,9 +86,6 @@ fn parse_etc_shells(content: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Order + dedupe the Unix candidate list: keep the first occurrence of each
|
||||
/// basename that `exists` confirms, labelled by that basename. Pure — `exists`
|
||||
/// is injected so tests need no real filesystem.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn unix_shells_from(
|
||||
candidates: impl IntoIterator<Item = String>,
|
||||
@@ -172,20 +105,9 @@ fn unix_shells_from(
|
||||
out
|
||||
}
|
||||
|
||||
/// Shells package managers commonly install *without* registering them in
|
||||
/// `/etc/shells` — Homebrew and nix leave that edit to the user, and few make
|
||||
/// it, so `/etc/shells` misses e.g. a brew-installed fish entirely. Probed on
|
||||
/// `PATH` (the login-shell-enriched one — see `enrich_path_from_login_shell`
|
||||
/// in `main` — so Dock launches see Homebrew's prefix too).
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
const PATH_PROBED_SHELLS: [&str; 5] = ["fish", "nu", "pwsh", "elvish", "xonsh"];
|
||||
|
||||
/// Expand [`PATH_PROBED_SHELLS`] into concrete candidate paths, one per
|
||||
/// `path_var` directory in `PATH` order. Fed through the same exists + dedupe
|
||||
/// pass as the `/etc/shells` entries, so the first directory that actually
|
||||
/// holds the shell wins — `which` semantics without spawning anything.
|
||||
/// Relative `PATH` entries are skipped: a `./fish` candidate would resolve
|
||||
/// somewhere else at every spawn. Pure — the caller supplies `path_var`.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn path_shell_candidates(path_var: &str) -> Vec<String> {
|
||||
let dirs: Vec<&str> = path_var.split(':').filter(|d| d.starts_with('/')).collect();
|
||||
@@ -200,12 +122,6 @@ fn path_shell_candidates(path_var: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detect_unix() -> Vec<DetectedShell> {
|
||||
// Seed the login shell first so it wins its basename's dedupe slot and
|
||||
// leads the list — it also covers shells installed outside /etc/shells
|
||||
// (nix/homebrew installs the user pointed $SHELL at without registering).
|
||||
// The PATH probe comes last: registered shells keep their `/etc/shells`
|
||||
// paths, and only the unregistered leftovers (brew fish, nushell, …) are
|
||||
// picked up from `PATH`.
|
||||
let login = std::env::var("SHELL").ok().filter(|s| !s.is_empty());
|
||||
let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default();
|
||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||
@@ -216,13 +132,6 @@ fn detect_unix() -> Vec<DetectedShell> {
|
||||
unix_shells_from(candidates, |p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Windows shell a *default* spawn launches: PowerShell 7 (`pwsh.exe`)
|
||||
/// when installed, else Windows PowerShell. Probed once and cached — the
|
||||
/// daemon consults this on every pane spawn.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_default_shell() -> &'static str {
|
||||
use std::sync::OnceLock;
|
||||
@@ -234,9 +143,6 @@ pub fn windows_default_shell() -> &'static str {
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate PowerShell 7: fixed install roots first (Program
|
||||
/// Files x64/x86/ARM, dotnet tools, scoop, the Microsoft Store shim), then a
|
||||
/// `PATH` search as the catch-all.
|
||||
#[cfg(windows)]
|
||||
fn find_pwsh7() -> Option<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
@@ -259,14 +165,11 @@ fn find_pwsh7() -> Option<PathBuf> {
|
||||
.or_else(|| find_in_path("pwsh.exe"))
|
||||
}
|
||||
|
||||
/// First candidate that exists on disk. Shared by the per-shell probes.
|
||||
#[cfg(windows)]
|
||||
fn pick_first_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Minimal `PATH` search (no PATHEXT expansion — callers pass the full
|
||||
/// `foo.exe` name).
|
||||
#[cfg(windows)]
|
||||
fn find_in_path(exe: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
@@ -315,11 +218,6 @@ fn detect_windows() -> Vec<DetectedShell> {
|
||||
out.push(DetectedShell {
|
||||
label: "Git Bash".into(),
|
||||
program: bash.to_string_lossy().into_owned(),
|
||||
// Interactive login shell — matches Git Bash's own launcher. These
|
||||
// are tty7's args, not the user's, so shell integration may replace
|
||||
// them with its own spelling of the same thing (see
|
||||
// `protocol::ShellSpec::args_are_tty7_defaults`); they stand as the
|
||||
// fallback for when integration doesn't apply or fails to set up.
|
||||
args: vec!["-i".into(), "-l".into()],
|
||||
});
|
||||
}
|
||||
@@ -328,8 +226,6 @@ fn detect_windows() -> Vec<DetectedShell> {
|
||||
out.push(DetectedShell {
|
||||
label: format!("WSL · {distro}"),
|
||||
program: "wsl.exe".into(),
|
||||
// `--cd ~` lands in the distro's home rather than a translated
|
||||
// Windows path the inner shell can't do much with.
|
||||
args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()],
|
||||
});
|
||||
}
|
||||
@@ -337,38 +233,15 @@ fn detect_windows() -> Vec<DetectedShell> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Git Bash's `bash.exe`, if Git for Windows is installed. Exposed only to
|
||||
/// tests, so `daemon::shell_integration`'s live-PTY check can spawn the same
|
||||
/// binary the dropdown does (and skip itself when there is none).
|
||||
#[cfg(all(windows, test))]
|
||||
pub fn git_bash_path() -> Option<PathBuf> {
|
||||
find_git_bash()
|
||||
}
|
||||
|
||||
/// Installed WSL distribution names, empty when WSL is absent — and always
|
||||
/// empty off Windows, so callers need no `cfg` of their own.
|
||||
///
|
||||
/// Two callers want the same list for different reasons. [`detect_shells`]
|
||||
/// offers a distro as a **shell** to launch in a pane; the workspace switcher
|
||||
/// offers it as a **machine** that can host a remote workspace
|
||||
/// (`ui::remote_connect::available_hosts`). Same enumeration, so the two lists
|
||||
/// can never disagree about which distros exist.
|
||||
///
|
||||
/// Spawns `wsl.exe` on Windows — the same rule as [`detect_shells`]: call it
|
||||
/// off the UI thread.
|
||||
pub fn wsl_distros() -> Vec<String> {
|
||||
wsl_distros_probed().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`wsl_distros`], keeping the difference between *nothing is installed* and
|
||||
/// *the probe could not answer*.
|
||||
///
|
||||
/// `Some(vec![])` is an answer — WSL is present and has no distributions, or this
|
||||
/// is not Windows at all, where there can never be one. `None` means the probe
|
||||
/// itself failed, and a caller holding a previous list should keep it rather than
|
||||
/// report that the user's distributions have gone away: `wsl.exe` refuses while a
|
||||
/// `wsl --shutdown` is in flight, which is a routine thing to run and a terrible
|
||||
/// reason to empty the machine picker.
|
||||
pub fn wsl_distros_probed() -> Option<Vec<String>> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -380,8 +253,6 @@ pub fn wsl_distros_probed() -> Option<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
|
||||
/// x86, and the per-user installer's home).
|
||||
#[cfg(windows)]
|
||||
fn find_git_bash() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
@@ -402,14 +273,6 @@ fn find_git_bash() -> Option<PathBuf> {
|
||||
pick_first_existing(candidates)
|
||||
}
|
||||
|
||||
/// Installed WSL distribution names via `wsl.exe -l -q`.
|
||||
///
|
||||
/// **`None` is "the probe could not answer", not "there are none"** — no
|
||||
/// `wsl.exe`, or one that failed, which is what a distribution mid-`wsl
|
||||
/// --shutdown` or a broken WSL install looks like. `Some(vec![])` is the
|
||||
/// authoritative empty answer: WSL is there and nothing is registered.
|
||||
/// [`hide_console`](crate::core::proc::hide_console) keeps the probe from
|
||||
/// flashing a console window (we're a GUI process).
|
||||
#[cfg(windows)]
|
||||
fn list_wsl_distros() -> Option<Vec<String>> {
|
||||
let mut cmd = std::process::Command::new("wsl.exe");
|
||||
@@ -421,11 +284,8 @@ fn list_wsl_distros() -> Option<Vec<String>> {
|
||||
Some(parse_wsl_list(&output.stdout))
|
||||
}
|
||||
|
||||
/// Decode `wsl.exe -l -q` output — UTF-16LE, one distro per line — skipping
|
||||
/// blanks and Docker Desktop's internal distros. Pure for testability.
|
||||
#[cfg_attr(unix, allow(dead_code))]
|
||||
fn parse_wsl_list(bytes: &[u8]) -> Vec<String> {
|
||||
// UTF-16LE: pair up bytes, tolerate a stray trailing byte.
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
@@ -453,8 +313,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unix_shells_dedupe_by_basename_keeping_first() {
|
||||
// The login shell (seeded first) claims "zsh"; the /etc/shells copy of
|
||||
// zsh under another prefix is dropped; missing files are dropped.
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/zsh",
|
||||
"/bin/zsh",
|
||||
@@ -476,8 +334,6 @@ mod tests {
|
||||
#[test]
|
||||
fn path_shell_candidates_expand_dirs_in_order_skipping_relative() {
|
||||
let cands = path_shell_candidates("/opt/homebrew/bin:relative:.:/usr/bin/:");
|
||||
// Per shell, one candidate per *absolute* PATH dir, in PATH order, with
|
||||
// any trailing slash on the dir normalized away.
|
||||
assert_eq!(cands[0], "/opt/homebrew/bin/fish");
|
||||
assert_eq!(cands[1], "/usr/bin/fish");
|
||||
assert!(cands.contains(&"/opt/homebrew/bin/nu".to_string()));
|
||||
@@ -487,10 +343,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unregistered_path_shells_are_detected_after_etc_shells() {
|
||||
// A brew-installed fish: absent from /etc/shells (and not the login
|
||||
// shell), present on PATH — must still make the list, after the
|
||||
// registered shells. zsh exists on PATH too but keeps its /etc/shells
|
||||
// slot via the basename dedupe.
|
||||
let etc = ["/bin/zsh".to_string(), "/bin/bash".to_string()];
|
||||
let candidates = etc
|
||||
.into_iter()
|
||||
@@ -514,7 +366,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_wsl_list_decodes_utf16le_and_filters() {
|
||||
// "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n"
|
||||
let text = "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n";
|
||||
let bytes: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(parse_wsl_list(&bytes), vec!["Ubuntu", "Debian"]);
|
||||
@@ -543,8 +394,6 @@ mod tests {
|
||||
fn default_shell_name_prefers_the_configured_program() {
|
||||
assert_eq!(default_shell_name(Some("/usr/bin/fish")), "fish");
|
||||
assert_eq!(default_shell_name(Some("pwsh")), "pwsh");
|
||||
// Blank config falls through to the platform default — just assert it
|
||||
// yields *something* non-empty without pinning this host's $SHELL.
|
||||
assert!(!default_shell_name(None).is_empty());
|
||||
assert!(!default_shell_name(Some(" ")).is_empty());
|
||||
}
|
||||
|
||||
@@ -1,95 +1,44 @@
|
||||
//! The SSH connection-manager profile model (PRD §7.1) plus QuickConnect parsing.
|
||||
//!
|
||||
//! A [`SshProfile`] is a full, user-editable connection definition persisted in
|
||||
//! `config.json` (`Config::ssh_profiles`). Secrets never live here: a profile only
|
||||
//! carries a [`CredentialRef`] naming its OS keychain entry.
|
||||
//!
|
||||
//! This is distinct from [`crate::core::ssh_config`], which does live *discovery*
|
||||
//! of `~/.ssh/config` aliases for the palette. Profiles are owned by tty7 and can
|
||||
//! be imported from `ssh_config` (see [`crate::core::ssh_config::import_profiles`]).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::keychain::CredentialRef;
|
||||
|
||||
/// A saved SSH connection profile. See PRD §7.1 for the field-by-field rationale.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct SshProfile {
|
||||
/// Stable identity. Referenced by [`SshProfile::jump_host`] on other profiles.
|
||||
#[serde(default = "new_id")]
|
||||
pub id: Uuid,
|
||||
/// Display name (also the `ssh_config` alias for imported profiles).
|
||||
pub name: String,
|
||||
/// Optional group/folder label. Imported profiles use
|
||||
/// [`crate::core::ssh_config::IMPORTED_GROUP`].
|
||||
pub group: Option<String>,
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────────
|
||||
/// Target host (an IP or DNS name).
|
||||
pub host: String,
|
||||
/// TCP port. Defaults to 22.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
/// Login user. Empty means "resolve at connect time".
|
||||
pub user: String,
|
||||
/// Jump host: the id of another profile to tunnel through (multi-level chains
|
||||
/// resolve by following each hop's own `jump_host`).
|
||||
pub jump_host: Option<Uuid>,
|
||||
/// A `ProxyCommand` to spawn as the transport. `%h`/`%p` tokens are substituted
|
||||
/// at connect time (not here) — see PRD FR-C1.
|
||||
pub proxy_command: Option<String>,
|
||||
/// A SOCKS5 proxy to dial through.
|
||||
pub socks_proxy: Option<HostPort>,
|
||||
/// An HTTP `CONNECT` proxy to dial through.
|
||||
pub http_proxy: Option<HostPort>,
|
||||
|
||||
// ── Authentication ───────────────────────────────────────────────────────
|
||||
/// How to authenticate. `Auto` (the default) tries every method in order.
|
||||
#[serde(deserialize_with = "crate::core::config::de_lenient")]
|
||||
pub auth: AuthMode,
|
||||
/// Private-key files to try, in order. Each supports `%h`/`%r` placeholders
|
||||
/// (see [`expand_identity_placeholders`]).
|
||||
pub identity_files: Vec<String>,
|
||||
/// Enable ssh-agent forwarding for the session.
|
||||
pub agent_forward: bool,
|
||||
/// Pointer to the keychain entry holding this profile's saved secret. Never a
|
||||
/// secret itself.
|
||||
pub credential_ref: Option<CredentialRef>,
|
||||
|
||||
// ── Forwarding ───────────────────────────────────────────────────────────
|
||||
/// Port forwards established automatically once connected.
|
||||
pub forwards: Vec<ForwardRule>,
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
/// Keepalive interval in seconds (`None` = library default).
|
||||
pub keepalive_interval_s: Option<u32>,
|
||||
/// Max missed keepalives before the connection is considered dead.
|
||||
pub keepalive_count_max: Option<u32>,
|
||||
/// Connection timeout in seconds.
|
||||
pub connect_timeout_s: Option<u32>,
|
||||
/// Per-profile override for the "confirm before closing" prompt (`None` =
|
||||
/// follow the global setting).
|
||||
pub warn_on_close: Option<bool>,
|
||||
/// Suppress the server login banner.
|
||||
pub skip_banner: bool,
|
||||
/// Bootstrap tty7's shell integration into the remote shell (prompt marks,
|
||||
/// exit codes, cwd — what the inline line editor runs on). On by default;
|
||||
/// a remote we can't integrate declines itself, so this is the escape hatch
|
||||
/// for one we *can* but shouldn't.
|
||||
#[serde(default = "default_true")]
|
||||
pub shell_integration: bool,
|
||||
/// Commands sent automatically right after the shell opens.
|
||||
pub login_scripts: Vec<String>,
|
||||
/// Request X11 forwarding.
|
||||
pub x11: bool,
|
||||
|
||||
// ── Advanced ─────────────────────────────────────────────────────────────
|
||||
/// Preferred algorithm lists (empty list = library default for that category).
|
||||
pub algorithms: Algorithms,
|
||||
/// Per-profile override for host-key verification (`None` = follow the global
|
||||
/// setting; `Some(false)` disables verification for this profile).
|
||||
pub verify_host_keys: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -126,7 +75,6 @@ impl Default for SshProfile {
|
||||
}
|
||||
|
||||
impl SshProfile {
|
||||
/// A fresh profile with a new id and the given name; all else default.
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
@@ -134,8 +82,6 @@ impl SshProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// This profile's `identity_files` with `%h`/`%r` expanded against its own
|
||||
/// host/user (see [`expand_identity_placeholders`]).
|
||||
pub fn expanded_identity_files(&self) -> Vec<String> {
|
||||
self.identity_files
|
||||
.iter()
|
||||
@@ -143,20 +89,15 @@ impl SshProfile {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `user@host:port` connect string for this profile (see
|
||||
/// [`to_connect_string`]).
|
||||
pub fn connect_string(&self) -> String {
|
||||
to_connect_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host + port pair (used for SOCKS/HTTP proxies and forward endpoints).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct HostPort {
|
||||
/// Hostname or IP.
|
||||
pub host: String,
|
||||
/// Port number.
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
@@ -170,7 +111,6 @@ impl Default for HostPort {
|
||||
}
|
||||
|
||||
impl HostPort {
|
||||
/// Construct a `HostPort`.
|
||||
pub fn new(host: impl Into<String>, port: u16) -> Self {
|
||||
Self {
|
||||
host: host.into(),
|
||||
@@ -179,51 +119,34 @@ impl HostPort {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a profile authenticates. `Auto` tries every applicable method in order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AuthMode {
|
||||
/// Try public-key, agent, saved password, keyboard-interactive, prompt — in
|
||||
/// order (the default).
|
||||
#[default]
|
||||
Auto,
|
||||
/// GSSAPI with MIC only (Kerberos-style SSO).
|
||||
Gssapi,
|
||||
/// Password only (saved, then prompted).
|
||||
Password,
|
||||
/// Public-key only.
|
||||
PublicKey,
|
||||
/// ssh-agent only.
|
||||
Agent,
|
||||
/// keyboard-interactive only (2FA rides this path).
|
||||
KeyboardInteractive,
|
||||
}
|
||||
|
||||
/// The direction of a port forward.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ForwardKind {
|
||||
/// Local (`-L`): listen locally, tunnel to `target` via the server.
|
||||
#[default]
|
||||
Local,
|
||||
/// Remote (`-R`): the server listens, tunnels back to `target` on our side.
|
||||
Remote,
|
||||
/// Dynamic (`-D`): a local SOCKS proxy; `target` is unused.
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// One preconfigured port forward (PRD §7.1 `forwards`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ForwardRule {
|
||||
/// Local / Remote / Dynamic.
|
||||
#[serde(deserialize_with = "crate::core::config::de_lenient")]
|
||||
pub kind: ForwardKind,
|
||||
/// The listener endpoint (local side for Local/Dynamic, remote side for Remote).
|
||||
pub bind: HostPort,
|
||||
/// The endpoint traffic is delivered to. Ignored for [`ForwardKind::Dynamic`].
|
||||
pub target: HostPort,
|
||||
/// Optional human-readable label.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
@@ -238,64 +161,39 @@ impl Default for ForwardRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferred algorithm lists per category. An empty list means "use the library
|
||||
/// default set for this category" (PRD §7.1: `空=默认`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Algorithms {
|
||||
/// Key-exchange algorithms.
|
||||
pub kex: Vec<String>,
|
||||
/// Symmetric ciphers.
|
||||
pub cipher: Vec<String>,
|
||||
/// MAC algorithms.
|
||||
pub mac: Vec<String>,
|
||||
/// Host-key algorithms.
|
||||
pub hostkey: Vec<String>,
|
||||
/// Compression algorithms.
|
||||
pub compression: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed QuickConnect target (PRD FR-P4). `user`/`port` are `None` when the
|
||||
/// input omitted them; callers apply their own defaults (typically `port` → 22).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QuickConnect {
|
||||
/// The login user, if the input specified one (text before the last `@`).
|
||||
pub user: Option<String>,
|
||||
/// The host (IPv6 addresses returned without their surrounding brackets).
|
||||
pub host: String,
|
||||
/// The port, if the input specified one.
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl QuickConnect {
|
||||
/// The port, or 22 when unspecified.
|
||||
pub fn port_or_default(&self) -> u16 {
|
||||
self.port.unwrap_or(22)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a QuickConnect string: `[ssh://]user@host[:port]`, with IPv6 in bracket
|
||||
/// form `[::1]:2222` (PRD FR-P4). Mirrors Tabby's semantics (brief §8):
|
||||
///
|
||||
/// - the `ssh://` scheme prefix is optional and stripped;
|
||||
/// - `user` is everything before the **last** `@`, so `@` in usernames works; an
|
||||
/// empty user (leading `@`) yields `user: None`;
|
||||
/// - IPv6 must be bracketed; `host` is returned unbracketed;
|
||||
/// - a port segment that isn't a valid `1..=65535` fails the whole parse (`None`).
|
||||
///
|
||||
/// Returns `None` when the host is empty or the port is invalid.
|
||||
pub fn parse_quick_connect(input: &str) -> Option<QuickConnect> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Optional scheme.
|
||||
let body = trimmed
|
||||
.strip_prefix("ssh://")
|
||||
.or_else(|| trimmed.strip_prefix("SSH://"))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
// Split user off at the LAST '@' so '@' inside a username is preserved.
|
||||
let (user, hostport) = match body.rfind('@') {
|
||||
Some(ix) => {
|
||||
let u = &body[..ix];
|
||||
@@ -316,13 +214,10 @@ pub fn parse_quick_connect(input: &str) -> Option<QuickConnect> {
|
||||
Some(QuickConnect { user, host, port })
|
||||
}
|
||||
|
||||
/// Split a `host[:port]` / `[ipv6][:port]` fragment. Returns `None` if a present
|
||||
/// port segment is not a valid `1..=65535`.
|
||||
fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> {
|
||||
if hostport.is_empty() {
|
||||
return Some((String::new(), None));
|
||||
}
|
||||
// IPv6 bracket form: [host] or [host]:port.
|
||||
if let Some(rest) = hostport.strip_prefix('[') {
|
||||
let close = rest.find(']')?;
|
||||
let host = rest[..close].to_string();
|
||||
@@ -330,14 +225,10 @@ fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> {
|
||||
let port = match after.strip_prefix(':') {
|
||||
Some(p) => Some(parse_port(p)?),
|
||||
None if after.is_empty() => None,
|
||||
// Trailing junk after ']' that isn't a ':port' → reject.
|
||||
None => return None,
|
||||
};
|
||||
return Some((host, port));
|
||||
}
|
||||
// Non-bracket. A single ':' means `host:port` (the suffix must be a valid
|
||||
// port, else reject). Several colons is a bare, unbracketed IPv6 address —
|
||||
// ambiguous, so keep it whole as the host rather than guess a port.
|
||||
match hostport.matches(':').count() {
|
||||
0 => Some((hostport.to_string(), None)),
|
||||
1 => {
|
||||
@@ -348,21 +239,14 @@ fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a required, valid port; `None` on out-of-range / non-numeric / zero.
|
||||
fn parse_port(s: &str) -> Option<u16> {
|
||||
try_parse_port(s)
|
||||
}
|
||||
|
||||
/// `Some(port)` only for a valid `1..=65535`; `None` otherwise (u16 parse already
|
||||
/// rejects > 65535, and we additionally reject 0).
|
||||
fn try_parse_port(s: &str) -> Option<u16> {
|
||||
s.parse::<u16>().ok().filter(|&p| p != 0)
|
||||
}
|
||||
|
||||
/// Render a profile as a `user@host:port` connect string (PRD FR-P5). The `user@`
|
||||
/// is omitted when the user is empty and `:port` is omitted when it's the default
|
||||
/// 22. IPv6 hosts are re-bracketed so the result round-trips through
|
||||
/// [`parse_quick_connect`].
|
||||
pub fn to_connect_string(profile: &SshProfile) -> String {
|
||||
let host = if profile.host.contains(':') {
|
||||
format!("[{}]", profile.host)
|
||||
@@ -382,15 +266,6 @@ pub fn to_connect_string(profile: &SshProfile) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Expand `%h` (host) and `%r` (remote user) placeholders in an identity-file path
|
||||
/// (PRD FR-A2), plus a leading `~/` to the home directory. A single left-to-right
|
||||
/// pass, so a `%h` that expands to text containing `%r` is not re-expanded. `%%`
|
||||
/// yields a literal `%`.
|
||||
///
|
||||
/// The tilde matters GUI-side: identity paths are overwhelmingly `~/.ssh/...`
|
||||
/// (every ssh_config import), and the keychain passphrase scheme hashes the key
|
||||
/// *file contents* — an unexpanded `~` makes that read silently fail, so
|
||||
/// "remember passphrase" would neither store nor resolve.
|
||||
pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> String {
|
||||
let mut out = String::with_capacity(path.len());
|
||||
let mut chars = path.chars();
|
||||
@@ -403,7 +278,6 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin
|
||||
Some('h') => out.push_str(host),
|
||||
Some('r') => out.push_str(user),
|
||||
Some('%') => out.push('%'),
|
||||
// Unknown token: keep it verbatim (e.g. "%d" stays "%d").
|
||||
Some(other) => {
|
||||
out.push('%');
|
||||
out.push(other);
|
||||
@@ -414,8 +288,6 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin
|
||||
expand_tilde(&out)
|
||||
}
|
||||
|
||||
/// Expand a leading `~/` (or a bare `~`) to the user's home directory; every
|
||||
/// other path passes through unchanged, as does `~` when no home is known.
|
||||
pub fn expand_tilde(path: &str) -> String {
|
||||
let home = || {
|
||||
#[cfg(windows)]
|
||||
@@ -441,15 +313,11 @@ pub fn expand_tilde(path: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Profiles written before the shell-integration switch existed must load
|
||||
/// with it *on*: a plain `#[serde(default)]` would give `false` and quietly
|
||||
/// opt every existing profile out of the feature it never knew about.
|
||||
#[test]
|
||||
fn profiles_saved_before_the_switch_existed_default_to_integrated() {
|
||||
let profile: SshProfile =
|
||||
serde_json::from_str(r#"{"name":"prod","host":"h","user":"u"}"#).unwrap();
|
||||
assert!(profile.shell_integration);
|
||||
// …and a profile that explicitly opted out stays opted out.
|
||||
let off: SshProfile = serde_json::from_str(
|
||||
r#"{"name":"prod","host":"h","user":"u","shell_integration":false}"#,
|
||||
)
|
||||
@@ -470,13 +338,11 @@ mod tests {
|
||||
assert_eq!(q.host, "10.0.0.5");
|
||||
assert_eq!(q.port, Some(2222));
|
||||
|
||||
// Host-only.
|
||||
let q = parse_quick_connect("example.com").unwrap();
|
||||
assert_eq!(q.user, None);
|
||||
assert_eq!(q.host, "example.com");
|
||||
assert_eq!(q.port, None);
|
||||
|
||||
// Host:port with no user.
|
||||
let q = parse_quick_connect("example.com:8022").unwrap();
|
||||
assert_eq!(q.user, None);
|
||||
assert_eq!(q.host, "example.com");
|
||||
@@ -493,13 +359,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn quick_connect_at_in_username_uses_last_at() {
|
||||
// The user contains an '@' (e.g. an email-style login).
|
||||
let q = parse_quick_connect("me@corp.com@bastion").unwrap();
|
||||
assert_eq!(q.user.as_deref(), Some("me@corp.com"));
|
||||
assert_eq!(q.host, "bastion");
|
||||
assert_eq!(q.port, None);
|
||||
|
||||
// Leading '@' → empty user → None, host kept.
|
||||
let q = parse_quick_connect("@host").unwrap();
|
||||
assert_eq!(q.user, None);
|
||||
assert_eq!(q.host, "host");
|
||||
@@ -521,7 +385,6 @@ mod tests {
|
||||
assert_eq!(q.host, "2001:db8::dead:beef");
|
||||
assert_eq!(q.port, Some(22));
|
||||
|
||||
// A bare (unbracketed) IPv6 is ambiguous but must not be split into a port.
|
||||
let q = parse_quick_connect("fe80::1").unwrap();
|
||||
assert_eq!(q.host, "fe80::1");
|
||||
assert_eq!(q.port, None);
|
||||
@@ -531,15 +394,10 @@ mod tests {
|
||||
fn quick_connect_rejects_bad_ports_and_empties() {
|
||||
assert!(parse_quick_connect("").is_none());
|
||||
assert!(parse_quick_connect(" ").is_none());
|
||||
// Port out of u16 range.
|
||||
assert!(parse_quick_connect("host:70000").is_none());
|
||||
// Port zero is invalid.
|
||||
assert!(parse_quick_connect("host:0").is_none());
|
||||
// Non-numeric single-colon suffix is a malformed port.
|
||||
assert!(parse_quick_connect("host:ssh").is_none());
|
||||
// Empty host.
|
||||
assert!(parse_quick_connect("deploy@").is_none());
|
||||
// Max valid port.
|
||||
assert_eq!(parse_quick_connect("host:65535").unwrap().port, Some(65535));
|
||||
}
|
||||
|
||||
@@ -554,12 +412,10 @@ mod tests {
|
||||
p.port = 2222;
|
||||
assert_eq!(to_connect_string(&p), "deploy@10.0.0.5:2222");
|
||||
|
||||
// Empty user → no leading `user@`.
|
||||
p.user = String::new();
|
||||
p.port = 22;
|
||||
assert_eq!(to_connect_string(&p), "10.0.0.5");
|
||||
|
||||
// IPv6 host is re-bracketed and round-trips.
|
||||
p.host = "::1".to_string();
|
||||
p.user = "root".to_string();
|
||||
p.port = 2200;
|
||||
@@ -573,8 +429,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identity_placeholder_expansion() {
|
||||
// `~/` expands to the real home dir (the GUI hashes the key file's
|
||||
// contents for the keychain, so the path must be readable as-is).
|
||||
let home = expand_tilde("~");
|
||||
assert_eq!(
|
||||
expand_identity_placeholders("~/.ssh/id_%h", "example.com", "deploy"),
|
||||
@@ -584,14 +438,11 @@ mod tests {
|
||||
expand_identity_placeholders("~/keys/%r@%h.pem", "host", "alice"),
|
||||
format!("{home}/keys/alice@host.pem")
|
||||
);
|
||||
// A literal %% survives as a single %, and unknown tokens stay verbatim.
|
||||
assert_eq!(
|
||||
expand_identity_placeholders("100%%-%d-%h", "h", "u"),
|
||||
"100%-%d-h"
|
||||
);
|
||||
// Single left-to-right pass: %h expanding to text with %r is not re-expanded.
|
||||
assert_eq!(expand_identity_placeholders("%h", "%r", "u"), "%r");
|
||||
// No placeholders and no tilde → unchanged.
|
||||
assert_eq!(
|
||||
expand_identity_placeholders("/abs/.ssh/id_ed25519", "h", "u"),
|
||||
"/abs/.ssh/id_ed25519"
|
||||
@@ -603,7 +454,6 @@ mod tests {
|
||||
let home = expand_tilde("~");
|
||||
assert!(!home.is_empty() && home != "~");
|
||||
assert_eq!(expand_tilde("~/.ssh/id"), format!("{home}/.ssh/id"));
|
||||
// Not a home reference: mid-path or suffixed tildes stay verbatim.
|
||||
assert_eq!(expand_tilde("/a/~/b"), "/a/~/b");
|
||||
assert_eq!(expand_tilde("~user/x"), "~user/x");
|
||||
assert_eq!(expand_tilde("/abs/path"), "/abs/path");
|
||||
@@ -630,7 +480,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn profile_serde_defaults_and_round_trip() {
|
||||
// A minimal profile JSON fills everything else from defaults.
|
||||
let p: SshProfile = serde_json::from_str(r#"{"name":"min","host":"h"}"#).unwrap();
|
||||
assert_eq!(p.name, "min");
|
||||
assert_eq!(p.host, "h");
|
||||
@@ -638,21 +487,15 @@ mod tests {
|
||||
assert_eq!(p.auth, AuthMode::Auto);
|
||||
assert!(p.credential_ref.is_none());
|
||||
|
||||
// Back-compat: a config.json from a build that still wrote the removed
|
||||
// `use_system_ssh` flag loads fine — serde ignores the unknown field
|
||||
// (the struct has container-level `#[serde(default)]`, no
|
||||
// `deny_unknown_fields`).
|
||||
let p: SshProfile =
|
||||
serde_json::from_str(r#"{"name":"old","host":"h","use_system_ssh":true}"#).unwrap();
|
||||
assert_eq!(p.name, "old");
|
||||
assert_eq!(p.host, "h");
|
||||
|
||||
// A bad `auth` value falls back leniently instead of failing the parse.
|
||||
let p: SshProfile =
|
||||
serde_json::from_str(r#"{"name":"x","host":"h","auth":"bogus"}"#).unwrap();
|
||||
assert_eq!(p.auth, AuthMode::Auto);
|
||||
|
||||
// Full round trip preserves the id and every field.
|
||||
let mut original = SshProfile::new("full");
|
||||
original.host = "10.0.0.9".to_string();
|
||||
original.user = "deploy".to_string();
|
||||
@@ -693,20 +536,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serde default for [`SshProfile::id`]: a fresh v4 UUID.
|
||||
fn new_id() -> Uuid {
|
||||
Uuid::new_v4()
|
||||
}
|
||||
|
||||
/// Serde default for [`SshProfile::port`]: the standard SSH port.
|
||||
fn default_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Serde default for [`SshProfile::shell_integration`]. Named rather than
|
||||
/// `#[serde(default)]` because the default is `true`, and because profiles
|
||||
/// written before the field existed must deserialize as opted *in* — the
|
||||
/// integration is the behavior we want everywhere it works.
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
//! Thread-scheduling helpers shared by the daemon and the GUI client.
|
||||
|
||||
/// Ask the OS to schedule the calling thread at user-interactive QoS.
|
||||
///
|
||||
/// macOS assigns unclassified threads a default QoS the scheduler is free to
|
||||
/// park on efficiency cores under load. Measured on an M1 Pro mid-benchmark:
|
||||
/// whole seconds where the PTY drain drops from ~96 MB/s to 50–70 MB/s — an
|
||||
/// E-core's pace — then recovers. The threads on the interactive output path
|
||||
/// (daemon PTY reader, connection writer/reader, client socket reader) carry
|
||||
/// keystroke echo and the visible output stream, which is exactly the workload
|
||||
/// `QOS_CLASS_USER_INTERACTIVE` names. Best effort; a refused hint just keeps
|
||||
/// the default class. No-op elsewhere: Linux/Windows schedulers don't demote
|
||||
/// by QoS class.
|
||||
pub fn promote_to_user_interactive() {
|
||||
// Escape hatch for benchmarking the promotion itself (and for users whose
|
||||
// workload fares better under default scheduling): any non-empty value
|
||||
// other than "0" disables it.
|
||||
if std::env::var("TTY7_NO_QOS").is_ok_and(|v| !v.is_empty() && v != "0") {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
// SAFETY: a plain scheduling hint for the current thread; no pointers, no
|
||||
// preconditions.
|
||||
unsafe {
|
||||
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
//! Persisted last-window geometry, stored at `window.json` in the config dir
|
||||
//! (alongside `config.json` / `views.json`). The quit hook in `ui::app`
|
||||
//! writes the window's final bounds here unconditionally; startup reads it
|
||||
//! back only when `Config::remember_window_size` is on, so toggling the
|
||||
//! setting off and on again still restores the most recent quit's geometry.
|
||||
//! Same durability contract as the other config-dir files: missing/malformed
|
||||
//! reads fall back to "nothing remembered", writes are atomic.
|
||||
//!
|
||||
//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because
|
||||
//! [`WindowView`](super::session::WindowView) embeds it and `views.json` is
|
||||
//! parsed in this gpui-free crate. Converting to and from `Bounds` is the GUI
|
||||
//! crate's job — see its `core::window_state::WindowGeometry` extension trait.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Don't restore a window smaller than this (logical px) — a corrupt or
|
||||
/// hand-edited file shouldn't reopen tty7 as a sliver.
|
||||
const MIN_SIZE: f32 = 200.0;
|
||||
|
||||
/// Last known window geometry, in gpui's global coordinate space (logical
|
||||
/// pixels; origins can be negative or beyond the primary display on
|
||||
/// multi-monitor setups). For a fullscreen window this records the *restore*
|
||||
/// bounds, so the next normal launch isn't screen-sized.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: f32,
|
||||
@@ -34,9 +15,6 @@ impl WindowState {
|
||||
crate::core::config::config_path("window.json")
|
||||
}
|
||||
|
||||
/// Load the remembered geometry; `None` when nothing usable is on disk
|
||||
/// (never saved, unreadable, malformed, or degenerate values), in which
|
||||
/// case the caller falls back to the centered default.
|
||||
pub fn load() -> Option<Self> {
|
||||
let path = Self::path()?;
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
@@ -46,8 +24,6 @@ impl WindowState {
|
||||
state.is_usable().then_some(state)
|
||||
}
|
||||
|
||||
/// A geometry worth restoring: all values finite and the size at least
|
||||
/// [`MIN_SIZE`] each way.
|
||||
fn is_usable(&self) -> bool {
|
||||
[self.x, self.y, self.width, self.height]
|
||||
.iter()
|
||||
@@ -56,8 +32,6 @@ impl WindowState {
|
||||
&& self.height >= MIN_SIZE
|
||||
}
|
||||
|
||||
/// Persist the geometry; IO / serialization errors are logged and swallowed
|
||||
/// (worst case the next launch opens at the default size).
|
||||
pub fn save(&self) {
|
||||
let Some(path) = Self::path() else {
|
||||
return;
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
//! Git-worktree support for the tab context menu's "New Worktree Tab": derive
|
||||
//! the repo from a pane's cwd, propose an unused two-word name (editable in the
|
||||
//! sheet, see `ui::worktree_prompt`), and run `git worktree add -b` under the
|
||||
//! repository's own `.tty7/worktrees/` (kept out of `git status` by an
|
||||
//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an
|
||||
//! isolated checkout on its own branch, physically next to the code it forks.
|
||||
//!
|
||||
//! Every filesystem touch and every `git` invocation goes through the [`Host`]
|
||||
//! the pane belongs to, so a worktree is created on the machine the code
|
||||
//! actually lives on rather than always on this one. That also means **all of
|
||||
//! it blocks** — on a remote host every call here is a round trip — so callers
|
||||
//! run the whole module on the background executor (`ui::host_ops`), never
|
||||
//! inline while building a menu.
|
||||
//!
|
||||
//! Path arithmetic is deliberately the host's too ([`Host::join`], never
|
||||
//! `PathBuf::join`): a Windows client driving a Linux host would otherwise
|
||||
//! build `/home/me\.tty7` and create a repository directory with a backslash in
|
||||
//! its name.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::host::Host;
|
||||
|
||||
/// Word pools for generated branch names (`quiet-otter`). Short, lowercase,
|
||||
/// branch-safe; two pools of 24 give 576 combinations before the numeric
|
||||
/// fallback in [`defaults`] kicks in.
|
||||
const ADJECTIVES: [&str; 24] = [
|
||||
"quiet", "amber", "bold", "calm", "cedar", "coral", "dusky", "early", "fable", "gold", "hazel",
|
||||
"ivory", "jade", "keen", "lunar", "mossy", "noble", "ochre", "pale", "rapid", "sunny", "tidal",
|
||||
@@ -35,16 +13,12 @@ const NOUNS: [&str; 24] = [
|
||||
"vole", "walrus", "yak",
|
||||
];
|
||||
|
||||
/// A freshly created worktree: where it lives and the branch checked out in it.
|
||||
#[derive(Debug)]
|
||||
pub struct NewWorktree {
|
||||
pub path: PathBuf,
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
/// What to create, as confirmed (or edited) in the sheet: the checkout's
|
||||
/// directory name under the managed root, the new branch's name, and the
|
||||
/// commit-ish it starts from.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeRequest {
|
||||
pub name: String,
|
||||
@@ -52,10 +26,6 @@ pub struct WorktreeRequest {
|
||||
pub base: String,
|
||||
}
|
||||
|
||||
/// Pre-filled values for the sheet: an unused two-word candidate (offered as
|
||||
/// both directory name and branch), the branch currently checked out (the
|
||||
/// natural start point; `"HEAD"` when detached), and the directory the new
|
||||
/// checkout would land in, for the live path preview.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeDefaults {
|
||||
pub name: String,
|
||||
@@ -63,27 +33,10 @@ pub struct WorktreeDefaults {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
/// `<root>/.tty7/worktrees` — where this repository's managed checkouts live.
|
||||
/// Built with [`Host::join`] rather than [`PathBuf::join`] so the separator is
|
||||
/// the *host's*, not the client's.
|
||||
fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf {
|
||||
host.join(&host.join(main_root, ".tty7"), "worktrees")
|
||||
}
|
||||
|
||||
/// Run `git -C <dir> <args>` on `host`, returning trimmed stdout on success and
|
||||
/// trimmed stderr as the error otherwise.
|
||||
///
|
||||
/// The shape is unchanged from when this module ran `git` itself; what changed
|
||||
/// is that the invocation is now the one every git read in tty7 shares
|
||||
/// (`core::git::git_output`): `GIT_OPTIONAL_LOCKS=0`, stdin nulled, ambient
|
||||
/// `GIT_DIR`/`GIT_WORK_TREE` removed. `GIT_OPTIONAL_LOCKS` only suppresses
|
||||
/// *optional* sub-operations (git's own words) — the locks `worktree add` and
|
||||
/// `branch -d` need to do their job are not optional and are still taken.
|
||||
///
|
||||
/// The three outcomes stay distinct: `Err` from the host means git never ran
|
||||
/// (missing binary, vanished cwd, dead connection) and carries the same
|
||||
/// `failed to run git:` prefix this module has always produced, while a git
|
||||
/// that ran and failed still reports its own stderr.
|
||||
fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
match host.git(dir, args) {
|
||||
Ok(out) if out.success() => Ok(out.stdout_trimmed()),
|
||||
@@ -92,8 +45,6 @@ fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` already exists as a local branch in the repo at `repo_root`.
|
||||
/// A failed probe (`--verify --quiet` exits non-zero) means it's free.
|
||||
fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool {
|
||||
git(
|
||||
host,
|
||||
@@ -108,8 +59,6 @@ fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// A tiny xorshift over a time+pid seed — enough randomness to spread branch
|
||||
/// names without pulling in a `rand` dependency.
|
||||
fn seed() -> u64 {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -125,16 +74,12 @@ fn next(state: &mut u64) -> u64 {
|
||||
*state
|
||||
}
|
||||
|
||||
/// One `adjective-noun` candidate from the pools.
|
||||
fn candidate(state: &mut u64) -> String {
|
||||
let a = ADJECTIVES[(next(state) % ADJECTIVES.len() as u64) as usize];
|
||||
let n = NOUNS[(next(state) % NOUNS.len() as u64) as usize];
|
||||
format!("{a}-{n}")
|
||||
}
|
||||
|
||||
/// A tty7-managed worktree a closing tab sat in, resolved for the
|
||||
/// close-time cleanup offer: where it is, its branch, the repository it
|
||||
/// belongs to, and whether it holds uncommitted changes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedWorktree {
|
||||
pub path: PathBuf,
|
||||
@@ -143,16 +88,7 @@ pub struct ManagedWorktree {
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
/// Resolve `cwd` to the tty7-managed worktree containing it, or `None` when it
|
||||
/// sits anywhere else. Only checkouts under the main repository's
|
||||
/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered
|
||||
/// for removal. Blocking (spawns `git`).
|
||||
pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
|
||||
// Canonicalize before the component test: git reports resolved physical
|
||||
// paths (`/private/var/…` on macOS), while `cwd` may arrive through
|
||||
// symlinks — a textual comparison would then never match. The `.tty7/
|
||||
// worktrees` ancestor check is a cheap pure-textual pre-filter, so the
|
||||
// common case (every ordinary tab close) never spawns git.
|
||||
let cwd = host.canonicalize(cwd).ok()?;
|
||||
let suffix = host.join(Path::new(".tty7"), "worktrees");
|
||||
if !cwd.ancestors().any(|a| a.ends_with(&suffix)) {
|
||||
@@ -168,8 +104,6 @@ pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
|
||||
.map(PathBuf::from)?
|
||||
.parent()?
|
||||
.to_path_buf();
|
||||
// The checkout must really sit in *this* repository's managed directory —
|
||||
// both paths come from git, so they compare on equal (physical) footing.
|
||||
if !path.starts_with(managed_root(host, &main_root)) {
|
||||
return None;
|
||||
}
|
||||
@@ -185,13 +119,6 @@ pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any of `cwds` still lives inside the worktree at `path` — removing
|
||||
/// the checkout then would pull the directory out from under a live shell (new
|
||||
/// tabs inherit the current cwd, so two tabs sharing one worktree is common).
|
||||
/// Both sides are canonicalized before the ancestor test — cwds may arrive
|
||||
/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix
|
||||
/// that git-reported paths lack, so comparing raw would never match. A
|
||||
/// vanished path never counts as occupying.
|
||||
pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool {
|
||||
let Ok(path) = host.canonicalize(path) else {
|
||||
return false;
|
||||
@@ -200,9 +127,6 @@ pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool {
|
||||
.any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
|
||||
}
|
||||
|
||||
/// Remove a managed worktree (`git worktree remove`, `--force` to discard
|
||||
/// uncommitted changes), then best-effort delete its branch with `-d` — so a
|
||||
/// branch carrying unmerged commits survives the cleanup.
|
||||
pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> {
|
||||
let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?;
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
@@ -215,11 +139,6 @@ pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate the repository containing `cwd` and the directory its managed
|
||||
/// worktrees live in: `(repo_root, <main-root>/.tty7/worktrees)`. Anchored on
|
||||
/// the *main* repository even when `cwd` is itself inside a linked worktree
|
||||
/// (a worktree tab spawning another worktree), so checkouts never nest. The
|
||||
/// common git-dir is `<main>/.git`, whose parent is the main root.
|
||||
fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"])
|
||||
.map_err(|_| "not inside a git repository".to_string())?;
|
||||
@@ -237,18 +156,12 @@ fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
Ok((repo_root, dir))
|
||||
}
|
||||
|
||||
/// Compute the sheet's pre-filled values: a generated `adjective-noun` name
|
||||
/// (retried until both the branch and the directory are unused, with a
|
||||
/// numeric-suffix fallback so a saturated pool still terminates) and the
|
||||
/// currently checked-out branch as the start point.
|
||||
pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> {
|
||||
let (repo_root, dir) = repo_dir(host, cwd)?;
|
||||
|
||||
let mut state = seed();
|
||||
let mut name = candidate(&mut state);
|
||||
for attempt in 0..64 {
|
||||
// Both the ref and the directory must be free — a stale directory from a
|
||||
// hand-removed worktree would make `git worktree add` fail either way.
|
||||
if !branch_exists(host, &repo_root, &name) && !host.exists(&host.join(&dir, &name)) {
|
||||
break;
|
||||
}
|
||||
@@ -259,16 +172,11 @@ pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String>
|
||||
};
|
||||
}
|
||||
|
||||
// Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD.
|
||||
let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.unwrap_or_else(|_| "HEAD".to_string());
|
||||
Ok(WorktreeDefaults { name, base, dir })
|
||||
}
|
||||
|
||||
/// Create the requested worktree for the repository containing `cwd`, at
|
||||
/// `<main-root>/.tty7/worktrees/<name>`, on new branch `branch` starting from
|
||||
/// `base`. Branch and base validity is git's to judge; the directory name only
|
||||
/// has to stay a single path component so it can't escape the managed root.
|
||||
pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> {
|
||||
if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\'])
|
||||
{
|
||||
@@ -277,10 +185,6 @@ pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewW
|
||||
let (repo_root, dir) = repo_dir(host, cwd)?;
|
||||
host.create_dir(&dir, true)
|
||||
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
// A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included,
|
||||
// the ignore file itself too) out of the repository's `git status`, without
|
||||
// ever editing the repo's own .gitignore. Best-effort: a failed write only
|
||||
// costs status noise, never the worktree.
|
||||
let ignore = host.join(
|
||||
dir.parent().expect(".tty7/worktrees has a parent"),
|
||||
".gitignore",
|
||||
@@ -315,8 +219,6 @@ pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewW
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh scratch dir under the system temp location, unique per test —
|
||||
/// the same std-only pattern the config tests use (no tempfile dep).
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -324,8 +226,6 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Strip Windows' `\\?\` verbatim prefix so `std::fs::canonicalize` output
|
||||
/// compares equal to the plain absolute paths git reports; a no-op on Unix.
|
||||
fn plain(p: &Path) -> PathBuf {
|
||||
let s = p.to_string_lossy();
|
||||
PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string())
|
||||
@@ -344,8 +244,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A throwaway repo with one commit, so `worktree add` has a HEAD to branch
|
||||
/// from.
|
||||
fn temp_repo(name: &str) -> PathBuf {
|
||||
let dir = scratch(name);
|
||||
sh(&dir, &["git", "init", "-q"]);
|
||||
@@ -357,15 +255,10 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// The host every test drives: this machine. `LocalHost` is what the GUI
|
||||
/// hands these functions today, so testing against it tests the real path;
|
||||
/// a remote host is covered by the conformance suite instead.
|
||||
fn h() -> crate::host::SharedHost {
|
||||
crate::host::local::LocalHost::new()
|
||||
}
|
||||
|
||||
/// The simplest sensible request: directory and branch share `name`,
|
||||
/// starting from HEAD — what the sheet submits when nothing is edited.
|
||||
fn req(name: &str) -> WorktreeRequest {
|
||||
WorktreeRequest {
|
||||
name: name.into(),
|
||||
@@ -391,8 +284,6 @@ mod tests {
|
||||
assert!(!branch_exists(&*h, &repo, &d.name));
|
||||
let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(d.base, head);
|
||||
// The target dir is the repo's own `.tty7/worktrees` (git reports the
|
||||
// canonical root: /var → /private/var on macOS).
|
||||
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
|
||||
assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees"));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
@@ -405,20 +296,15 @@ mod tests {
|
||||
let wt = create(&*h, &repo, &req("quiet-otter")).unwrap();
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
assert!(branch_exists(&*h, &repo, &wt.branch));
|
||||
// The worktree lands under `<repo>/.tty7/worktrees/<name>`…
|
||||
let canon = plain(&std::fs::canonicalize(&repo).unwrap());
|
||||
assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter"));
|
||||
// …on the new branch…
|
||||
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(head, wt.branch);
|
||||
// …and the auto-written `.tty7/.gitignore` keeps the main repo's
|
||||
// status clean despite the checkout living inside it.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(),
|
||||
"*\n"
|
||||
);
|
||||
assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), "");
|
||||
// A second request colliding on the directory is refused up front.
|
||||
assert!(
|
||||
create(&*h, &repo, &req("quiet-otter"))
|
||||
.unwrap_err()
|
||||
@@ -431,7 +317,6 @@ mod tests {
|
||||
fn create_honors_custom_branch_and_base() {
|
||||
let h = h();
|
||||
let repo = temp_repo("base");
|
||||
// A `stable` branch one commit behind the default branch's HEAD.
|
||||
sh(&repo, &["git", "branch", "stable"]);
|
||||
std::fs::write(repo.join("b.txt"), "b").unwrap();
|
||||
sh(&repo, &["git", "add", "."]);
|
||||
@@ -446,11 +331,9 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Directory and branch names diverge as requested…
|
||||
assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir");
|
||||
let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
|
||||
assert_eq!(head, "feat/my-branch");
|
||||
// …and the checkout starts from `stable` (no b.txt yet).
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
assert!(!wt.path.join("b.txt").exists());
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
@@ -478,8 +361,6 @@ mod tests {
|
||||
let h = h();
|
||||
let repo = temp_repo("nest");
|
||||
let first = create(&*h, &repo, &req("first-wt")).unwrap();
|
||||
// Spawn the second worktree from *inside* the first: it must land in
|
||||
// the main repo's `.tty7/worktrees`, not nest inside the first checkout.
|
||||
let second = create(&*h, &first.path, &req("second-wt")).unwrap();
|
||||
assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap());
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
@@ -490,9 +371,7 @@ mod tests {
|
||||
let h = h();
|
||||
let repo = temp_repo("mg");
|
||||
let wt = create(&*h, &repo, &req("mg-wt")).unwrap();
|
||||
// The repo root itself is never "managed"…
|
||||
assert!(managed(&*h, &repo).is_none());
|
||||
// …nor is a linked worktree the user made outside `.tty7/worktrees`.
|
||||
let own = scratch("mg-own");
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
sh(
|
||||
@@ -507,15 +386,12 @@ mod tests {
|
||||
],
|
||||
);
|
||||
assert!(managed(&*h, &own).is_none());
|
||||
// Any path inside the managed checkout resolves to it, initially clean.
|
||||
let sub = wt.path.join("sub");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
let m = managed(&*h, &sub).unwrap();
|
||||
assert_eq!(m.branch, wt.branch);
|
||||
assert_eq!(m.path, wt.path);
|
||||
assert!(!m.dirty);
|
||||
// Uncommitted changes flip `dirty` and block a plain remove; --force
|
||||
// discards them. The branch (no unique commits) is deleted with it.
|
||||
std::fs::write(wt.path.join("b.txt"), "b").unwrap();
|
||||
let m = managed(&*h, &wt.path).unwrap();
|
||||
assert!(m.dirty);
|
||||
@@ -527,32 +403,14 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
}
|
||||
|
||||
/// `Host::git` runs every invocation with `GIT_OPTIONAL_LOCKS=0`, which
|
||||
/// this module did *not* set when it spawned `git` itself. That variable is
|
||||
/// the one thing in the unified invocation with any claim to affect a
|
||||
/// *write*, so the whole create → list → remove → delete-branch path is
|
||||
/// exercised end to end under it rather than argued about.
|
||||
///
|
||||
/// It is safe by git's own definition — "complete any requested operation
|
||||
/// without performing any optional sub-operations that require taking a
|
||||
/// lock" (`git(1)`, GIT_OPTIONAL_LOCKS). `worktree add` and `branch -d` are
|
||||
/// the *requested* operations, never optional sub-operations, and the locks
|
||||
/// they need are taken regardless. This test is what keeps that from being
|
||||
/// a reading of the manual: it fails if a git version ever decides
|
||||
/// otherwise.
|
||||
#[test]
|
||||
fn writes_survive_the_optional_locks_invariant() {
|
||||
let h = h();
|
||||
let repo = temp_repo("locks");
|
||||
// That the variable is *set* on every `Host::git` is asserted once, for
|
||||
// every host, by the conformance suite's `git_optional_locks_env_is_set`
|
||||
// — not re-derived here. What this test owns is the consequence.
|
||||
|
||||
// add — the write the contract flags as the one to prove.
|
||||
let wt = create(&*h, &repo, &req("lock-wt")).unwrap();
|
||||
assert!(wt.path.join("a.txt").exists());
|
||||
|
||||
// list — the new checkout is really registered, not merely on disk.
|
||||
let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap();
|
||||
assert!(
|
||||
list.lines()
|
||||
@@ -560,8 +418,6 @@ mod tests {
|
||||
"worktree list must show the new checkout: {list}"
|
||||
);
|
||||
|
||||
// A commit inside the checkout: an index write, the operation whose
|
||||
// *optional* index refresh is what the variable suppresses.
|
||||
std::fs::write(wt.path.join("c.txt"), "c").unwrap();
|
||||
git(&*h, &wt.path, &["add", "."]).unwrap();
|
||||
git(
|
||||
@@ -580,15 +436,10 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), "");
|
||||
|
||||
// remove + `branch -d`: the branch now carries a commit the main branch
|
||||
// does not, so the best-effort `-d` correctly declines and the branch
|
||||
// survives — the safety property `remove` documents.
|
||||
let m = managed(&*h, &wt.path).unwrap();
|
||||
remove(&*h, &m, false).unwrap();
|
||||
assert!(!wt.path.exists());
|
||||
assert!(branch_exists(&*h, &repo, &wt.branch));
|
||||
// …and a branch with nothing unique on it is deleted, so the `-d` is
|
||||
// genuinely running rather than always failing.
|
||||
let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap();
|
||||
let m = managed(&*h, &plain_wt.path).unwrap();
|
||||
remove(&*h, &m, false).unwrap();
|
||||
@@ -605,9 +456,7 @@ mod tests {
|
||||
let inside = wt.path.join("deep");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
assert!(occupied(&*h, &wt.path, &[repo.clone(), inside]));
|
||||
// Cwds elsewhere in the repo don't count…
|
||||
assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo)));
|
||||
// …and neither does a cwd that no longer exists.
|
||||
assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")]));
|
||||
let _ = std::fs::remove_dir_all(&repo);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +1,20 @@
|
||||
//! [`Duplex`] — one bidirectional link, as the *server* side sees it.
|
||||
//!
|
||||
//! The client half of a control connection takes its two halves separately
|
||||
//! ([`ControlClient::connect`](crate::daemon::control::ControlClient::connect)):
|
||||
//! it is always the side that opened the link, so it already holds whatever it
|
||||
//! opened. The server is handed things — an accepted socket, or a process it was
|
||||
//! `exec`'d into — and has to get two halves *out* of them. That is all this
|
||||
//! trait does.
|
||||
//!
|
||||
//! # Why not `try_clone`
|
||||
//!
|
||||
//! Every existing server path in the tree splits with `try_clone`
|
||||
//! (`daemon::server::handle_conn`), which works because a socket is one object
|
||||
//! with two directions. Under `tty7-server --stdio` it is not: the read half is
|
||||
//! file descriptor 0 and the write half is file descriptor 1, two unrelated
|
||||
//! pipes to two different places. There is nothing to clone. So the split
|
||||
//! happens once, at construction, and the trait says so.
|
||||
//!
|
||||
//! # Shutdown is [`LinkShutdown`], not a second abstraction
|
||||
//!
|
||||
//! A server thread parked in `read` cannot be woken by a flag — it is inside a
|
||||
//! syscall, and the peer has no reason to send anything, because it is waiting
|
||||
//! for a reply. This is precisely the deadlock
|
||||
//! [`LinkShutdown`](crate::daemon::control::LinkShutdown) exists to break on the
|
||||
//! client side, and it is the same deadlock here.
|
||||
//!
|
||||
//! So [`Halves::shutdown`] **is** a `LinkShutdown`, reusing that trait rather
|
||||
//! than mirroring it. Two shutdown abstractions that mean the same thing would
|
||||
//! be two places to forget a transport, and the socket impls would have to be
|
||||
//! written twice; adopting the existing one costs nothing and keeps a single
|
||||
//! answer to "how do I force this link to end".
|
||||
//!
|
||||
//! It is also non-optional. Every transport a server can be handed *has* an
|
||||
//! answer — for a socket it is `shutdown(2)`, for stdio it is closing the write
|
||||
//! half — and making the field an `Option` would only mean the question could be
|
||||
//! skipped, which is how the client-side deadlock happened in the first place.
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::daemon::control::LinkShutdown;
|
||||
|
||||
/// The two halves of a link, plus the handle that can force the read half to
|
||||
/// return.
|
||||
pub struct Halves<R, W> {
|
||||
/// Inbound bytes. Read on the connection's own thread.
|
||||
pub read: R,
|
||||
/// Outbound bytes. Shared by every worker replying on this connection, so
|
||||
/// the server keeps it behind a mutex and writes one whole frame per lock.
|
||||
pub write: W,
|
||||
/// How to end the link from another thread. See the module docs.
|
||||
pub shutdown: Arc<dyn LinkShutdown>,
|
||||
}
|
||||
|
||||
/// A bidirectional link a server can serve one connection over.
|
||||
///
|
||||
/// Implemented for the accepted-socket types on both platforms and for
|
||||
/// [`StdioDuplex`]; anything else a future transport brings (an SSH channel,
|
||||
/// say) implements it the same way.
|
||||
pub trait Duplex: Send + 'static {
|
||||
/// The inbound half.
|
||||
type Read: io::Read + Send + 'static;
|
||||
/// The outbound half.
|
||||
type Write: io::Write + Send + 'static;
|
||||
|
||||
/// Consume the link and yield its halves. Consuming rather than borrowing is
|
||||
/// what lets stdio participate: its halves were never one object to begin
|
||||
/// with.
|
||||
fn split(self) -> io::Result<Halves<Self::Read, Self::Write>>;
|
||||
|
||||
/// A short label for logs and diagnostics, so "the link dropped" can say
|
||||
/// *which kind* of link.
|
||||
fn kind_label(&self) -> &'static str;
|
||||
}
|
||||
|
||||
@@ -112,22 +57,6 @@ impl Duplex for std::net::TcpStream {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stdio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The process's own stdin and stdout, as one link.
|
||||
///
|
||||
/// This is how `tty7-server --stdio --serve` is reached: whatever spawned it
|
||||
/// (`ssh host tty7-server --stdio`, `wsl.exe -- tty7-server --stdio`, or a test
|
||||
/// harness) talks to it down the pipes it was born with. There is no socket, no
|
||||
/// port and no filesystem rendezvous — which is the entire point, because that
|
||||
/// is what makes the path work under `AllowStreamLocalForwarding no`, under WSL,
|
||||
/// and in CI on a box with no sshd.
|
||||
///
|
||||
/// Unix only. A Windows `tty7-server` is reached over its own loopback
|
||||
/// transport; nothing in the tree spawns one down a pipe, and the handle
|
||||
/// surgery below has no portable equivalent worth carrying unused.
|
||||
#[cfg(unix)]
|
||||
pub struct StdioDuplex {
|
||||
read: std::fs::File,
|
||||
@@ -136,31 +65,14 @@ pub struct StdioDuplex {
|
||||
|
||||
#[cfg(unix)]
|
||||
impl StdioDuplex {
|
||||
/// Take exclusive ownership of the process's stdin and stdout.
|
||||
///
|
||||
/// **This is a hijack, deliberately.** Both descriptors are duplicated, and
|
||||
/// the originals are then pointed at `/dev/null`. After this call `println!`,
|
||||
/// a library's stray progress bar, and anything else that reaches for fd 1
|
||||
/// write into the void instead of into the middle of a control frame. A
|
||||
/// protocol carried on stdout cannot share stdout, and "nothing in this
|
||||
/// process ever prints" is not an invariant that survives a dependency
|
||||
/// bump — so it is enforced here rather than assumed.
|
||||
///
|
||||
/// Diagnostics still work: stderr is untouched, and it is where the stdio
|
||||
/// server logs.
|
||||
pub fn take() -> io::Result<StdioDuplex> {
|
||||
use std::os::fd::FromRawFd as _;
|
||||
|
||||
// Duplicate first, redirect second: if the redirect failed after a
|
||||
// successful dup we would still hold a working link, whereas the other
|
||||
// order could leave the process with no stdout at all.
|
||||
let stdin_fd = dup_fd(libc::STDIN_FILENO)?;
|
||||
let stdout_fd = dup_fd(libc::STDOUT_FILENO)?;
|
||||
redirect_to_null(libc::STDIN_FILENO)?;
|
||||
redirect_to_null(libc::STDOUT_FILENO)?;
|
||||
|
||||
// SAFETY: both fds came from `dup(2)` above, are owned by nobody else,
|
||||
// and are handed to `File` exactly once.
|
||||
let read = unsafe { std::fs::File::from_raw_fd(stdin_fd) };
|
||||
let write = unsafe { std::fs::File::from_raw_fd(stdout_fd) };
|
||||
Ok(StdioDuplex {
|
||||
@@ -191,20 +103,6 @@ impl Duplex for StdioDuplex {
|
||||
}
|
||||
}
|
||||
|
||||
/// The write half of a [`StdioDuplex`]: a closable stdout.
|
||||
///
|
||||
/// Closing it is the whole reason this type exists rather than a bare `File`.
|
||||
/// The peer of a stdio server has exactly one way to learn the server is
|
||||
/// finished — reading EOF on the pipe — and the only way to produce that EOF is
|
||||
/// to drop the last descriptor on our end. So the file lives behind a shared
|
||||
/// slot that [`LinkShutdown::shutdown_link`] can empty from any thread.
|
||||
///
|
||||
/// The read half is deliberately *not* closable the same way. Closing a pipe
|
||||
/// descriptor another thread is already blocked reading on does not wake it (the
|
||||
/// open file description outlives the descriptor), so pretending otherwise would
|
||||
/// be a shutdown that silently does nothing. What actually ends the read is the
|
||||
/// peer hanging up — which closing our write half is exactly what provokes. It
|
||||
/// is the same half-close a TCP `shutdown(Write)` performs, for the same reason.
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone)]
|
||||
pub struct StdioWriter {
|
||||
@@ -228,9 +126,6 @@ impl io::Write for StdioWriter {
|
||||
let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match slot.as_mut() {
|
||||
Some(f) => f.flush(),
|
||||
// Nothing buffered and nothing to flush it to: the shutdown already
|
||||
// happened, and reporting an error here would only turn an orderly
|
||||
// close into a logged failure.
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -246,9 +141,6 @@ impl LinkShutdown for StdioWriter {
|
||||
.take()
|
||||
.is_some();
|
||||
if !taken {
|
||||
// Already closed. Idempotent on purpose: `close` runs from both the
|
||||
// teardown path and a `Drop`, and neither should have to know
|
||||
// whether the other went first.
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
@@ -257,8 +149,6 @@ impl LinkShutdown for StdioWriter {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn dup_fd(fd: libc::c_int) -> io::Result<libc::c_int> {
|
||||
// SAFETY: `fd` is one of the standard descriptors; `dup` either returns a
|
||||
// fresh owned descriptor or -1 with errno set.
|
||||
let new = unsafe { libc::dup(fd) };
|
||||
if new < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
@@ -273,8 +163,6 @@ fn redirect_to_null(fd: libc::c_int) -> io::Result<()> {
|
||||
.write(true)
|
||||
.open("/dev/null")?;
|
||||
use std::os::fd::AsRawFd as _;
|
||||
// SAFETY: both descriptors are valid and owned here; `dup2` closes `fd`
|
||||
// and re-points it at `/dev/null` atomically.
|
||||
let rc = unsafe { libc::dup2(null.as_raw_fd(), fd) };
|
||||
if rc < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
@@ -287,9 +175,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
/// The socket impls hand back halves that really are the same link, and a
|
||||
/// shutdown from a third handle ends a parked read — the property the whole
|
||||
/// trait exists for.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_unix_stream_splits_into_working_halves() {
|
||||
@@ -313,7 +198,6 @@ mod tests {
|
||||
read.read_exact(&mut got).unwrap();
|
||||
assert_eq!(&got, b"pong");
|
||||
|
||||
// The parked reader has to come back, not hang.
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut sink = Vec::new();
|
||||
read.read_to_end(&mut sink).map(|_| ())
|
||||
@@ -322,8 +206,6 @@ mod tests {
|
||||
let _ = reader.join().unwrap();
|
||||
}
|
||||
|
||||
/// Shutting a stdio writer down closes it for good: a later write fails
|
||||
/// rather than quietly succeeding into a descriptor the peer no longer has.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_shut_stdio_writer_refuses_further_writes() {
|
||||
@@ -338,7 +220,6 @@ mod tests {
|
||||
w.write(b"after").unwrap_err().kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
);
|
||||
// Idempotent: teardown and `Drop` both call it.
|
||||
w.shutdown_link().unwrap();
|
||||
assert_eq!(std::fs::read(tmp.path()).unwrap(), b"before");
|
||||
}
|
||||
|
||||
@@ -1,68 +1,21 @@
|
||||
//! The pure half of the installer: `uname -sm` → release asset, client version →
|
||||
//! release tag → download URL, and the remote paths a server binary lives at.
|
||||
//!
|
||||
//! Everything here is a total function of its arguments — no network, no SFTP, no
|
||||
//! clock — which is the point: the asset naming here is a *literal* contract
|
||||
//! with the release workflow (`.github/workflows/release.yml`), and a contract
|
||||
//! is only worth having if both sides can be tested without standing up the
|
||||
//! other one.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// The release asset for a 64-bit x86 Linux box.
|
||||
///
|
||||
/// **`<os>-<arch>-musl`, not the Rust target triple.** These names used to be
|
||||
/// `${{ matrix.target }}` pasted into a filename, which put `unknown` — the
|
||||
/// triple's *vendor* field, meaning "no particular vendor" — in front of anyone
|
||||
/// reading the releases page. Of the triple's four fields only two say anything
|
||||
/// to whoever downloads this: the architecture, which is what `asset_for_uname`
|
||||
/// picks by, and `musl`, which is why one file runs on any distribution. The
|
||||
/// order matches the GUI assets the same release publishes
|
||||
/// (`tty7-<version>-linux-x86_64.tar.gz`), so one release is one naming scheme.
|
||||
///
|
||||
/// The build target keeps the triple wherever it really is one — `cargo
|
||||
/// zigbuild --target`, the `target/<triple>/release` path, the cache key. This
|
||||
/// is a *download* name, and the two are no longer spelled the same on purpose.
|
||||
pub const ASSET_X86_64: &str = "tty7-server-linux-x86_64-musl";
|
||||
/// The release asset for a 64-bit ARM Linux box. See [`ASSET_X86_64`] for the
|
||||
/// naming.
|
||||
pub const ASSET_AARCH64: &str = "tty7-server-linux-aarch64-musl";
|
||||
/// The sha256 manifest published beside every asset in a release.
|
||||
pub const CHECKSUMS_ASSET: &str = "checksums.txt";
|
||||
|
||||
/// Where release assets are downloaded from. The tag and asset name are appended
|
||||
/// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for
|
||||
/// the checksum file itself.
|
||||
pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download";
|
||||
|
||||
/// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative
|
||||
/// to `$HOME`. Split into components because the installer has to `mkdir` each
|
||||
/// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of
|
||||
/// the *client's* OS — a Windows client must not produce `.local\share`.
|
||||
pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"];
|
||||
|
||||
/// Why a machine cannot be served a `tty7-server`.
|
||||
///
|
||||
/// Both variants carry the raw `uname -sm` output: the whole value of refusing
|
||||
/// instead of guessing is that the user can read the string we refused and either
|
||||
/// recognise their box or paste it into an issue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UnsupportedTarget {
|
||||
/// `uname -s` is not `Linux`. A remote tty7-server is a Linux binary; there
|
||||
/// is no macOS/BSD/Solaris asset to fall back to.
|
||||
NotLinux { raw: String },
|
||||
/// `uname -s` is `Linux` but `uname -m` is not one we publish for — 32-bit
|
||||
/// arm, i686, riscv64, or something we have never seen.
|
||||
UnknownMachine { raw: String },
|
||||
/// `uname -sm` did not produce the two whitespace-separated words it is
|
||||
/// specified to. Almost always means the command did not run at all (a login
|
||||
/// shell that printed a banner, a restricted shell) rather than a real
|
||||
/// answer, so it gets its own variant with the raw text.
|
||||
Unparseable { raw: String },
|
||||
}
|
||||
|
||||
impl UnsupportedTarget {
|
||||
/// The `uname -sm` text this refusal is about, as the remote printed it.
|
||||
pub fn raw(&self) -> &str {
|
||||
match self {
|
||||
Self::NotLinux { raw } | Self::UnknownMachine { raw } | Self::Unparseable { raw } => {
|
||||
@@ -94,17 +47,6 @@ impl fmt::Display for UnsupportedTarget {
|
||||
|
||||
impl std::error::Error for UnsupportedTarget {}
|
||||
|
||||
/// Map raw `uname -sm` output to the release asset that runs on that machine.
|
||||
///
|
||||
/// **Exact string match, then fail.** No prefix matching, no "starts with `arm`
|
||||
/// so it is probably aarch64" heuristic. Guessing wrong here installs a binary
|
||||
/// that dies with `Exec format error` at first exec — an error with no visible
|
||||
/// connection to the architecture detection that caused it, on a machine the user
|
||||
/// may not be able to inspect. An unknown machine string is a clean, explainable
|
||||
/// refusal that names itself.
|
||||
///
|
||||
/// `amd64` / `arm64` are accepted alongside the values Linux actually reports
|
||||
/// because some container images and BSD-flavoured userlands normalise to them.
|
||||
pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget> {
|
||||
let raw = uname_sm.trim().to_string();
|
||||
let mut words = raw.split_whitespace();
|
||||
@@ -121,18 +63,6 @@ pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an asset name that arrived over a wire back to a `&'static str`.
|
||||
///
|
||||
/// [`super::InstallRequest::asset`] is `&'static str` because on the producing
|
||||
/// side it is always one of the two consts above. A decoder cannot promise that,
|
||||
/// and the relay in `daemon::router` has to rebuild the request a *different
|
||||
/// process* raised — so the two known names map to themselves, and anything else
|
||||
/// (a client older or newer than the daemon that named it) is leaked.
|
||||
///
|
||||
/// Leaking is bounded in the way that matters: the value comes from tty7's own
|
||||
/// daemon naming one of its own release assets, and a session sees at most a
|
||||
/// handful of distinct machines. It is preferred to guessing one of the two
|
||||
/// consts, which would show the user a prompt naming the wrong architecture.
|
||||
pub fn interned(name: &str) -> &'static str {
|
||||
if name == ASSET_X86_64 {
|
||||
ASSET_X86_64
|
||||
@@ -143,12 +73,6 @@ pub fn interned(name: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The release tag whose assets a client of `version` must download.
|
||||
///
|
||||
/// The nightly channel republishes a single rolling `nightly` tag every night, so
|
||||
/// a nightly client must not ask for `v26.7.6-nightly.20260727` — that tag does
|
||||
/// not exist and never will. Rule: the version contains `-nightly.` → `nightly`;
|
||||
/// otherwise `v` + version.
|
||||
pub fn release_tag(version: &str) -> String {
|
||||
if version.contains("-nightly.") {
|
||||
"nightly".to_string()
|
||||
@@ -157,40 +81,18 @@ pub fn release_tag(version: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The download URL for one asset of one release.
|
||||
pub fn download_url(tag: &str, asset: &str) -> String {
|
||||
format!("{RELEASE_BASE}/{tag}/{asset}")
|
||||
}
|
||||
|
||||
/// Absolute remote paths for one *dialect*'s server binary.
|
||||
///
|
||||
/// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for
|
||||
/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows
|
||||
/// client).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePaths {
|
||||
/// `$HOME/.local/share/tty7/bin`.
|
||||
pub bin_dir: String,
|
||||
/// `$HOME/.local/share/tty7/bin/tty7-server-c<control>p<protocol>` — the
|
||||
/// atomically published binary. See [`binary_name`] for why the dialects, and
|
||||
/// not the version, are what the name carries.
|
||||
pub binary: String,
|
||||
/// `$HOME/.local/share/tty7/bin/.tty7-server-c<control>p<protocol>.tmp` —
|
||||
/// where the bytes land before `chmod`, the `--protocol` check, and `rename`.
|
||||
///
|
||||
/// A dotfile, so a half-written upload is not mistaken for an installed
|
||||
/// server by anything reading the directory. The installer adds a per-process
|
||||
/// suffix (`super::unique_temp`) before writing: one file per dialect means
|
||||
/// two clients installing the same dialect at once would otherwise interleave
|
||||
/// their bytes into one name.
|
||||
pub temp: String,
|
||||
/// Every directory that must exist before the upload, outermost first. SFTP
|
||||
/// has no recursive mkdir, so the installer walks this.
|
||||
pub dir_chain: Vec<String>,
|
||||
}
|
||||
|
||||
/// Build the remote paths for a server speaking `control`/`protocol` under an
|
||||
/// absolute remote `home`.
|
||||
pub fn remote_paths(home: &str, control: u32, protocol: u32) -> RemotePaths {
|
||||
let home = home.trim_end_matches('/');
|
||||
let mut dir_chain = Vec::with_capacity(INSTALL_DIR_COMPONENTS.len());
|
||||
@@ -209,41 +111,10 @@ pub fn remote_paths(home: &str, control: u32, protocol: u32) -> RemotePaths {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filename a server speaking `control`/`protocol` is installed under.
|
||||
///
|
||||
/// **The dialects are the name, and the version is nowhere in it.** Everything
|
||||
/// the installer decides — is there something usable here, can the daemon that
|
||||
/// is running talk to us — is a question about dialects, and a name built from
|
||||
/// them answers it with a `stat` the client can address without asking the
|
||||
/// remote anything. A name built from the version answers a *different*
|
||||
/// question, and answers this one wrong in both directions: two builds that
|
||||
/// share a version string but not a dialect (any two dev builds between
|
||||
/// releases) look interchangeable, and two builds that share a dialect but not a
|
||||
/// version look incompatible and cost an 8 MB upload that changes nothing.
|
||||
///
|
||||
/// One file per dialect, so a machine accumulates at most one binary per wire
|
||||
/// break rather than one per release. Which *build* is sitting behind a given
|
||||
/// dialect is a separate question, answered by [`PROTOCOL_FLAG`][flag] and by
|
||||
/// the control handshake — not by the filename.
|
||||
///
|
||||
/// [flag]: super::PROTOCOL_FLAG
|
||||
pub fn binary_name(control: u32, protocol: u32) -> String {
|
||||
format!("tty7-server-c{control}p{protocol}")
|
||||
}
|
||||
|
||||
/// [`RemotePaths`] pointing at a binary that is **already on the machine**,
|
||||
/// found rather than named — the server a connect adopted because it speaks our
|
||||
/// dialects (`Installer::adoptable_running_server`).
|
||||
///
|
||||
/// `binary` is the path as the remote reported it, verbatim: it is what the
|
||||
/// transport must connect to, and rebuilding it from a version parsed out of the
|
||||
/// filename would turn a binary installed somewhere unexpected into a path that
|
||||
/// does not exist.
|
||||
///
|
||||
/// `temp` and `dir_chain` still describe *our* install location, because that is
|
||||
/// where a later install would write. Nothing writes anything on the adoption
|
||||
/// path, so they are unused there; keeping them well-formed means a caller that
|
||||
/// falls back to installing does not need a second `RemotePaths`.
|
||||
pub fn remote_paths_for_binary(
|
||||
home: &str,
|
||||
binary: &str,
|
||||
@@ -255,18 +126,6 @@ pub fn remote_paths_for_binary(
|
||||
paths
|
||||
}
|
||||
|
||||
/// The dialects encoded in an installed binary's *path*, if it is one of ours.
|
||||
///
|
||||
/// This is how the running daemon is identified without asking it: the install
|
||||
/// path carries the dialects by construction, so `readlink /proc/<pid>/exe` on
|
||||
/// the remote answers "can the thing serving this machine talk to us" in the
|
||||
/// round trip that found it.
|
||||
///
|
||||
/// `None` for anything else, and that deliberately includes every binary
|
||||
/// installed by a client that named files after versions: an old name carries no
|
||||
/// dialect, so it gets no opinion, and the probe (`--protocol`) is what settles
|
||||
/// it. Guessing a dialect from a version string is the exact inference this
|
||||
/// naming exists to make impossible.
|
||||
pub fn dialect_from_path(path: &str) -> Option<(u32, u32)> {
|
||||
let name = path.rsplit('/').next()?;
|
||||
let (control, protocol) = name.strip_prefix("tty7-server-c")?.split_once('p')?;
|
||||
@@ -277,9 +136,6 @@ pub fn dialect_from_path(path: &str) -> Option<(u32, u32)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The contract's mapping table, row for row. This test *is* the client half
|
||||
/// of the asset naming contract: if the release workflow ever renames an
|
||||
/// asset, this is where the two sides stop agreeing.
|
||||
#[test]
|
||||
fn uname_maps_to_the_published_assets() {
|
||||
for raw in ["Linux x86_64", "Linux amd64"] {
|
||||
@@ -295,8 +151,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real `uname` output ends in a newline, and a shell may pad it. Trimming is
|
||||
/// the only normalisation allowed — the *words* are matched exactly.
|
||||
#[test]
|
||||
fn uname_output_is_trimmed_before_matching() {
|
||||
assert_eq!(asset_for_uname("Linux x86_64\n").unwrap(), ASSET_X86_64);
|
||||
@@ -306,10 +160,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal path, which is the whole reason this function exists. Every
|
||||
/// one of these would be a plausible prefix/fuzzy match — `x86_64-v2` starts
|
||||
/// with `x86_64`, `armv7l` starts with `arm`, `Linux` appears inside
|
||||
/// `GNU/Linux` — and each would install a binary that cannot exec.
|
||||
#[test]
|
||||
fn unknown_machines_are_refused_not_guessed() {
|
||||
for raw in [
|
||||
@@ -334,9 +184,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Linux host is refused with its own variant so the message can say
|
||||
/// "needs Linux" rather than "unknown architecture" — the user's next step is
|
||||
/// completely different.
|
||||
#[test]
|
||||
fn non_linux_systems_are_refused() {
|
||||
for raw in [
|
||||
@@ -355,11 +202,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that is not exactly two words never reaches the mapping. In
|
||||
/// practice this catches the common failure where the command did not run and
|
||||
/// we got a shell banner, an error message, or nothing at all — and it is the
|
||||
/// guard that keeps a three-word string from silently matching on its first
|
||||
/// two words.
|
||||
#[test]
|
||||
fn output_that_is_not_two_words_is_unparseable() {
|
||||
for raw in [
|
||||
@@ -380,15 +222,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable releases resolve to their own tag; nightlies resolve to the single
|
||||
/// rolling `nightly` tag, because per-night tags are never created.
|
||||
#[test]
|
||||
fn release_tag_sends_nightlies_to_the_rolling_tag() {
|
||||
assert_eq!(release_tag("26.7.5"), "v26.7.5");
|
||||
assert_eq!(release_tag("0.1.0"), "v0.1.0");
|
||||
assert_eq!(release_tag("26.7.6-nightly.20260727"), "nightly");
|
||||
// A pre-release that is *not* a nightly keeps its own tag: only the
|
||||
// nightly channel republishes under a rolling name.
|
||||
assert_eq!(release_tag("26.8.0-rc.1"), "v26.8.0-rc.1");
|
||||
}
|
||||
|
||||
@@ -404,16 +242,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The asset names, pinned as literals.**
|
||||
///
|
||||
/// They are one half of a contract whose other half is a `cp` in two
|
||||
/// workflow files, and checking them against the consts they come from
|
||||
/// would assert nothing. A literal here is what makes changing one side
|
||||
/// without the other a failing test rather than a 404 on a user's machine.
|
||||
///
|
||||
/// Including the absence of `unknown`: that word only ever reached these
|
||||
/// names by way of `${{ matrix.target }}`, and a build triple pasted into a
|
||||
/// download name is worth failing on rather than explaining again.
|
||||
#[test]
|
||||
fn asset_names_are_the_ones_the_release_workflow_publishes() {
|
||||
assert_eq!(ASSET_X86_64, "tty7-server-linux-x86_64-musl");
|
||||
@@ -424,17 +252,10 @@ mod tests {
|
||||
"{asset} carries the triple's vendor field"
|
||||
);
|
||||
}
|
||||
// `checksums::expected_digest` matches the filename field whole, and
|
||||
// says outright that it relies on no asset name being a substring of
|
||||
// another. Two names is the whole set, so check it here.
|
||||
assert!(!ASSET_X86_64.contains(ASSET_AARCH64));
|
||||
assert!(!ASSET_AARCH64.contains(ASSET_X86_64));
|
||||
}
|
||||
|
||||
/// Path construction, including the `mkdir` chain. Asserted literally: these
|
||||
/// strings are what an SFTP server sees, and a `\` in any of them (which is
|
||||
/// what `PathBuf::join` would produce on a Windows client) would create a file
|
||||
/// named `.local\share\tty7\bin` in the remote home directory.
|
||||
#[test]
|
||||
fn remote_paths_are_posix_and_named_by_dialect() {
|
||||
let p = remote_paths("/home/me", 3, 4);
|
||||
@@ -459,9 +280,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The temp name is a sibling dotfile of the target, so the finishing rename
|
||||
/// is same-directory (same filesystem → atomic) and a partial upload is not
|
||||
/// mistaken for an installed server.
|
||||
#[test]
|
||||
fn temp_path_is_a_hidden_sibling_of_the_binary() {
|
||||
let p = remote_paths("/home/me", 3, 4);
|
||||
@@ -471,19 +289,15 @@ mod tests {
|
||||
assert!(!p.binary.rsplit('/').next().unwrap().starts_with('.'));
|
||||
}
|
||||
|
||||
/// A trailing slash on the resolved home (some SFTP servers return `/root/`)
|
||||
/// must not produce a doubled separator.
|
||||
#[test]
|
||||
fn trailing_slash_on_home_is_absorbed() {
|
||||
assert_eq!(
|
||||
remote_paths("/root/", 1, 1).binary,
|
||||
"/root/.local/share/tty7/bin/tty7-server-c1p1"
|
||||
);
|
||||
// Root as home is degenerate but must still be well-formed.
|
||||
assert_eq!(remote_paths("/", 1, 1).bin_dir, "/.local/share/tty7/bin");
|
||||
}
|
||||
|
||||
/// The inverse used to identify a *running* daemon from its executable path.
|
||||
#[test]
|
||||
fn dialects_are_recoverable_from_an_install_path() {
|
||||
assert_eq!(
|
||||
@@ -491,19 +305,12 @@ mod tests {
|
||||
Some((3, 4))
|
||||
);
|
||||
assert_eq!(dialect_from_path("tty7-server-c12p30"), Some((12, 30)));
|
||||
// Not ours, or not dialect-named: no opinion rather than a wrong one.
|
||||
assert_eq!(dialect_from_path("/usr/bin/tty7-server"), None);
|
||||
assert_eq!(dialect_from_path("/bin/bash"), None);
|
||||
assert_eq!(dialect_from_path("tty7-server-c3"), None);
|
||||
assert_eq!(dialect_from_path("tty7-server-cxpy"), None);
|
||||
}
|
||||
|
||||
/// Every name a version-naming client ever installed reads as "no opinion".
|
||||
///
|
||||
/// The whole point of the rename is that a version string can no longer be
|
||||
/// mistaken for a dialect; a parser that squeezed `3` out of `26.7.3` would
|
||||
/// reintroduce exactly that, and on the paths of binaries already sitting on
|
||||
/// users' machines.
|
||||
#[test]
|
||||
fn legacy_version_named_binaries_carry_no_dialect() {
|
||||
for legacy in [
|
||||
@@ -516,7 +323,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Round-trip: the name we install under is the name we recognise later.
|
||||
#[test]
|
||||
fn install_path_and_dialect_extraction_round_trip() {
|
||||
for (c, p) in [(1u32, 1u32), (3, 4), (26, 7)] {
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
//! `checksums.txt` parsing and asset verification.
|
||||
//!
|
||||
//! The release publishes one GNU coreutils `sha256sum`-format manifest covering
|
||||
//! every asset. HTTPS to github.com is the trust anchor — the manifest is not
|
||||
//! separately signed — so this module's whole job is to make sure the bytes we
|
||||
//! are about to write onto someone else's machine are the bytes that release
|
||||
//! actually published.
|
||||
//!
|
||||
//! Pure: no network, no filesystem. The bytes come in as a slice.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
/// A parsed sha256 digest: 32 raw bytes, compared by value rather than by
|
||||
/// string so casing and whitespace can never make a comparison accidentally
|
||||
/// succeed.
|
||||
pub type Digest = [u8; 32];
|
||||
|
||||
/// Why an asset failed verification. Every variant aborts the install; none of
|
||||
/// them is retried, and there is no unverified fallback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChecksumError {
|
||||
/// The manifest has no line for this asset. Either the release is
|
||||
/// incomplete or we derived an asset name the release does not carry — both
|
||||
/// are "stop", never "install it anyway".
|
||||
Missing { asset: String },
|
||||
/// The asset's line exists but is not `<64 hex> <name>`.
|
||||
Malformed { asset: String, line: String },
|
||||
/// The manifest and the downloaded bytes disagree. The one variant that can
|
||||
/// mean something is actively wrong (a corrupted download, a proxy that
|
||||
/// rewrote the body, a compromised mirror), so it reports both digests.
|
||||
Missing {
|
||||
asset: String,
|
||||
},
|
||||
Malformed {
|
||||
asset: String,
|
||||
line: String,
|
||||
},
|
||||
Mismatch {
|
||||
asset: String,
|
||||
expected: String,
|
||||
@@ -64,14 +47,12 @@ impl fmt::Display for ChecksumError {
|
||||
|
||||
impl std::error::Error for ChecksumError {}
|
||||
|
||||
/// The sha256 of some bytes.
|
||||
pub fn sha256(bytes: &[u8]) -> Digest {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Render a digest as lowercase hex, for messages.
|
||||
pub fn hex(digest: &Digest) -> String {
|
||||
use fmt::Write as _;
|
||||
digest.iter().fold(String::with_capacity(64), |mut s, b| {
|
||||
@@ -80,8 +61,6 @@ pub fn hex(digest: &Digest) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse 64 hex characters into a digest. Case-insensitive; any
|
||||
/// other length or a non-hex character is a parse failure.
|
||||
fn parse_hex(s: &str) -> Option<Digest> {
|
||||
if s.len() != 64 {
|
||||
return None;
|
||||
@@ -93,25 +72,12 @@ fn parse_hex(s: &str) -> Option<Digest> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The digest `manifest` records for `asset`.
|
||||
///
|
||||
/// **The filename field is matched whole, never by substring.**
|
||||
/// `tty7-server-linux-x86_64-musl` happens not to be a substring of any other
|
||||
/// asset today, but that is an accident of the current release contents, not a
|
||||
/// property anyone maintains — and a substring match that drifted would
|
||||
/// silently verify one binary's bytes against another's digest.
|
||||
///
|
||||
/// The coreutils format is `<digest><two spaces><name>`; the second space is `*`
|
||||
/// in binary mode (`<digest> *<name>`), which some tools emit, so a leading `*`
|
||||
/// on the name is stripped. Blank lines and `#` comments are skipped.
|
||||
pub fn expected_digest(manifest: &str, asset: &str) -> Result<Digest, ChecksumError> {
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.trim().is_empty() || line.trim_start().starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Split once on whitespace: everything before is the digest field,
|
||||
// everything after (minus the binary-mode marker) is the filename field.
|
||||
let Some((digest_field, name_field)) = line.split_once(char::is_whitespace) else {
|
||||
continue;
|
||||
};
|
||||
@@ -129,8 +95,6 @@ pub fn expected_digest(manifest: &str, asset: &str) -> Result<Digest, ChecksumEr
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify downloaded `bytes` against the manifest. `Ok(())` is the only outcome
|
||||
/// that permits an install.
|
||||
pub fn verify(manifest: &str, asset: &str, bytes: &[u8]) -> Result<(), ChecksumError> {
|
||||
let expected = expected_digest(manifest, asset)?;
|
||||
let actual = sha256(bytes);
|
||||
@@ -149,8 +113,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::daemon::install::asset::{ASSET_AARCH64, ASSET_X86_64};
|
||||
|
||||
/// A manifest shaped exactly like the release workflow's, with digests that
|
||||
/// really are the sha256 of the payloads below.
|
||||
fn manifest_for(payloads: &[(&str, &[u8])]) -> String {
|
||||
payloads
|
||||
.iter()
|
||||
@@ -165,17 +127,13 @@ mod tests {
|
||||
verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify");
|
||||
}
|
||||
|
||||
/// Uppercase hex in the manifest is still the same digest.
|
||||
#[test]
|
||||
fn digest_comparison_is_case_insensitive() {
|
||||
let bytes = b"payload".as_slice();
|
||||
// Only the digest is uppercased — the filename field stays exact-match.
|
||||
let manifest = format!("{} {ASSET_X86_64}\n", hex(&sha256(bytes)).to_uppercase());
|
||||
verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter");
|
||||
}
|
||||
|
||||
/// **The failure path.** Bytes that do not match must abort with
|
||||
/// both digests reported — not retry, not install anyway.
|
||||
#[test]
|
||||
fn mismatched_bytes_abort_with_both_digests() {
|
||||
let published = b"the real server binary".as_slice();
|
||||
@@ -196,14 +154,11 @@ mod tests {
|
||||
}
|
||||
other => panic!("a mismatch must report both digests, got {other:?}"),
|
||||
}
|
||||
// The message has to be actionable on its own — it is what the user sees.
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("sha256"), "{msg}");
|
||||
assert!(msg.contains("aborted"), "{msg}");
|
||||
}
|
||||
|
||||
/// A one-bit difference is caught. Cheap to assert, and the property the
|
||||
/// whole verification exists for.
|
||||
#[test]
|
||||
fn a_single_flipped_bit_fails() {
|
||||
let mut payload = vec![0u8; 4096];
|
||||
@@ -217,9 +172,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// No line for our asset → abort. This is the "release is missing the
|
||||
/// architecture we need" case, and installing the other architecture (or
|
||||
/// nothing-checked) would both be worse than stopping.
|
||||
#[test]
|
||||
fn a_missing_entry_aborts() {
|
||||
let manifest = manifest_for(&[(ASSET_AARCH64, b"arm bytes")]);
|
||||
@@ -233,9 +185,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A line whose digest field is not 64 hex characters is malformed, not
|
||||
/// "close enough". Truncated digests are exactly what a partially-uploaded
|
||||
/// manifest looks like.
|
||||
#[test]
|
||||
fn a_malformed_entry_aborts() {
|
||||
for bad in [
|
||||
@@ -254,9 +203,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whole-field match, not substring.** A manifest carrying a longer name
|
||||
/// that *contains* ours must not satisfy the lookup — this is the guard
|
||||
/// whole-field matching exists for.
|
||||
#[test]
|
||||
fn filename_matching_is_exact_not_substring() {
|
||||
let payload = b"decoy".as_slice();
|
||||
@@ -274,8 +220,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Binary-mode (`*name`) lines, CRLF line endings, comments and blank lines
|
||||
/// are all shapes a checksum file can legitimately arrive in.
|
||||
#[test]
|
||||
fn tolerates_binary_mode_crlf_and_comments() {
|
||||
let payload = b"payload".as_slice();
|
||||
@@ -285,8 +229,6 @@ mod tests {
|
||||
verify(&manifest, ASSET_X86_64, payload).expect("binary-mode CRLF lines must parse");
|
||||
}
|
||||
|
||||
/// Empty input hashes to the well-known empty sha256; a fixed vector keeps
|
||||
/// the hashing itself honest rather than only self-consistent.
|
||||
#[test]
|
||||
fn sha256_matches_known_vectors() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,45 +1,14 @@
|
||||
//! The HTTPS half of D5: the *client* downloads release assets and pushes them
|
||||
//! over SSH, because the machines this feature exists for — behind a jump host,
|
||||
//! on an internal network, in a locked-down VPC — frequently cannot reach GitHub
|
||||
//! themselves.
|
||||
//!
|
||||
//! ## Why `ureq`, and why behind a feature
|
||||
//!
|
||||
//! The GUI's update check uses `reqwest_client`, which wraps Zed's reqwest fork
|
||||
//! behind `gpui::http_client`. `tty7-core` must not depend on gpui, so that stack
|
||||
//! is unavailable here. `ureq` is blocking (which matches this call path — the
|
||||
//! installer runs on a daemon std thread, not in an async context), rustls-based
|
||||
//! (no OpenSSL, so nothing to find at build time), and shares the `rustls` and
|
||||
//! `http` versions already in the tree.
|
||||
//!
|
||||
//! It is optional, behind `remote-install`, which the GUI crate turns on and
|
||||
//! `tty7-server` does not. `tty7-server` builds as a *static musl* binary that is
|
||||
//! itself the thing being downloaded; giving it an HTTP client would add size and
|
||||
//! a TLS backend to every remote install for a code path it can never take. Same
|
||||
//! mechanism as the existing `gssapi` feature, for the same reason.
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::AssetFetcher;
|
||||
|
||||
/// Overall budget for one asset download. A 6 MB binary on a bad connection is
|
||||
/// slow but finite; a stalled TLS session is not, and this is what makes the
|
||||
/// difference visible.
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Refuse a body larger than this. A release asset is ~6 MB; anything at this
|
||||
/// scale means we are downloading something other than what we asked for, and
|
||||
/// buffering it in memory before finding out is not a good trade.
|
||||
const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// How much body to take per read, and therefore how often progress is
|
||||
/// reported: ~130 updates over a 8 MB asset. Large enough that the syscall
|
||||
/// overhead stays irrelevant, small enough that a bar moves smoothly rather
|
||||
/// than in visible jumps.
|
||||
const READ_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Downloads release assets over HTTPS.
|
||||
pub struct HttpsFetcher {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
@@ -72,11 +41,6 @@ impl AssetFetcher for HttpsFetcher {
|
||||
.call()
|
||||
.map_err(|e| describe(url, &e.to_string()))?;
|
||||
|
||||
// GitHub serves release assets from a redirect to object storage; ureq
|
||||
// follows those itself. A 404 here is the interesting one: it means the
|
||||
// release tag we derived from our own version was never published (a
|
||||
// local dev build, a tag that failed to publish), and saying so beats
|
||||
// "download failed".
|
||||
let status = response.status().as_u16();
|
||||
if status == 404 {
|
||||
return Err(format!(
|
||||
@@ -87,8 +51,6 @@ impl AssetFetcher for HttpsFetcher {
|
||||
return Err(format!("{url} returned HTTP {status}"));
|
||||
}
|
||||
|
||||
// Only a hint: it is what the *server* claims, so it sizes the
|
||||
// allocation and the progress bar but never the ceiling check below.
|
||||
let declared = response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
@@ -97,9 +59,6 @@ impl AssetFetcher for HttpsFetcher {
|
||||
.filter(|n| *n <= MAX_ASSET_BYTES);
|
||||
|
||||
let mut body = response.into_body();
|
||||
// `take` still caps the read, so a lying (or absent) Content-Length
|
||||
// cannot make this buffer more than the ceiling — one byte over is
|
||||
// enough to detect it, which is why the limit is `+ 1`.
|
||||
let mut reader = body.as_reader().take(MAX_ASSET_BYTES + 1);
|
||||
let mut bytes = Vec::with_capacity(declared.unwrap_or(0) as usize);
|
||||
let mut buf = vec![0u8; READ_CHUNK];
|
||||
@@ -122,9 +81,6 @@ impl AssetFetcher for HttpsFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a transport error into something a user can act on. The distinction
|
||||
/// worth drawing is "the network is not reachable from here" (retry later, or
|
||||
/// check the proxy) versus everything else.
|
||||
fn describe(url: &str, reason: &str) -> String {
|
||||
let lower = reason.to_ascii_lowercase();
|
||||
if lower.contains("dns") || lower.contains("resolve") {
|
||||
@@ -142,32 +98,11 @@ fn describe(url: &str, reason: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructing the agent must not panic (a rustls provider that fails to
|
||||
/// install would, and would do it at the worst possible moment — mid-connect
|
||||
/// on someone's first remote workspace).
|
||||
#[test]
|
||||
fn the_agent_builds() {
|
||||
let _ = HttpsFetcher::default();
|
||||
}
|
||||
|
||||
/// Talks to the real github.com. `#[ignore]`d because it needs the network,
|
||||
/// which no other test here does — run it by hand (`cargo test -p tty7-core
|
||||
/// --features remote-install -- --ignored talks_to_github --nocapture`)
|
||||
/// after touching the HTTP client.
|
||||
///
|
||||
/// Two things only a live server can prove:
|
||||
///
|
||||
/// - **Redirects are followed.** Every GitHub download path — `/raw/` and
|
||||
/// `releases/download/…` alike — answers with a 302 to another host. A
|
||||
/// client that does not follow it returns an empty body under a status
|
||||
/// that still reads as success, so the "asset" would sha256 to the digest
|
||||
/// of nothing. Asserting real content is what catches that.
|
||||
/// - **The TLS trust anchor works.** ureq's webpki roots must accept
|
||||
/// github.com's chain; that HTTPS connection *is* the security model here
|
||||
/// (`checksums.txt` is not separately signed).
|
||||
///
|
||||
/// Deliberately a small file rather than a release asset: assets are ~20 MB
|
||||
/// and this is a correctness check, not a bandwidth test.
|
||||
#[test]
|
||||
#[ignore = "needs the network"]
|
||||
fn talks_to_github() {
|
||||
@@ -181,8 +116,6 @@ mod tests {
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
// And a tag that was never published is reported as such, not as a
|
||||
// generic transport failure.
|
||||
let missing = super::super::asset::download_url("v0.0.0-never", "tty7-server-nope");
|
||||
let err = fetcher
|
||||
.get(&missing)
|
||||
@@ -190,8 +123,6 @@ mod tests {
|
||||
assert!(err.contains("404"), "{err}");
|
||||
}
|
||||
|
||||
/// The proxy/TLS case gets its own wording because the fix is completely
|
||||
/// different from "try again later".
|
||||
#[test]
|
||||
fn tls_failures_name_the_likely_cause() {
|
||||
let msg = describe("https://example/x", "invalid peer certificate");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,3 @@
|
||||
//! [`RemoteOps`] over a live [`SshConnection`]: command execution on a session
|
||||
//! channel, file manipulation over SFTP.
|
||||
//!
|
||||
//! This is the only file in `install` that talks to a network. Everything it
|
||||
//! does is a thin, synchronous wrapper — the installer above it is a state
|
||||
//! machine, and keeping the IO down here as dumb as possible is what lets that
|
||||
//! state machine be tested against a fake.
|
||||
//!
|
||||
//! ## Why the SFTP work is split
|
||||
//!
|
||||
//! `stat` / `mkdir` / `chmod` / `rename` / `remove` / `list` all go through
|
||||
//! [`SftpManager`], which owns one cached SFTP session per connection — the
|
||||
//! installer costs no extra channel for them. The **byte write** does not:
|
||||
//! `SftpManager::start_transfer` is the wrong upload path for an install: it is
|
||||
//! a background job keyed by `pane_id` that reads from a local *file* and
|
||||
//! reports progress to the GUI's transfer tray, and an install has no pane, no
|
||||
//! local file (the bytes are in memory, already verified) and nothing to show
|
||||
//! in a tray. [`SftpManager::put_bytes`] exists for exactly this shape, so the
|
||||
//! write shares the connection's cached SFTP session — and its
|
||||
//! retry-once-on-transport-failure behaviour — rather than opening a channel of
|
||||
//! its own.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -30,16 +8,9 @@ use crate::daemon::ssh::{SshConnection, SshManager, sftp::SftpManager};
|
||||
|
||||
use super::{ExecOutput, RemoteOps, RemoteStat};
|
||||
|
||||
/// How long any single remote command may take. Generous: `uname` is instant,
|
||||
/// but the daemon probe opens a socket on a machine that may be busy, and a
|
||||
/// distant host's round trips add up. Short enough that a hung sshd surfaces as
|
||||
/// an error rather than as a connect that never returns.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Budget for the fire-and-forget daemon launch. The remote shell backgrounds
|
||||
/// the daemon and exits immediately, so this only has to cover a round trip.
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// [`RemoteOps`] backed by one authenticated SSH connection.
|
||||
pub struct SshRemoteOps {
|
||||
conn: Arc<SshConnection>,
|
||||
}
|
||||
@@ -49,7 +20,6 @@ impl SshRemoteOps {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
/// Run one SFTP op through the shared, cached session.
|
||||
fn sftp_op(&self, op: SftpOp) -> Result<SftpOpResult, String> {
|
||||
match SftpManager::global().op(&self.conn, &op) {
|
||||
SftpOpResult::Error(e) => Err(e),
|
||||
@@ -64,10 +34,6 @@ impl SshRemoteOps {
|
||||
|
||||
impl RemoteOps for SshRemoteOps {
|
||||
fn home_dir(&self) -> Result<String, String> {
|
||||
// SFTP's REALPATH against the session's own working directory, which is
|
||||
// the login directory. The same trick the file browser uses to open
|
||||
// somewhere better than `/`, and the only way to learn `$HOME` without
|
||||
// trusting a shell to have one set.
|
||||
match self.sftp_op(SftpOp::Realpath {
|
||||
path: ".".to_string(),
|
||||
})? {
|
||||
@@ -97,11 +63,6 @@ impl RemoteOps for SshRemoteOps {
|
||||
fn spawn_detached(&self, cmd: &str) -> Result<(), String> {
|
||||
let conn = self.conn.clone();
|
||||
let cmd = cmd.to_string();
|
||||
// The remote shell backgrounds the process and exits, so this *is* a
|
||||
// normal exec — only the budget differs. Its exit status is ignored on
|
||||
// purpose: `sh -c '... &'` reports on the backgrounding, never on the
|
||||
// daemon, and whether the daemon really came up is settled by probing
|
||||
// its socket, not by trusting a shell.
|
||||
self.block_on(async move {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, exec(&conn, &cmd)).await {
|
||||
Ok(Ok(_)) => Ok(()),
|
||||
@@ -123,9 +84,6 @@ impl RemoteOps for SshRemoteOps {
|
||||
is_dir: entry.kind == crate::daemon::protocol::SftpEntryKind::Dir,
|
||||
})),
|
||||
Ok(other) => Err(format!("unexpected SFTP reply for stat: {other:?}")),
|
||||
// "Not there" is an answer, not a failure — it is the *expected*
|
||||
// answer on the first install, and turning it into an error would
|
||||
// make step 2 unable to say "go install it".
|
||||
Err(e) if is_not_found(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
@@ -136,9 +94,6 @@ impl RemoteOps for SshRemoteOps {
|
||||
path: path.to_string(),
|
||||
}) {
|
||||
Ok(_) => Ok(()),
|
||||
// Servers disagree about which status an existing directory gets
|
||||
// (`Failure`, `PermissionDenied`, a bare "file already exists"), so
|
||||
// the authority on "does it exist" is a stat, not the error text.
|
||||
Err(e) => match self.stat(path) {
|
||||
Ok(Some(stat)) if stat.is_dir => Ok(()),
|
||||
_ => Err(e),
|
||||
@@ -191,12 +146,6 @@ impl RemoteOps for SshRemoteOps {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stringified SFTP error means "no such file", which every caller
|
||||
/// here treats as a normal answer rather than a failure.
|
||||
///
|
||||
/// russh-sftp renders a server status as `<code>: <message>`; the message text
|
||||
/// is the server's, so this matches on the shapes OpenSSH and the common
|
||||
/// non-OpenSSH servers produce rather than on a code we cannot see.
|
||||
fn is_not_found(msg: &str) -> bool {
|
||||
let lower = msg.to_ascii_lowercase();
|
||||
lower.contains("no such file")
|
||||
@@ -205,11 +154,6 @@ fn is_not_found(msg: &str) -> bool {
|
||||
|| lower.contains("does not exist")
|
||||
}
|
||||
|
||||
/// Run one command on its own session channel and collect everything it said.
|
||||
///
|
||||
/// Loops until `wait()` returns `None` rather than breaking on `Eof`/`Close`:
|
||||
/// the exit status arrives as its own message and can follow both, and the exit
|
||||
/// status is the entire point of the daemon probe.
|
||||
async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String> {
|
||||
let mut channel = conn
|
||||
.open_session_channel()
|
||||
@@ -219,9 +163,6 @@ async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String
|
||||
.exec(true, cmd)
|
||||
.await
|
||||
.map_err(|e| format!("could not run `{cmd}`: {e}"))?;
|
||||
// Close our end of the command's stdin immediately. Nothing here writes to
|
||||
// a command, and the daemon probe specifically relies on its stdin ending
|
||||
// so the bridge it starts hangs up instead of parking forever.
|
||||
let _ = channel.eof().await;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
@@ -247,10 +188,6 @@ async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The "absent" classification has to hold for the wordings the servers we
|
||||
/// meet actually use, because step 2's whole decision ("is the right version
|
||||
/// already installed?") rests on it — and misreading a missing file as an
|
||||
/// error would turn every first install into a hard failure.
|
||||
#[test]
|
||||
fn missing_files_are_recognised_across_server_wordings() {
|
||||
for msg in [
|
||||
@@ -264,9 +201,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// And must not swallow the failures that have to be reported: a full
|
||||
/// disk or a read-only home has to surface as an error with a path, never as
|
||||
/// "the file isn't there, go ahead and install".
|
||||
#[test]
|
||||
fn real_failures_are_not_mistaken_for_absence() {
|
||||
for msg in [
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
//! The install flow, driven end to end against an in-memory remote.
|
||||
//!
|
||||
//! Four things are asked for by name — `uname` parsing, version path
|
||||
//! construction, atomic replacement, and the sha256 failure path — and none of
|
||||
//! them may touch the network. The first two are unit-tested in
|
||||
//! [`super::asset`] and [`super::checksums`]; the last two need the *whole*
|
||||
//! sequence, which is what the fake remote here provides.
|
||||
//!
|
||||
//! The fake keeps a journal of every operation in order. That is what makes
|
||||
//! "atomic" testable: atomicity is not a property of any single call, it is the
|
||||
//! claim that the final path is only ever touched by a `rename` of an
|
||||
//! already-`chmod`ed temp — which is a statement about the *order* of the
|
||||
//! journal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -20,31 +6,19 @@ use super::*;
|
||||
use crate::daemon::install::asset::{ASSET_X86_64, CHECKSUMS_ASSET};
|
||||
|
||||
const VERSION: &str = "26.7.5";
|
||||
/// The dialects the fixture's client speaks. Fixed literals rather than
|
||||
/// [`RemoteProtocol::of_this_build`] so [`BINARY`] can be asserted as a string:
|
||||
/// these tests are about *how* the name is built, and a name derived from the
|
||||
/// same constants it is checked against would assert nothing.
|
||||
const CONTROL: u32 = 3;
|
||||
const PROTOCOL: u32 = 4;
|
||||
const HOME: &str = "/home/me";
|
||||
const BIN_DIR: &str = "/home/me/.local/share/tty7/bin";
|
||||
const BINARY: &str = "/home/me/.local/share/tty7/bin/tty7-server-c3p4";
|
||||
/// The shared per-dialect staging name. What actually gets written is
|
||||
/// [`temp()`] — `unique_temp` of this.
|
||||
const TEMP_BASE: &str = "/home/me/.local/share/tty7/bin/.tty7-server-c3p4.tmp";
|
||||
|
||||
/// The staging path this process writes to, which carries its pid.
|
||||
fn temp() -> String {
|
||||
unique_temp(TEMP_BASE)
|
||||
}
|
||||
|
||||
/// Stand-in for the release asset. Content is irrelevant; only its digest is.
|
||||
const SERVER_BYTES: &[u8] = b"\x7fELF...a static musl tty7-server, pretend it is 6 MB";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct FakeFile {
|
||||
bytes: Vec<u8>,
|
||||
@@ -52,8 +26,6 @@ struct FakeFile {
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
/// One entry in the journal. Only the operations that can change what is on
|
||||
/// disk are recorded; reads are not, because no ordering claim depends on them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Journal {
|
||||
Mkdir(String),
|
||||
@@ -69,25 +41,11 @@ struct FakeRemote {
|
||||
files: Mutex<HashMap<String, FakeFile>>,
|
||||
journal: Mutex<Vec<Journal>>,
|
||||
uname: String,
|
||||
/// Set to make every `put` fail, simulating a full disk / read-only home.
|
||||
put_error: Option<String>,
|
||||
daemon_running: Mutex<bool>,
|
||||
/// What `readlink /proc/<pid>/exe` finds, when a daemon is running.
|
||||
running_exe: Mutex<Option<String>>,
|
||||
/// Whether launching actually starts the fake daemon (false models a binary
|
||||
/// that dies on exec).
|
||||
launch_works: bool,
|
||||
/// What each binary answers to `--protocol`, by path. A path that is absent
|
||||
/// models a server too old to know the flag: the probe fails, and the
|
||||
/// installer falls back to having no opinion.
|
||||
speaks: Mutex<HashMap<String, RemoteProtocol>>,
|
||||
/// What a *freshly uploaded* binary answers. Registered by `put` against the
|
||||
/// path written, because the real installer asks the bytes it just staged
|
||||
/// what they speak before publishing them — a fake whose uploads stayed mute
|
||||
/// would model every install as a failed one.
|
||||
///
|
||||
/// `None` models bytes that cannot answer at all: the wrong architecture, or
|
||||
/// a build older than the flag.
|
||||
installed_speaks: Option<RemoteProtocol>,
|
||||
}
|
||||
|
||||
@@ -115,22 +73,16 @@ impl FakeRemote {
|
||||
}
|
||||
}
|
||||
|
||||
/// Teach the binary at `exe` to answer `--protocol` with `spoken`.
|
||||
fn speaking(self, exe: &str, spoken: RemoteProtocol) -> Self {
|
||||
self.speaks.lock().unwrap().insert(exe.to_string(), spoken);
|
||||
self
|
||||
}
|
||||
|
||||
/// Make whatever gets uploaded answer with `spoken` — a source that hands
|
||||
/// over a build other than the one the client asked for. `None` for bytes
|
||||
/// that cannot answer at all.
|
||||
fn uploads_speaking(mut self, spoken: Option<RemoteProtocol>) -> Self {
|
||||
self.installed_speaks = spoken;
|
||||
self
|
||||
}
|
||||
|
||||
/// A machine tty7 has installed on before (so consent is not re-asked), with
|
||||
/// this client's own dialect already published.
|
||||
fn with_previous_install(self) -> Self {
|
||||
self.preinstall(BINARY, 0o755);
|
||||
self.speaks
|
||||
@@ -140,8 +92,6 @@ impl FakeRemote {
|
||||
self
|
||||
}
|
||||
|
||||
/// A machine an *older, version-naming* client installed on: consent was
|
||||
/// given once, and what it left behind claims no dialect. Returns the path.
|
||||
fn with_legacy_install(self, version: &str) -> (Self, String) {
|
||||
let path = format!("{BIN_DIR}/tty7-server-{version}");
|
||||
self.preinstall(&path, 0o755);
|
||||
@@ -210,8 +160,6 @@ impl RemoteOps for FakeRemote {
|
||||
let exe = exe.trim_matches('\'');
|
||||
return match self.speaks.lock().unwrap().get(exe) {
|
||||
Some(spoken) => ok(&serde_json::to_string(spoken).unwrap()),
|
||||
// What a server older than the flag does: usage on stderr, and
|
||||
// a non-zero status.
|
||||
None => Ok(ExecOutput {
|
||||
status: Some(1),
|
||||
stdout: String::new(),
|
||||
@@ -313,8 +261,6 @@ impl RemoteOps for FakeRemote {
|
||||
is_dir: false,
|
||||
},
|
||||
);
|
||||
// Uploaded bytes are a binary that can be asked what it speaks, which is
|
||||
// exactly what the installer does with them next.
|
||||
let mut speaks = self.speaks.lock().unwrap();
|
||||
match &self.installed_speaks {
|
||||
Some(spoken) => speaks.insert(path.to_string(), spoken.clone()),
|
||||
@@ -332,7 +278,6 @@ impl RemoteOps for FakeRemote {
|
||||
match files.remove(from) {
|
||||
Some(f) => {
|
||||
files.insert(to.to_string(), f);
|
||||
// The binary keeps its answer when it changes name.
|
||||
let mut speaks = self.speaks.lock().unwrap();
|
||||
match speaks.remove(from) {
|
||||
Some(spoken) => speaks.insert(to.to_string(), spoken),
|
||||
@@ -370,12 +315,8 @@ impl RemoteOps for FakeRemote {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves a canned release: the asset plus a manifest that really does contain
|
||||
/// its digest, unless [`FakeRelease::corrupt`] says otherwise.
|
||||
struct FakeRelease {
|
||||
asset_bytes: Vec<u8>,
|
||||
/// Bytes the manifest claims the asset hashes to. Differs from
|
||||
/// `asset_bytes` in the tampering test.
|
||||
manifest_of: Vec<u8>,
|
||||
fetched: Mutex<Vec<String>>,
|
||||
fail: Option<String>,
|
||||
@@ -391,8 +332,6 @@ impl FakeRelease {
|
||||
}
|
||||
}
|
||||
|
||||
/// A release whose manifest does not describe the bytes it serves — a
|
||||
/// corrupted download, a rewriting proxy, a tampered mirror.
|
||||
fn corrupt(mut self) -> Self {
|
||||
self.asset_bytes = b"something else entirely".to_vec();
|
||||
self
|
||||
@@ -457,19 +396,6 @@ impl InstallConfirm for FakeUser {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's installer: this file's [`VERSION`], this file's dialect, and
|
||||
/// timeouts a fake can satisfy.
|
||||
///
|
||||
/// **Every test builds its installer through here**, and the dialect is why.
|
||||
/// `Installer::new` starts at [`RemoteProtocol::of_this_build`], while
|
||||
/// [`FakeRemote`] answers with [`ours`] — the fixture's fixed `c3p4`. A test that
|
||||
/// hand-rolls the builder and forgets [`Installer::with_dialect`] passes only
|
||||
/// while the real [`CONTROL_VERSION`](crate::daemon::control::CONTROL_VERSION)
|
||||
/// happens to equal [`CONTROL`], and then fails on the next wire break with a
|
||||
/// `DialectMismatch` that has nothing to do with whatever that bump changed.
|
||||
/// Two tests did exactly that, so `release` is a trait object: a chunked or
|
||||
/// throttled fetcher is a reason to vary the *source*, never a reason to leave
|
||||
/// this function.
|
||||
fn installer<'a>(
|
||||
remote: &'a FakeRemote,
|
||||
release: &'a dyn AssetFetcher,
|
||||
@@ -482,13 +408,6 @@ fn installer<'a>(
|
||||
.with_timeouts(Duration::from_millis(200), Duration::from_millis(10))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The happy path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// All six steps on a machine that has never seen tty7: identify it, find
|
||||
/// nothing installed, download and verify, ask once, publish atomically, and
|
||||
/// launch a daemon.
|
||||
#[test]
|
||||
fn first_install_runs_all_six_steps() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -519,7 +438,6 @@ fn first_install_runs_all_six_steps() {
|
||||
"the temp name is consumed by the rename"
|
||||
);
|
||||
|
||||
// Both release artifacts were fetched from the same tag.
|
||||
assert_eq!(
|
||||
release.fetched(),
|
||||
vec![
|
||||
@@ -529,10 +447,6 @@ fn first_install_runs_all_six_steps() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Atomic replacement.** The final path must only ever be produced by
|
||||
/// renaming a temp that is *already* executable — never written to directly,
|
||||
/// and never chmod'ed after it is visible. Both would leave a window in which a
|
||||
/// concurrent connect finds `tty7-server-c<c>p<p>` present and unusable.
|
||||
#[test]
|
||||
fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -544,7 +458,6 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() {
|
||||
|
||||
let writes = remote.writes();
|
||||
|
||||
// Nothing writes the final path directly.
|
||||
assert!(
|
||||
!writes
|
||||
.iter()
|
||||
@@ -580,8 +493,6 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The directory chain is created outermost-first (SFTP has no `mkdir -p`) and
|
||||
/// the directory that holds the binaries ends up 0700.
|
||||
#[test]
|
||||
fn the_install_directory_is_created_in_order_and_locked_down() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -611,14 +522,6 @@ fn the_install_directory_is_created_in_order_and_locked_down() {
|
||||
assert_eq!(remote.file(BIN_DIR).unwrap().mode, 0o700);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sha256 — the failure path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **A checksum mismatch aborts and writes nothing.** Not a retry, not an
|
||||
/// unverified install, not a partially-written temp left behind: the remote
|
||||
/// filesystem must be untouched, and the user must not even have been asked
|
||||
/// (there is nothing to consent to).
|
||||
#[test]
|
||||
fn a_sha256_mismatch_aborts_before_touching_the_remote() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -656,13 +559,10 @@ fn a_sha256_mismatch_aborts_before_touching_the_remote() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A release with no line for our asset is the same class of failure: stop,
|
||||
/// do not install something unverified.
|
||||
#[test]
|
||||
fn a_release_missing_our_asset_aborts() {
|
||||
let remote = FakeRemote::new();
|
||||
let mut release = FakeRelease::new();
|
||||
// Manifest describes a payload nobody serves, under a different name.
|
||||
release.manifest_of = b"unrelated".to_vec();
|
||||
let user = FakeUser::approving();
|
||||
|
||||
@@ -673,12 +573,6 @@ fn a_release_missing_our_asset_aborts() {
|
||||
assert!(remote.writes().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The prompt has to carry everything it must say: which path, how big,
|
||||
/// and where the bytes came from.
|
||||
#[test]
|
||||
fn the_confirmation_states_path_size_and_origin() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -708,8 +602,6 @@ fn the_confirmation_states_path_size_and_origin() {
|
||||
assert_eq!(request.version, VERSION);
|
||||
}
|
||||
|
||||
/// Declining writes nothing and says so. The bytes were already downloaded and
|
||||
/// verified by then; that is fine, they never left the client.
|
||||
#[test]
|
||||
fn declining_installs_nothing() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -728,9 +620,6 @@ fn declining_installs_nothing() {
|
||||
assert!(remote.file(BINARY).is_none());
|
||||
}
|
||||
|
||||
/// **With no UI attached the default is to refuse, not to proceed.** A daemon
|
||||
/// running headless must not decide on the user's behalf that writing binaries
|
||||
/// to their servers is acceptable.
|
||||
#[test]
|
||||
fn the_default_confirmation_declines() {
|
||||
let request = InstallRequest {
|
||||
@@ -749,13 +638,11 @@ fn the_default_confirmation_declines() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A machine tty7 has already written to is upgraded silently — the consent was
|
||||
/// about "may tty7 put binaries here", and it was given.
|
||||
#[test]
|
||||
fn upgrading_a_known_machine_does_not_ask_again() {
|
||||
let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4");
|
||||
let release = FakeRelease::new();
|
||||
let user = FakeUser::declining(); // would refuse if asked
|
||||
let user = FakeUser::declining();
|
||||
|
||||
let report = installer(&remote, &release, &user, "me@known-box:22")
|
||||
.run()
|
||||
@@ -767,18 +654,10 @@ fn upgrading_a_known_machine_does_not_ask_again() {
|
||||
user.asked().is_empty(),
|
||||
"no prompt on a machine we already use"
|
||||
);
|
||||
// The old binary is still there: one file per dialect, and the older one may
|
||||
// still be the one a running daemon was exec'd from.
|
||||
assert!(remote.file(&legacy).is_some());
|
||||
assert!(remote.file(BINARY).is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skipping work.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The common path: the right version is already installed and a daemon is
|
||||
/// serving. No download, no prompt, no write, no launch.
|
||||
#[test]
|
||||
fn an_up_to_date_machine_downloads_nothing() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -798,10 +677,6 @@ fn an_up_to_date_machine_downloads_nothing() {
|
||||
assert!(remote.writes().is_empty());
|
||||
}
|
||||
|
||||
/// A binary that is present but not executable is a crashed install (the rename
|
||||
/// landed, the chmod did not). Reinstalling beats launching something the kernel
|
||||
/// will refuse with `Exec format error`'s equally opaque cousin, `Permission
|
||||
/// denied`.
|
||||
#[test]
|
||||
fn a_present_but_unexecutable_binary_is_reinstalled() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -817,12 +692,6 @@ fn a_present_but_unexecutable_binary_is_reinstalled() {
|
||||
assert_eq!(remote.file(BINARY).unwrap().mode, 0o755);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refusals and write failures.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An architecture we do not publish for is refused before anything is
|
||||
/// downloaded or written, and the message quotes the machine string verbatim.
|
||||
#[test]
|
||||
fn an_unsupported_machine_is_refused_before_any_work() {
|
||||
for (uname, expect_linux) in [("Linux armv7l", true), ("Darwin arm64", false)] {
|
||||
@@ -850,9 +719,6 @@ fn an_unsupported_machine_is_refused_before_any_work() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A failed remote write reports the path and the server's reason, and is not
|
||||
/// retried anywhere else**. A full disk must not become "let me try
|
||||
/// /tmp".
|
||||
#[test]
|
||||
fn a_failed_write_names_the_path_and_does_not_fall_back() {
|
||||
let mut remote = FakeRemote::new();
|
||||
@@ -878,7 +744,6 @@ fn a_failed_write_names_the_path_and_does_not_fall_back() {
|
||||
assert!(message.contains(&temp()), "{message}");
|
||||
assert!(message.contains("no space left"), "{message}");
|
||||
|
||||
// One attempt at one path. No second put, no alternative directory.
|
||||
let puts: Vec<_> = remote
|
||||
.journal()
|
||||
.into_iter()
|
||||
@@ -888,8 +753,6 @@ fn a_failed_write_names_the_path_and_does_not_fall_back() {
|
||||
assert!(remote.file(BINARY).is_none());
|
||||
}
|
||||
|
||||
/// A download failure names the URL, so "which release did it even look for" is
|
||||
/// answerable from the message alone.
|
||||
#[test]
|
||||
fn a_download_failure_names_the_url() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -907,12 +770,6 @@ fn a_download_failure_names_the_url() {
|
||||
assert!(remote.writes().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 6: the daemon.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Nothing serving → launch, then confirm by re-probing rather than by trusting
|
||||
/// the shell's exit status.
|
||||
#[test]
|
||||
fn a_daemon_is_launched_when_the_socket_answers_nothing() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -941,8 +798,6 @@ fn a_daemon_is_launched_when_the_socket_answers_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A binary that will not stay up fails with a message naming it, rather than
|
||||
/// leaving the caller to discover it on the first frame.
|
||||
#[test]
|
||||
fn a_daemon_that_never_answers_is_an_error() {
|
||||
let mut remote = FakeRemote::new();
|
||||
@@ -960,10 +815,6 @@ fn a_daemon_that_never_answers_is_an_error() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Dialect mismatch: keep the old daemon, record the mismatch.** It owns every
|
||||
/// live pane on that machine; ending them at connect time is the user's call,
|
||||
/// not the installer's — exactly as `spawn::ensure_running` treats the local
|
||||
/// daemon.
|
||||
#[test]
|
||||
fn an_older_running_daemon_is_kept_and_reported() {
|
||||
let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4");
|
||||
@@ -988,7 +839,6 @@ fn an_older_running_daemon_is_kept_and_reported() {
|
||||
assert_eq!(mismatch.running_version.as_deref(), Some("26.7.4"));
|
||||
assert_eq!(mismatch.wanted_version, VERSION);
|
||||
|
||||
// And it reaches the GUI's take-once queue.
|
||||
let queued = take_mismatched_remote_daemons();
|
||||
assert!(
|
||||
queued.iter().any(|m| m.host == "me@mismatch-box:22"),
|
||||
@@ -996,9 +846,6 @@ fn an_older_running_daemon_is_kept_and_reported() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A daemon we cannot identify (no readable `/proc`, a hand-placed binary) is
|
||||
/// not a mismatch. Having no opinion must never be reported as a disagreement,
|
||||
/// or every locked-down container would prompt on connect.
|
||||
#[test]
|
||||
fn an_unidentifiable_running_daemon_is_not_a_mismatch() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -1013,7 +860,6 @@ fn an_unidentifiable_running_daemon_is_not_a_mismatch() {
|
||||
assert!(report.mismatch.is_none());
|
||||
}
|
||||
|
||||
/// Restart is the other branch of the prompt: stop what is running, start ours.
|
||||
#[test]
|
||||
fn restart_replaces_the_running_daemon() {
|
||||
let remote = FakeRemote::new()
|
||||
@@ -1037,13 +883,6 @@ fn restart_replaces_the_running_daemon() {
|
||||
assert!(*remote.daemon_running.lock().unwrap());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote command construction.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The launch detaches the daemon from the SSH session and gives it no stream to
|
||||
/// hold open. Without either half, closing the channel would kill it (SIGHUP to
|
||||
/// the session's group) or the channel would never close (inherited stdout).
|
||||
#[test]
|
||||
fn the_launch_command_detaches_and_closes_every_stream() {
|
||||
let cmd = launch_command("/home/me/.local/share/tty7/bin/tty7-server-26.7.5");
|
||||
@@ -1061,12 +900,6 @@ fn the_launch_command_detaches_and_closes_every_stream() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport's settle **follows** the launch; it never replaces it. Cheap to
|
||||
/// get wrong in a `format!` and expensive to notice, because a daemon that was
|
||||
/// never launched fails exactly like one that died right after being launched.
|
||||
///
|
||||
/// And a transport that asks for nothing — every one but WSL — gets the launch
|
||||
/// line by itself, with no trailing newline to change what the shell reads.
|
||||
#[test]
|
||||
fn a_launch_settle_follows_the_launch_and_never_replaces_it() {
|
||||
let plain = launch_script(BINARY, None);
|
||||
@@ -1084,9 +917,6 @@ fn a_launch_settle_follows_the_launch_and_never_replaces_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every command interpolates a remote path, and home directories with spaces
|
||||
/// or apostrophes exist. Unquoted, `/home/o'brien/...` would end the string
|
||||
/// mid-path and run whatever followed.
|
||||
#[test]
|
||||
fn remote_paths_are_shell_quoted() {
|
||||
assert_eq!(shell_quote("/home/me/bin"), "'/home/me/bin'");
|
||||
@@ -1095,10 +925,6 @@ fn remote_paths_are_shell_quoted() {
|
||||
"'/home/my box/tty7-server'"
|
||||
);
|
||||
assert_eq!(shell_quote("/home/o'brien/x"), r"'/home/o'\''brien/x'");
|
||||
// A path that tries to break out stays one argument. The invariant that
|
||||
// makes it safe: after the outer quotes, every remaining `'` belongs to a
|
||||
// `'\''` escape — so there is no point at which the shell is outside a
|
||||
// quoted string and could see `;` as a separator.
|
||||
let quoted = shell_quote("/tmp/x'; rm -rf ~; echo '");
|
||||
let inner = quoted
|
||||
.strip_prefix('\'')
|
||||
@@ -1110,34 +936,19 @@ fn remote_paths_are_shell_quoted() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The launch command embeds a quoted path, so a hostile-looking home directory
|
||||
/// cannot turn into a second command.
|
||||
#[test]
|
||||
fn the_launch_command_quotes_its_binary() {
|
||||
let cmd = launch_command("/home/me/a b/tty7-server-1.0.0");
|
||||
assert!(cmd.contains("'/home/me/a b/tty7-server-1.0.0'"), "{cmd}");
|
||||
}
|
||||
|
||||
/// The `/proc` sweep must survive a machine with no tty7-server running (the
|
||||
/// common case) without the loop's failure becoming the command's — a `set -e`
|
||||
/// login shell would otherwise report the probe as a broken connection.
|
||||
#[test]
|
||||
fn the_running_exe_probe_cannot_fail_the_command() {
|
||||
assert!(RUNNING_EXE_COMMAND.trim_end().ends_with("true"));
|
||||
assert!(TERMINATE_RUNNING_COMMAND.trim_end().ends_with("true"));
|
||||
// It looks only at our own install shape, so it can never terminate
|
||||
// something that merely happens to mention tty7.
|
||||
assert!(TERMINATE_RUNNING_COMMAND.contains("*/tty7-server-*"));
|
||||
}
|
||||
|
||||
// `connection_label` is now `ConnectionKey::as_str()` verbatim, so what used to
|
||||
// be tested here — peeling the label out of the derived `Debug` — no longer
|
||||
// exists. The key's own construction (including the jump chain, which is what
|
||||
// keeps two hosts behind different bastions from sharing a label) is covered by
|
||||
// `daemon::ssh::tests`, next to the `base_spec()` helper that builds one.
|
||||
|
||||
/// `ExecOutput`'s failure summary prefers what the remote said over a bare
|
||||
/// number, because "Permission denied" is actionable and "exit status 1" is not.
|
||||
#[test]
|
||||
fn exec_failures_quote_stderr_when_there_is_any() {
|
||||
let with_stderr = ExecOutput {
|
||||
@@ -1163,13 +974,6 @@ fn exec_failures_quote_stderr_when_there_is_any() {
|
||||
assert!(!killed.success());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `BundledOrRelease` — installing from a local copy instead of a release.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// With no bundle configured this is the release download, unchanged. Pinned
|
||||
/// because it is the path every ordinary user takes, and the whole feature is
|
||||
/// only acceptable if it is inert until asked for.
|
||||
#[test]
|
||||
fn without_a_bundle_the_source_is_the_plain_download() {
|
||||
let release = FakeRelease::new();
|
||||
@@ -1186,9 +990,6 @@ fn without_a_bundle_the_source_is_the_plain_download() {
|
||||
);
|
||||
}
|
||||
|
||||
/// With one, the bytes come off the disk and **nothing is fetched** — which is
|
||||
/// the point on an air-gapped client, behind a TLS-intercepting proxy, or on
|
||||
/// any build with no published release (every developer build).
|
||||
#[test]
|
||||
fn a_bundle_is_used_instead_of_downloading() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-bundle-src-{}", std::process::id()));
|
||||
@@ -1215,10 +1016,6 @@ fn a_bundle_is_used_instead_of_downloading() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A configured directory that lacks *this* asset fails, and does **not**
|
||||
/// quietly download instead. Someone who pointed at a directory meant to
|
||||
/// install from it; silently reaching for the network would defeat whichever
|
||||
/// reason they had — and on an air-gapped box it would fail far from the cause.
|
||||
#[test]
|
||||
fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-bundle-empty-{}", std::process::id()));
|
||||
@@ -1243,18 +1040,8 @@ fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The path the installer publishes to is **absolute and dialect-qualified**,
|
||||
/// and that is what the session-channel fallback has to exec.
|
||||
///
|
||||
/// Observed for real: the transport exec'd the bare name `tty7-server`, which
|
||||
/// is a `command not found` on a machine where the install had just succeeded —
|
||||
/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the
|
||||
/// file there is not even called `tty7-server`. The remote process died at
|
||||
/// once, taking the pane with it.
|
||||
#[test]
|
||||
fn the_published_path_is_absolute_and_dialect_qualified() {
|
||||
// Built from *this crate's* dialects rather than the fixture's, because
|
||||
// what the transport execs is whatever this build currently names.
|
||||
let real = RemoteProtocol::of_this_build();
|
||||
let published = asset::remote_paths(HOME, real.control, real.protocol).binary;
|
||||
assert!(
|
||||
@@ -1285,12 +1072,6 @@ fn the_published_path_is_absolute_and_dialect_qualified() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress (an 8 MB first install must not look like a hang).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Records every report in order, which is what makes "monotonic" and "reaches
|
||||
/// the total" testable — neither is a property of any single report.
|
||||
#[derive(Default)]
|
||||
struct Reports(Mutex<Vec<(String, InstallPhase)>>);
|
||||
|
||||
@@ -1310,7 +1091,6 @@ impl Reports {
|
||||
}
|
||||
}
|
||||
|
||||
/// A release whose asset arrives in pieces, like a real HTTP body.
|
||||
struct ChunkedRelease {
|
||||
inner: FakeRelease,
|
||||
chunks: usize,
|
||||
@@ -1338,11 +1118,6 @@ impl AssetFetcher for ChunkedRelease {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Both halves of the wait are reported, and each one finishes.**
|
||||
///
|
||||
/// The download and the upload are separate network hops of the same ~8 MB, and
|
||||
/// a bar that covered only one of them would sit at 100% through the other —
|
||||
/// which is the exact failure this exists to prevent.
|
||||
#[test]
|
||||
fn an_install_reports_both_transfers_to_completion() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -1396,8 +1171,6 @@ fn an_install_reports_both_transfers_to_completion() {
|
||||
"the upload reaches the byte count the consent prompt quoted"
|
||||
);
|
||||
|
||||
// Order matters: the client cannot push bytes it has not fetched, and a UI
|
||||
// that saw them interleaved would have to decide which one to draw.
|
||||
let first_upload = phases
|
||||
.iter()
|
||||
.position(|p| matches!(p, InstallPhase::Uploading { .. }))
|
||||
@@ -1412,11 +1185,6 @@ fn an_install_reports_both_transfers_to_completion() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every report names the machine it is about.**
|
||||
///
|
||||
/// The GUI keys its progress slots by machine, so a report that arrived with the
|
||||
/// wrong label — or an empty one — would paint one box's bytes under another's
|
||||
/// name while both were installing.
|
||||
#[test]
|
||||
fn every_report_carries_the_host() {
|
||||
let remote = FakeRemote::new();
|
||||
@@ -1440,11 +1208,6 @@ fn every_report_carries_the_host() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **An install that is already present reports nothing.**
|
||||
///
|
||||
/// The common path — a machine tty7 has installed to before — does no transfer
|
||||
/// at all, and a bar that flashed on every connect would train the user to
|
||||
/// ignore it on the one connect where it means something.
|
||||
#[test]
|
||||
fn a_present_binary_reports_no_progress() {
|
||||
let remote = FakeRemote::new().with_previous_install();
|
||||
@@ -1465,11 +1228,6 @@ fn a_present_binary_reports_no_progress() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The scoped sink outranks the global one, and is put back afterwards.**
|
||||
///
|
||||
/// Same contract as `with_install_confirm`, and it matters for the same reason:
|
||||
/// in the daemon each routed connection has its own client, and a global would
|
||||
/// send one machine's byte counts to the other machine's window.
|
||||
#[test]
|
||||
fn a_scoped_progress_sink_outranks_the_global_one() {
|
||||
let scoped = Arc::new(Reports::default());
|
||||
@@ -1489,11 +1247,6 @@ fn a_scoped_progress_sink_outranks_the_global_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`fraction` is safe to hand straight to a layout.**
|
||||
///
|
||||
/// It feeds a width, so anything outside `0.0..=1.0` draws a bar that overflows
|
||||
/// its track or inverts it. A zero or absent total is the interesting case: it
|
||||
/// means "unknown", not "zero percent", and the caller has to be able to tell.
|
||||
#[test]
|
||||
fn a_fraction_is_either_absent_or_in_range() {
|
||||
assert_eq!(
|
||||
@@ -1529,11 +1282,6 @@ fn a_fraction_is_either_absent_or_in_range() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialects, not build strings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What this client speaks, which is what a remote has to match.
|
||||
fn ours() -> RemoteProtocol {
|
||||
RemoteProtocol {
|
||||
control: CONTROL,
|
||||
@@ -1543,18 +1291,8 @@ fn ours() -> RemoteProtocol {
|
||||
}
|
||||
|
||||
const OTHER_BUILD: &str = "26.7.9-nightly.20260801";
|
||||
/// A server installed by a client that named files after *versions* — every
|
||||
/// binary already sitting on a user's machine when this naming shipped. Its path
|
||||
/// claims no dialect, so it can only be adopted by being asked.
|
||||
const OTHER_EXE: &str = "/home/me/.local/share/tty7/bin/tty7-server-26.7.9-nightly.20260801";
|
||||
|
||||
/// **A newer server this client can talk to is adopted, not overwritten.**
|
||||
///
|
||||
/// The scene from the field: a `26.7.6` client meets a machine already serving
|
||||
/// `26.7.7-nightly`, both speaking the same dialects. Before this, the client
|
||||
/// stat'ed for its *own* version, missed, uploaded 8 MB nobody needed, and then
|
||||
/// asked the user to choose between keeping their sessions and restarting a
|
||||
/// server that was working fine.
|
||||
#[test]
|
||||
fn a_compatible_running_server_is_reused_without_installing() {
|
||||
let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
|
||||
@@ -1600,11 +1338,6 @@ fn a_compatible_running_server_is_reused_without_installing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A server speaking a different dialect is still installed over.**
|
||||
///
|
||||
/// The other half of the same judgement — adoption is not a blanket "reuse
|
||||
/// whatever is there". A control dialect we cannot speak is exactly what the
|
||||
/// prompt exists for.
|
||||
#[test]
|
||||
fn an_incompatible_running_server_is_not_adopted() {
|
||||
let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
|
||||
@@ -1634,11 +1367,6 @@ fn an_incompatible_running_server_is_not_adopted() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The pane dialect counts too, not just the control one.**
|
||||
///
|
||||
/// A remote workspace uses both: control for the workspace, the pane protocol
|
||||
/// for every terminal in it. Matching one and not the other would open the
|
||||
/// workspace and then fail on the first pane.
|
||||
#[test]
|
||||
fn a_matching_control_dialect_is_not_enough_on_its_own() {
|
||||
let remote = FakeRemote::new().serving(OTHER_EXE).speaking(
|
||||
@@ -1663,14 +1391,8 @@ fn a_matching_control_dialect_is_not_enough_on_its_own() {
|
||||
assert!(report.reused.is_none());
|
||||
}
|
||||
|
||||
/// **A server too old to answer `--protocol` is handled exactly as before.**
|
||||
///
|
||||
/// It predates the flag, so it exits non-zero; we learn nothing, and "nothing
|
||||
/// learnt" has to keep meaning "install ours and let the user decide", never
|
||||
/// "assume it is fine".
|
||||
#[test]
|
||||
fn a_server_that_cannot_be_probed_is_installed_over() {
|
||||
// `.serving` without `.speaking`: the probe fails.
|
||||
let remote = FakeRemote::new().serving(OTHER_EXE);
|
||||
let release = FakeRelease::new();
|
||||
let user = FakeUser::approving();
|
||||
@@ -1687,10 +1409,6 @@ fn a_server_that_cannot_be_probed_is_installed_over() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Our own version already installed still short-circuits everything.**
|
||||
///
|
||||
/// The fast path must not have grown a probe: a machine we have installed on
|
||||
/// before should cost a `stat` and nothing more.
|
||||
#[test]
|
||||
fn the_matching_version_still_costs_no_probe() {
|
||||
let remote = FakeRemote::new().with_previous_install().serving(BINARY);
|
||||
@@ -1713,11 +1431,6 @@ fn the_matching_version_still_costs_no_probe() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`serves` is symmetric in neither direction by accident — it is equality.**
|
||||
///
|
||||
/// Written down because "newer can serve older" is the tempting wrong rule, and
|
||||
/// the failure it produces (a wire error mid-session, long after the connect)
|
||||
/// is far worse than the prompt it avoids.
|
||||
#[test]
|
||||
fn only_identical_dialects_serve() {
|
||||
let base = ours();
|
||||
@@ -1746,10 +1459,6 @@ fn only_identical_dialects_serve() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The probe's output survives a chatty login shell.**
|
||||
///
|
||||
/// `.bashrc` on a shared box prints banners, `direnv` prints exports, and all of
|
||||
/// it lands on the same stdout the JSON does.
|
||||
#[test]
|
||||
fn a_noisy_shell_does_not_break_the_probe() {
|
||||
let spoken = ours();
|
||||
@@ -1767,18 +1476,6 @@ fn a_noisy_shell_does_not_break_the_probe() {
|
||||
assert_eq!(RemoteProtocol::parse("not json at all"), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The name is a promise, and it is checked before it is published.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Bytes that speak the wrong dialect are never published.**
|
||||
///
|
||||
/// The whole naming scheme rests on `tty7-server-c<c>p<p>` really speaking
|
||||
/// c/p, and nothing upstream of the upload can guarantee that: a
|
||||
/// `TTY7_BUNDLED_SERVER_DIR` can hold a stale cross-compile, and a release tag
|
||||
/// can predate a wire break. Publishing anyway writes a file that lies, and the
|
||||
/// *next* connect trusts the name, skips the install, and dies in the handshake
|
||||
/// with nothing to blame.
|
||||
#[test]
|
||||
fn an_upload_that_speaks_the_wrong_dialect_is_not_published() {
|
||||
let remote = FakeRemote::new().uploads_speaking(Some(RemoteProtocol {
|
||||
@@ -1815,12 +1512,6 @@ fn an_upload_that_speaks_the_wrong_dialect_is_not_published() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Bytes that cannot answer at all are refused the same way.**
|
||||
///
|
||||
/// A binary for the wrong architecture cannot exec, so it cannot answer. This
|
||||
/// is the first moment that mistake can surface as itself; without the check it
|
||||
/// used to reach a daemon launch and die as `Exec format error`, which names
|
||||
/// nothing about `uname`.
|
||||
#[test]
|
||||
fn an_upload_that_cannot_answer_is_not_published() {
|
||||
let remote = FakeRemote::new().uploads_speaking(None);
|
||||
@@ -1838,11 +1529,6 @@ fn an_upload_that_cannot_answer_is_not_published() {
|
||||
assert!(remote.file(BINARY).is_none());
|
||||
}
|
||||
|
||||
/// **A dialect already installed is reused without downloading or asking.**
|
||||
///
|
||||
/// The hot path, stated as a cost: one `stat` of a path built from this
|
||||
/// client's own two numbers, and no network at all — which is what has to hold
|
||||
/// on a machine that cannot reach GitHub.
|
||||
#[test]
|
||||
fn a_machine_with_our_dialect_installed_costs_nothing() {
|
||||
let remote = FakeRemote::new().with_previous_install().serving(BINARY);
|
||||
@@ -1859,16 +1545,9 @@ fn a_machine_with_our_dialect_installed_costs_nothing() {
|
||||
assert!(release.fetched().is_empty(), "{:?}", release.fetched());
|
||||
}
|
||||
|
||||
/// **A different build behind our dialect is used as-is.**
|
||||
///
|
||||
/// The deliberate limit of the whole scheme: dialects decide whether a connect
|
||||
/// works, and "is this the build I just compiled" is a different question that
|
||||
/// must not cost an 8 MB upload on every connect. Someone else's install, or an
|
||||
/// older client's, serves us fine.
|
||||
#[test]
|
||||
fn another_build_at_our_dialect_is_used_rather_than_replaced() {
|
||||
let remote = FakeRemote::new().with_previous_install().serving(BINARY);
|
||||
// Same file, same dialect, a build string from a different release.
|
||||
let remote = remote.speaking(
|
||||
BINARY,
|
||||
RemoteProtocol {
|
||||
@@ -1890,15 +1569,9 @@ fn another_build_at_our_dialect_is_used_rather_than_replaced() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A legacy version-named binary is not adopted on the strength of its name.**
|
||||
///
|
||||
/// Every machine tty7 had already installed on carries one. The name claims no
|
||||
/// dialect, so the only honest thing to do is ask — and if it cannot answer,
|
||||
/// install ours beside it.
|
||||
#[test]
|
||||
fn a_legacy_named_binary_is_probed_not_assumed() {
|
||||
let (remote, legacy) = FakeRemote::new().with_legacy_install(VERSION);
|
||||
// It answers, and it happens to speak our dialects: adopt it, no upload.
|
||||
let remote = remote.serving(&legacy).speaking(
|
||||
&legacy,
|
||||
RemoteProtocol {
|
||||
@@ -1925,12 +1598,6 @@ fn a_legacy_named_binary_is_probed_not_assumed() {
|
||||
assert!(report.mismatch.is_none());
|
||||
}
|
||||
|
||||
/// **The staging name is private to this process.**
|
||||
///
|
||||
/// One file per dialect means the shared temp name is the same string for every
|
||||
/// client installing that dialect, so two of them at once would interleave
|
||||
/// their bytes into it. The published name stays shared — `rename` is still
|
||||
/// what makes an install visible.
|
||||
#[test]
|
||||
fn the_staging_path_carries_the_pid() {
|
||||
let staged = temp();
|
||||
@@ -1948,17 +1615,6 @@ fn the_staging_path_carries_the_pid() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Replace Server" — the way out of a handshake this client lost.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **A good binary already at our path is restarted onto, not re-downloaded.**
|
||||
///
|
||||
/// The state `run` leaves behind every time it refuses to kill a daemon that
|
||||
/// owns live panes: our binary published, an older one still serving. It is the
|
||||
/// common case behind the handshake error, and the button that offers to fix it
|
||||
/// must not need a network — least of all a released asset speaking a dialect
|
||||
/// that, for any build between releases, does not exist yet.
|
||||
#[test]
|
||||
fn replacing_reuses_a_published_binary_that_already_serves_us() {
|
||||
let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4");
|
||||
@@ -2000,16 +1656,9 @@ fn replacing_reuses_a_published_binary_that_already_serves_us() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A binary whose name lies is overwritten.**
|
||||
///
|
||||
/// The other reason a handshake fails against a path this client trusts:
|
||||
/// something outside tty7 put a file there. `run` cannot catch it — it trusts
|
||||
/// the name, which is what makes the connect cheap — so this is the only thing
|
||||
/// that does.
|
||||
#[test]
|
||||
fn replacing_overwrites_a_published_binary_that_does_not_serve_us() {
|
||||
let remote = FakeRemote::new().with_previous_install();
|
||||
// Someone replaced it: the name says our dialect, the bytes disagree.
|
||||
let remote = remote.speaking(
|
||||
BINARY,
|
||||
RemoteProtocol {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,3 @@
|
||||
//! Persistent terminal daemon: keeps PTYs + their child processes alive across
|
||||
//! GUI restarts (tmux-style detach/reattach), with the GUI acting as a thin
|
||||
//! client over a Unix-domain socket.
|
||||
//!
|
||||
//! Layout:
|
||||
//! - [`protocol`] — the framed wire messages shared by client and daemon.
|
||||
//! - [`control`] — the *control* dialect: the same framing, but multiplexed by
|
||||
//! request id, carrying the filesystem/git RPCs a remote workspace runs
|
||||
//! against a machine that isn't this one.
|
||||
//! - [`transport`] — the cross-platform local stream the protocol rides on
|
||||
//! (Unix-domain socket on Unix, loopback TCP on Windows).
|
||||
//! - `pane` (daemon side) — owns one PTY/child, a replay ring, and fan-out.
|
||||
//! - `server` (daemon side) — the listener, pane registry, `--daemon`
|
||||
//! entry point.
|
||||
//! - `spawn` — endpoint resolution + auto-launching the daemon from the GUI.
|
||||
//! - [`pidfile`] — the daemon's pid marker, letting takeover paths in `spawn`
|
||||
//! reap a live-but-unreachable daemon instead of stranding it.
|
||||
//! - [`shell_integration`] — builds the throwaway `ZDOTDIR` (plus the bash/fish
|
||||
//! equivalents) whose rc files emit OSC 7 / OSC 133. Lives here because the
|
||||
//! PTY-owning `pane` is the sole injector; keeping it beside its only caller
|
||||
//! is what lets `daemon` avoid depending back on `terminal`.
|
||||
//!
|
||||
//! The client-side terminal that talks this protocol lives in
|
||||
//! `terminal::remote::RemoteTerminal`, exposing the same surface as the old
|
||||
//! in-process `Terminal` so the view layer is largely unchanged.
|
||||
|
||||
pub mod control;
|
||||
pub mod duplex;
|
||||
pub mod install;
|
||||
@@ -36,18 +10,12 @@ pub mod remote_link;
|
||||
pub mod router;
|
||||
pub mod server;
|
||||
pub mod spawn;
|
||||
/// Native (russh) SSH session engine — see the module docs.
|
||||
pub mod ssh;
|
||||
pub mod transport;
|
||||
|
||||
pub(crate) const DETECTED_SHELL_ENV: &str = "TTY7_DETECTED_SHELL";
|
||||
|
||||
// `pub(crate)` rather than private so a future non-daemon spawn path could reuse
|
||||
// the exact same rc-file setup; today `pane` is the only caller.
|
||||
pub(crate) mod shell_integration;
|
||||
|
||||
// Windows process-table helpers (foreground-command title + descendant teardown).
|
||||
// Windows-only: the Unix path gets the same information from the pty's foreground
|
||||
// process group and signals.
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod winproc;
|
||||
|
||||
+11
-1439
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,11 @@
|
||||
//! The daemon's pid marker: `<config>/daemon.pid`, written after a successful
|
||||
//! `bind` and removed on shutdown.
|
||||
//!
|
||||
//! The endpoint marker (socket / port file) answers "is something listening
|
||||
//! *here*?", but says nothing about *which process* — and that gap is exactly
|
||||
//! how daemons got stranded (see the takeover paths in `spawn`): a client that
|
||||
//! couldn't talk to the old daemon would unlink its endpoint and start a fresh
|
||||
//! one, leaving the old process alive, unreachable, and still holding every
|
||||
//! pane's PTY + children. The pidfile closes the gap: takeover paths read it
|
||||
//! and reap the recorded process before claiming the endpoint.
|
||||
//!
|
||||
//! A pidfile can outlive its daemon (crash, SIGKILL), and pids get recycled —
|
||||
//! so readers must never trust it blindly. `spawn::reap_recorded_daemon`
|
||||
//! verifies the pid's executable basename matches our own before signalling.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::config;
|
||||
|
||||
/// Path of the pidfile for this process's config dir. `None` only when the
|
||||
/// config dir can't be resolved (no `$HOME`).
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
config::config_path("daemon.pid")
|
||||
}
|
||||
|
||||
/// Record the current process as the daemon serving this config dir. Best
|
||||
/// effort: the pidfile is a rescue marker, not a correctness requirement, so a
|
||||
/// failed write must not take the daemon down — it just means a future
|
||||
/// takeover can't reap us and falls back to today's behavior.
|
||||
pub fn write_current() {
|
||||
let Some(path) = path() else { return };
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -37,14 +16,11 @@ pub fn write_current() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded daemon pid, if the pidfile exists and parses. Says nothing
|
||||
/// about whether that process is still alive or still a tty7 daemon.
|
||||
pub fn read() -> Option<u32> {
|
||||
let contents = std::fs::read_to_string(path()?).ok()?;
|
||||
contents.trim().parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Remove the pidfile. Best effort: a missing file is fine.
|
||||
pub fn remove() {
|
||||
if let Some(path) = path() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
@@ -55,18 +31,12 @@ pub fn remove() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pin the process config dir so the pidfile lives under a temp dir, never
|
||||
/// the real `~/.config`. First-call-wins across the whole test binary, so
|
||||
/// use the same directory the other IO tests pin.
|
||||
fn pin_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
config::set_config_dir(dir);
|
||||
}
|
||||
|
||||
/// One test drives the whole lifecycle — write → read → remove → reject
|
||||
/// garbage — so the shared `daemon.pid` file isn't raced by parallel tests
|
||||
/// (same reason transport's endpoint test is a single lifecycle).
|
||||
#[test]
|
||||
fn pidfile_lifecycle_round_trips_clears_and_rejects_garbage() {
|
||||
pin_config_dir();
|
||||
@@ -74,11 +44,8 @@ mod tests {
|
||||
assert_eq!(read(), Some(std::process::id()));
|
||||
remove();
|
||||
assert_eq!(read(), None, "no pid after removal");
|
||||
// Removing again is harmless.
|
||||
remove();
|
||||
|
||||
// A corrupt file (partial write, hand-edited) must read as "no pid",
|
||||
// never panic or misparse.
|
||||
std::fs::write(path().unwrap(), "not-a-pid\n").unwrap();
|
||||
assert_eq!(read(), None);
|
||||
remove();
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
//! What a pane is *running*: the process tree under its shell, and the TCP ports
|
||||
//! that tree is listening on. Feeds the GUI's details panel (`QueryProcs`).
|
||||
//!
|
||||
//! Everything here is best-effort and read-only. A pid can exit between the
|
||||
//! table walk and the name lookup, `lsof` may be missing, `/proc` may be
|
||||
//! unreadable — each of those degrades to a shorter list, never an error. The
|
||||
//! panel showing one fewer row is a non-event; a details query that can fail is
|
||||
//! a support burden.
|
||||
//!
|
||||
//! Called on demand from the details panel, not on a timer — see the doc on
|
||||
//! [`ClientMsg::QueryProcs`](crate::daemon::protocol::ClientMsg::QueryProcs) for
|
||||
//! why this is pull-based when `Cwd` and `Agent` are pushed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry};
|
||||
|
||||
/// Depth cap on the process walk. Deep trees are real (a shell running `make`
|
||||
/// running a compiler driver running the compiler), but past a handful of hops
|
||||
/// the rows stop being information and start being noise in a 260px column.
|
||||
const MAX_DEPTH: u8 = 6;
|
||||
|
||||
/// Hard cap on rows, so a pane that spawned a thousand workers can't turn a
|
||||
/// details query into a wire-format stress test.
|
||||
const MAX_PROCS: usize = 64;
|
||||
|
||||
/// The process tree under `shell_pid` plus its listening ports. `fg_pgid` is the
|
||||
/// PTY's foreground process group, used to mark the row the user is looking at;
|
||||
/// pass `None` when it isn't known.
|
||||
pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
|
||||
let table = process_table();
|
||||
let procs = walk(&table, shell_pid, fg_pgid);
|
||||
@@ -34,19 +13,13 @@ pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs {
|
||||
PaneProcs { procs, ports }
|
||||
}
|
||||
|
||||
/// One row of the system process table, reduced to what the walk needs.
|
||||
struct Row {
|
||||
ppid: u32,
|
||||
pgid: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Depth-first from the shell, so the caller can render in order and indent by
|
||||
/// `depth` without rebuilding a hierarchy.
|
||||
fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<ProcEntry> {
|
||||
// Children by parent, so the descent is a lookup rather than a table scan
|
||||
// per node. Sorted by pid: the process table's own order is unspecified, and
|
||||
// a list that reshuffles between two refreshes reads as churn.
|
||||
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (pid, row) in table {
|
||||
children.entry(row.ppid).or_default().push(*pid);
|
||||
@@ -72,7 +45,6 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
|
||||
continue;
|
||||
}
|
||||
if let Some(kids) = children.get(&pid) {
|
||||
// Pushed in reverse so the pop order stays ascending by pid.
|
||||
for kid in kids.iter().rev() {
|
||||
stack.push((*kid, depth + 1));
|
||||
}
|
||||
@@ -81,26 +53,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<
|
||||
out
|
||||
}
|
||||
|
||||
// ── Platform: the process table ─────────────────────────────────────────────
|
||||
|
||||
/// macOS: one `proc_listallpids` sweep, then `PROC_PIDTBSDINFO` per pid for
|
||||
/// parent/group. Cheaper than shelling out to `ps`, and it can't be defeated by
|
||||
/// a user's `ps` alias or a locale-dependent column layout.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
let mut table = HashMap::new();
|
||||
// Ask for the count first, then read into a buffer sized from it (plus slack,
|
||||
// since processes can appear between the two calls).
|
||||
// SAFETY: the documented "how big a buffer do I need" form — null buffer,
|
||||
// zero size — which only returns a byte count.
|
||||
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];
|
||||
// SAFETY: buffer and its true byte length; the call writes at most that many
|
||||
// bytes and returns how many it wrote.
|
||||
let written = unsafe {
|
||||
libc::proc_listallpids(
|
||||
pids.as_mut_ptr() as *mut libc::c_void,
|
||||
@@ -117,9 +78,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
}
|
||||
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
|
||||
// SAFETY: zeroed buffer of the expected type, real size passed; the
|
||||
// result is read back only when the kernel filled exactly that many
|
||||
// bytes (a short return means the pid died mid-walk).
|
||||
let ret = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
@@ -132,8 +90,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
if ret != size {
|
||||
continue;
|
||||
}
|
||||
// `pbi_comm` is the kernel's truncated name (16 bytes). Prefer the full
|
||||
// executable basename, which is what the user typed.
|
||||
let name = proc_name(pid).unwrap_or_else(|| cstr_field(&info.pbi_comm));
|
||||
table.insert(
|
||||
pid as u32,
|
||||
@@ -147,7 +103,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
table
|
||||
}
|
||||
|
||||
/// Read a fixed-size, NUL-padded C char array into a `String`.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cstr_field(buf: &[libc::c_char]) -> String {
|
||||
let bytes: Vec<u8> = buf
|
||||
@@ -158,9 +113,6 @@ fn cstr_field(buf: &[libc::c_char]) -> String {
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
/// Linux: `/proc/<pid>/stat` carries ppid and pgid in fixed positions. The
|
||||
/// comm field is parenthesized and may itself contain spaces and parens, so the
|
||||
/// fields after it are located from the *last* `)`, not by splitting the line.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
let mut table = HashMap::new();
|
||||
@@ -182,7 +134,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
continue;
|
||||
};
|
||||
let mut fields = stat[close + 1..].split_whitespace();
|
||||
// After `)`: state, ppid, pgrp, …
|
||||
let (Some(_state), Some(ppid), Some(pgid)) = (fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
@@ -191,7 +142,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
continue;
|
||||
};
|
||||
let name = proc_name(pid as i32).unwrap_or_else(|| {
|
||||
// Fall back to the parenthesized comm already in hand.
|
||||
stat[..close]
|
||||
.rfind('(')
|
||||
.map_or_else(|| String::new(), |open| stat[open + 1..close].to_string())
|
||||
@@ -201,9 +151,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
table
|
||||
}
|
||||
|
||||
/// Windows: reuse the existing toolhelp snapshot. It carries no process-group
|
||||
/// concept, so nothing is ever marked foreground — matching how `foreground_title`
|
||||
/// already treats the platform.
|
||||
#[cfg(windows)]
|
||||
fn process_table() -> HashMap<u32, Row> {
|
||||
crate::daemon::winproc::snapshot()
|
||||
@@ -212,7 +159,6 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
(
|
||||
p.pid,
|
||||
Row {
|
||||
// `winproc::Proc` names the parent link `parent`.
|
||||
ppid: p.parent,
|
||||
pgid: 0,
|
||||
name: p.name,
|
||||
@@ -227,12 +173,9 @@ fn process_table() -> HashMap<u32, Row> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Executable basename of `pid` (macOS).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn proc_name(pid: i32) -> Option<String> {
|
||||
let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most
|
||||
// `buf.len()` bytes and returns the count (<=0 on failure).
|
||||
let ret =
|
||||
unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
|
||||
if ret <= 0 {
|
||||
@@ -242,8 +185,6 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
Some(path.rsplit('/').next().unwrap_or(path).to_string())
|
||||
}
|
||||
|
||||
/// Executable basename of `pid` via `/proc/<pid>/exe` (Linux). Unreadable for
|
||||
/// processes we don't own, hence the caller's `comm` fallback.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_name(pid: i32) -> Option<String> {
|
||||
let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
|
||||
@@ -252,15 +193,6 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
// ── Platform: listening ports ───────────────────────────────────────────────
|
||||
|
||||
/// TCP listeners owned by any pid in `procs`, via `lsof`.
|
||||
///
|
||||
/// Shelling out rather than reading the socket tables directly: on macOS the
|
||||
/// only supported route is a private `libproc` fd walk, and on Linux matching
|
||||
/// `/proc/net/tcp` inodes against every pid's fds costs more syscalls than the
|
||||
/// subprocess. `lsof` ships with macOS; where it's missing this returns empty,
|
||||
/// which just hides the row.
|
||||
#[cfg(unix)]
|
||||
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -273,8 +205,6 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
.map(|p| p.pid.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// `-Fpn`: machine-readable output, pid (`p…`) and name (`n…`) fields only,
|
||||
// one per line. `-nP` skips DNS and /etc/services lookups — both can block.
|
||||
let out = Command::new("lsof")
|
||||
.args([
|
||||
"-nP",
|
||||
@@ -304,8 +234,6 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
let Some(port) = parse_listen_port(rest) else {
|
||||
continue;
|
||||
};
|
||||
// One listener commonly binds both v4 and v6, or several
|
||||
// addresses on the same port; the panel wants the port once.
|
||||
if ports.iter().any(|e| e.port == port && e.pid == current) {
|
||||
continue;
|
||||
}
|
||||
@@ -331,11 +259,8 @@ fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The port out of an `lsof -Fn` name field: `*:3000`, `127.0.0.1:8080`,
|
||||
/// `[::1]:5173`, sometimes with a trailing ` (LISTEN)` despite `-F`.
|
||||
fn parse_listen_port(name: &str) -> Option<u16> {
|
||||
let name = name.split_whitespace().next()?;
|
||||
// Split on the *last* colon: an IPv6 literal is full of them.
|
||||
let (_, port) = name.rsplit_once(':')?;
|
||||
port.parse().ok()
|
||||
}
|
||||
@@ -359,7 +284,6 @@ mod tests {
|
||||
(200, row(100, "make")),
|
||||
(300, row(200, "cc")),
|
||||
(400, row(100, "vim")),
|
||||
// A sibling process outside the shell's tree must not appear.
|
||||
(500, row(1, "Finder")),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -392,9 +316,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn walk_survives_a_cycle_in_the_table() {
|
||||
// Two processes claiming each other as parent — impossible on a live
|
||||
// kernel, but the table is a non-atomic sweep of pids that can be reused
|
||||
// mid-walk, so the descent must terminate regardless.
|
||||
let table: HashMap<u32, Row> = [(100, row(200, "a")), (200, row(100, "b"))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,6 @@ pub(crate) fn parse_ssh_invocation(argv: &[String]) -> Option<SshInvocation> {
|
||||
|
||||
let target = target?;
|
||||
if i < argv.len() {
|
||||
// Remote command present. Do not try to reuse this invocation for `-N`.
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -147,7 +146,6 @@ fn platform_foreground_argv(pid: i32) -> Option<Vec<String>> {
|
||||
}
|
||||
let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
|
||||
let mut len = 0usize;
|
||||
// SAFETY: first sysctl call requests the required buffer length.
|
||||
if unsafe {
|
||||
libc::sysctl(
|
||||
mib.as_mut_ptr(),
|
||||
@@ -163,7 +161,6 @@ fn platform_foreground_argv(pid: i32) -> Option<Vec<String>> {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
// SAFETY: buffer is allocated to the size returned by sysctl above.
|
||||
if unsafe {
|
||||
libc::sysctl(
|
||||
mib.as_mut_ptr(),
|
||||
|
||||
@@ -1,51 +1,3 @@
|
||||
//! [`RemoteLink`] — one logical byte stream from the local daemon to a remote
|
||||
//! `tty7-server`.
|
||||
//!
|
||||
//! ## Where this sits, and why it is not in the GUI
|
||||
//!
|
||||
//! The design's "one more transport shape doesn't disturb the layers above" is
|
||||
//! true, but not for the reason it looks like. It is *not* that
|
||||
//! [`crate::daemon::transport::Stream`] grew a variant — that type is a plain
|
||||
//! alias (`UnixStream` on Unix, loopback `TcpStream` on Windows) and it does not
|
||||
//! change by a byte here. It is that **a remote stream never reaches it**:
|
||||
//!
|
||||
//! ```text
|
||||
//! GUI ──transport::Stream (unchanged)──▶ local daemon
|
||||
//! │
|
||||
//! RemoteLink ──▶ SSH channel / WSL stdio
|
||||
//! ```
|
||||
//!
|
||||
//! The GUI still talks to a socket on this machine. The local daemon forwards
|
||||
//! those bytes onto a `RemoteLink` without parsing them — which is what keeps
|
||||
//! the router a router, and keeps the remote version handshake genuinely
|
||||
//! end-to-end between the GUI and the remote server rather than something the
|
||||
//! daemon in the middle has to understand.
|
||||
//!
|
||||
//! Every existing `transport::Stream` call site — `try_clone`, `set_read_timeout`,
|
||||
//! `shutdown(Shutdown::Write)` — is therefore untouched, because every one of
|
||||
//! them is on a stream that is still local.
|
||||
//!
|
||||
//! ## Why an enum, and why four variants over two types
|
||||
//!
|
||||
//! An enum rather than `Box<dyn AsyncRead + AsyncWrite>`, matching
|
||||
//! [`super::ssh::connect::Transport`]: each variant's poll methods are a direct
|
||||
//! delegate with no vtable, on a path that carries every byte of every remote
|
||||
//! pane's output.
|
||||
//!
|
||||
//! Four variants over two underlying types, because the pairs are
|
||||
//! distinguishable only by **how they were obtained**, and that distinction is
|
||||
//! exactly what diagnostics need:
|
||||
//!
|
||||
//! | Variant | Underlying | Distinct because |
|
||||
//! |---|---|---|
|
||||
//! | [`RemoteLink::StreamLocal`] | SSH channel | The preferred path. A failure here is what triggers the one-time fallback probe |
|
||||
//! | [`RemoteLink::SessionExec`] | SSH channel | Already the fallback. A failure here means the remote is genuinely unreachable, not that forwarding is disabled |
|
||||
//! | [`RemoteLink::Wsl`] | child stdio | No SSH involved; auth and host-key problems are impossible by construction |
|
||||
//! | [`RemoteLink::LocalStdio`] | child stdio | A test harness. Must never be mistaken for a real remote in a log |
|
||||
//!
|
||||
//! Collapsing each pair would turn "`AllowStreamLocalForwarding` is off, fall
|
||||
//! back" into an indistinguishable "the connection dropped".
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::process::Stdio;
|
||||
@@ -58,76 +10,29 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use super::router::RouteChannel;
|
||||
use super::ssh::ProcessStream;
|
||||
|
||||
/// One logical stream between the local daemon and a remote `tty7-server`.
|
||||
pub enum RemoteLink {
|
||||
/// Preferred: `direct-streamlocal@openssh.com` straight to the remote's
|
||||
/// `daemon.sock`, opened with russh's
|
||||
/// `client::Handle::channel_open_direct_streamlocal`. No extra process on
|
||||
/// the remote, and the remote server's own accept loop handles it exactly
|
||||
/// as it would a local connection.
|
||||
StreamLocal(russh::ChannelStream<russh::client::Msg>),
|
||||
|
||||
/// Fallback for `AllowStreamLocalForwarding no`: a session channel running
|
||||
/// `tty7-server --stdio`, which bridges its own stdin/stdout to that same
|
||||
/// socket. Same type as [`RemoteLink::StreamLocal`], different meaning.
|
||||
SessionExec(russh::ChannelStream<russh::client::Msg>),
|
||||
|
||||
/// WSL, which has no SSH at all: `wsl.exe -d <distro> -- tty7-server --stdio`.
|
||||
Wsl(ProcessStream),
|
||||
|
||||
/// A `tty7-server --stdio` child on *this* machine. The end-to-end test
|
||||
/// path — the one that lets the whole remote stack be exercised in CI with
|
||||
/// no second machine, no SSH daemon, and no credentials.
|
||||
LocalStdio(ProcessStream),
|
||||
}
|
||||
|
||||
impl RemoteLink {
|
||||
/// Adopt a `direct-streamlocal@openssh.com` channel as the preferred link.
|
||||
///
|
||||
/// Taking the [`Channel`] rather than its stream keeps the "which SSH
|
||||
/// primitive opened this" decision at the call site that made it, which is
|
||||
/// the only place that still knows.
|
||||
pub fn stream_local(channel: Channel<Msg>) -> RemoteLink {
|
||||
RemoteLink::StreamLocal(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Adopt a session channel already running `tty7-server --stdio` as the
|
||||
/// fallback link.
|
||||
pub fn session_exec(channel: Channel<Msg>) -> RemoteLink {
|
||||
RemoteLink::SessionExec(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Spawn `program args…` and take its stdio as a [`RemoteLink::LocalStdio`].
|
||||
///
|
||||
/// `kill_on_drop`, so dropping the link reaps the child rather than leaving
|
||||
/// a `tty7-server` parented to a test that has already finished.
|
||||
pub fn local_stdio(program: &str, args: &[&str]) -> io::Result<RemoteLink> {
|
||||
Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?))
|
||||
}
|
||||
|
||||
/// Spawn `wsl.exe -d <distro> -- <server> --stdio` and take its stdio.
|
||||
///
|
||||
/// `server` is an **absolute path inside the distribution**, not a bare
|
||||
/// name: `wsl.exe` runs the command without a login shell, so the `PATH`
|
||||
/// that would find `~/.local/share/tty7/bin` is not in effect.
|
||||
/// [`install::wsl::ensure_wsl_server`](crate::daemon::install::wsl::ensure_wsl_server)
|
||||
/// is what resolves it.
|
||||
///
|
||||
/// No shell is involved, so `server` needs no quoting; the distro name is
|
||||
/// validated because it is an *option's* argument and a leading `-` would be
|
||||
/// read as another option.
|
||||
///
|
||||
/// # `channel`
|
||||
///
|
||||
/// **A pane bridge and a control bridge are different commands**, and this
|
||||
/// is the only place that can tell them apart for WSL. The remote listens
|
||||
/// twice and the dialects are not interchangeable — a pane landing on the
|
||||
/// control socket writes its `Spawn` and is answered with nothing, which is
|
||||
/// what "the workspace connects but the pane says it can't reach the
|
||||
/// machine" was. The SSH path makes the same choice in
|
||||
/// [`ssh::open_remote_link`](crate::daemon::ssh), and `LocalStdio` makes it
|
||||
/// in the client's `PaneWorkspace::route_header` because its argv is run
|
||||
/// verbatim; WSL builds its argv here, so here is where it belongs.
|
||||
pub fn wsl(distro: &str, server: &str, channel: RouteChannel) -> io::Result<RemoteLink> {
|
||||
super::install::wsl::validate_distro(distro)?;
|
||||
let args = super::install::wsl::wsl_args(distro, &wsl_link_argv(server, channel));
|
||||
@@ -137,18 +42,6 @@ impl RemoteLink {
|
||||
)?))
|
||||
}
|
||||
|
||||
/// [`RemoteLink::wsl`] with the command given as a shell string rather than
|
||||
/// a resolved path — the WSL reading of
|
||||
/// [`RouteHeader::server_command`](super::router::RouteHeader::server_command),
|
||||
/// which over SSH is likewise handed to a shell.
|
||||
///
|
||||
/// The escape hatch for a distribution where the normal install path cannot
|
||||
/// be used; the resolved-path form above is what ships.
|
||||
///
|
||||
/// `channel` reaches the command through
|
||||
/// [`RouteChannel::bridge_command`], which is the same rewrite the SSH path
|
||||
/// applies to *its* shell command — an override must not silently lose the
|
||||
/// pane dialect that [`RemoteLink::wsl`] gets right.
|
||||
pub fn wsl_shell(distro: &str, command: &str, channel: RouteChannel) -> io::Result<RemoteLink> {
|
||||
super::install::wsl::validate_distro(distro)?;
|
||||
let command = channel.bridge_command(command);
|
||||
@@ -159,11 +52,6 @@ impl RemoteLink {
|
||||
)?))
|
||||
}
|
||||
|
||||
/// The label this link goes into logs and the status line under.
|
||||
///
|
||||
/// The whole reason the variants are not collapsed: an operator reading
|
||||
/// "streamlocal" versus "session-exec" in a log knows immediately whether
|
||||
/// the remote refused socket forwarding or whether the box is simply gone.
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
RemoteLink::StreamLocal(_) => "streamlocal",
|
||||
@@ -173,11 +61,6 @@ impl RemoteLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this link is a `--stdio` bridge rather than a direct socket.
|
||||
///
|
||||
/// The bridge costs one extra process on the remote and cannot report a
|
||||
/// connection refusal as precisely, so a caller deciding whether to retry
|
||||
/// the preferred path wants to know.
|
||||
pub fn is_stdio_bridge(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -185,7 +68,6 @@ impl RemoteLink {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the link rides an SSH channel (as opposed to a child process).
|
||||
pub fn is_ssh(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -194,11 +76,6 @@ impl RemoteLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// The command `wsl.exe` runs for a link on `channel`, as an argv.
|
||||
///
|
||||
/// Pure, because the difference between the two is one flag that decides which
|
||||
/// of the remote's two sockets the stream lands on, and it cannot be checked
|
||||
/// anywhere a distribution is required. See [`RemoteLink::wsl`].
|
||||
fn wsl_link_argv<'a>(server: &'a str, channel: RouteChannel) -> Vec<&'a str> {
|
||||
let mut argv = vec![server, "--stdio"];
|
||||
if channel == RouteChannel::Pane {
|
||||
@@ -214,16 +91,11 @@ fn spawn_stdio(program: &str, args: &[&str]) -> io::Result<ProcessStream> {
|
||||
|
||||
fn spawn_stdio_owned(program: &str, args: &[String]) -> io::Result<ProcessStream> {
|
||||
let mut command = tokio::process::Command::new(program);
|
||||
// A GUI process spawning `wsl.exe` would otherwise flash a console window
|
||||
// per pane. No-op off Windows.
|
||||
crate::core::proc::hide_console_tokio(&mut command);
|
||||
let mut child = command
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
// stderr stays inherited: the remote server's diagnostics belong in the
|
||||
// daemon's log, and capturing them into a pipe nobody drains would
|
||||
// eventually block the child on a full buffer.
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
@@ -238,50 +110,17 @@ fn spawn_stdio_owned(program: &str, args: &[String]) -> io::Result<ProcessStream
|
||||
Ok(ProcessStream::from_parts(child, stdin, stdout))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// How a host is entered: the one-time decision behind `StreamLocal` vs `SessionExec`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The command run on a session channel when socket forwarding is unavailable.
|
||||
///
|
||||
/// `--stdio` with neither `--serve` nor `--bridge` lets the *remote* decide:
|
||||
/// it bridges to a running daemon if there is one and serves in-process if
|
||||
/// there is not, which is the right answer in both cases and one this side has
|
||||
/// no way to know.
|
||||
///
|
||||
/// **Only a fallback for links that skip the install pass.** SSH links do not:
|
||||
/// `SshManager::open_remote_link` runs `install::ensure_remote_server` first and
|
||||
/// uses the absolute, dialect-qualified path it returns. That matters because
|
||||
/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the
|
||||
/// file there is `tty7-server-c<control>p<protocol>` — this bare name would be a
|
||||
/// `command not found` on a machine the install had just succeeded on.
|
||||
/// [`super::router::RouteHeader::server_command`] overrides either.
|
||||
pub const DEFAULT_REMOTE_SERVER_CMD: &str = "tty7-server --stdio";
|
||||
|
||||
/// `sockaddr_un.sun_path` is 104 bytes on macOS and 108 on Linux, NUL included.
|
||||
/// The remote server stays under the smaller figure
|
||||
/// (`host::server`'s `MAX_SOCKET_PATH_BYTES`), so the path derived here must use
|
||||
/// the same bound or the two sides would disagree about when the fallback name
|
||||
/// kicks in.
|
||||
const MAX_SOCKET_PATH_BYTES: usize = 100;
|
||||
|
||||
/// How this connection reaches the remote `tty7-server` — decided once per SSH
|
||||
/// connection and cached there, never re-decided per channel.
|
||||
///
|
||||
/// Probing per channel would put a failed `direct-streamlocal` open in front of
|
||||
/// every pane on a host whose admin turned `AllowStreamLocalForwarding` off,
|
||||
/// and each of those is a full round trip.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemoteEntry {
|
||||
/// `direct-streamlocal@openssh.com` straight to this absolute remote path.
|
||||
StreamLocal { socket: String },
|
||||
/// A session channel running `command`, which bridges its own stdio to that
|
||||
/// same socket.
|
||||
SessionExec { command: String },
|
||||
}
|
||||
|
||||
impl RemoteEntry {
|
||||
/// The label this entry's links appear under in logs.
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
RemoteEntry::StreamLocal { .. } => "streamlocal",
|
||||
@@ -290,22 +129,6 @@ impl RemoteEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the way in, from what a probe of the remote learned.
|
||||
///
|
||||
/// Split out as a pure function because the real decision is impossible to
|
||||
/// unit-test end to end — it needs an sshd with `AllowStreamLocalForwarding`
|
||||
/// flipped both ways — while the *policy* is exactly the part worth pinning:
|
||||
///
|
||||
/// | remote socket path | forwarding allowed | entry |
|
||||
/// |---|---|---|
|
||||
/// | resolved | yes | [`RemoteEntry::StreamLocal`] |
|
||||
/// | resolved | no | [`RemoteEntry::SessionExec`] |
|
||||
/// | unresolved | either | [`RemoteEntry::SessionExec`] |
|
||||
///
|
||||
/// An unresolved path forces the bridge even where forwarding is allowed:
|
||||
/// `direct-streamlocal` carries an absolute path and nothing else, so without
|
||||
/// one there is no request to make — whereas `tty7-server --stdio` resolves the
|
||||
/// path in the process that will actually bind it.
|
||||
pub fn choose_entry(
|
||||
socket: Option<&str>,
|
||||
forwarding_allowed: bool,
|
||||
@@ -321,10 +144,6 @@ pub fn choose_entry(
|
||||
}
|
||||
}
|
||||
|
||||
/// The four remote environment variables the control socket path is derived
|
||||
/// from. Read off the remote in one `exec`, never guessed from this machine's
|
||||
/// own environment — a macOS client has no `$XDG_RUNTIME_DIR` and a Linux
|
||||
/// server usually does.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RemoteEnv {
|
||||
pub control_sock: Option<String>,
|
||||
@@ -333,13 +152,8 @@ pub struct RemoteEnv {
|
||||
pub tmpdir: Option<String>,
|
||||
}
|
||||
|
||||
/// Marker every probe line carries, so a remote whose startup files print a
|
||||
/// banner (or a `fish` that greets) doesn't corrupt the answer. Same tactic as
|
||||
/// [`crate::daemon::shell_integration::remote`]'s shell probe.
|
||||
const ENV_MARKER: &str = "__tty7_env__";
|
||||
|
||||
/// The probe itself, wrapped in `sh -c` because the login shell it is handed to
|
||||
/// may be `fish`, which does not speak `${VAR-}`.
|
||||
pub const REMOTE_ENV_PROBE: &str = concat!(
|
||||
"sh -c 'printf \"__tty7_env__ %s\\n\" ",
|
||||
"\"sock=${TTY7_CONTROL_SOCK-}\" \"xdg=${XDG_RUNTIME_DIR-}\" ",
|
||||
@@ -347,7 +161,6 @@ pub const REMOTE_ENV_PROBE: &str = concat!(
|
||||
);
|
||||
|
||||
impl RemoteEnv {
|
||||
/// Parse [`REMOTE_ENV_PROBE`]'s output, ignoring everything unmarked.
|
||||
pub fn parse_probe(out: &str) -> RemoteEnv {
|
||||
let mut env = RemoteEnv::default();
|
||||
for line in out.lines() {
|
||||
@@ -357,8 +170,6 @@ impl RemoteEnv {
|
||||
let Some((key, value)) = rest.trim_start().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
// An unset variable prints empty; keep it `None` so the fallbacks
|
||||
// below treat it as absent rather than as the empty path.
|
||||
let value = (!value.is_empty()).then(|| value.to_string());
|
||||
match key {
|
||||
"sock" => env.control_sock = value,
|
||||
@@ -372,29 +183,6 @@ impl RemoteEnv {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the remote's `tty7-server` listens for control connections, derived
|
||||
/// from *its* environment.
|
||||
///
|
||||
/// This mirrors `host::server`'s `control_socket_path` step for step, because
|
||||
/// the two have to agree byte for byte: this side asks `direct-streamlocal` for
|
||||
/// a path, and the far side binds one, and nothing in between reconciles them.
|
||||
///
|
||||
/// | Order | Path |
|
||||
/// |---|---|
|
||||
/// | 1 | `$TTY7_CONTROL_SOCK` |
|
||||
/// | 2 | `$XDG_RUNTIME_DIR/tty7/daemon.sock` |
|
||||
/// | 3 | `$HOME/.local/share/tty7/daemon.sock` |
|
||||
/// | 4 | `<runtime-or-tmp>/tty7-<hash>.sock`, when any of the above overruns `sun_path` |
|
||||
///
|
||||
/// The hashed name is *not* automatically shorter: a deep `$XDG_RUNTIME_DIR`
|
||||
/// overruns `sun_path` on its own, which is the hole
|
||||
/// [`crate::daemon::transport`] was fixed for. Every candidate base is
|
||||
/// length-checked, and `None` — rather than a path the server will not be on —
|
||||
/// is the answer when none fits, which puts the session down the `--stdio`
|
||||
/// bridge that resolves the path in the process that binds it.
|
||||
///
|
||||
/// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client
|
||||
/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7`.
|
||||
pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> {
|
||||
if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) {
|
||||
return Some(explicit.to_string());
|
||||
@@ -427,8 +215,6 @@ pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> {
|
||||
.find(|candidate| fits(candidate))
|
||||
}
|
||||
|
||||
/// `Path::join`'s behaviour, spelled out for POSIX strings: one separator, no
|
||||
/// doubling when the base already ends in one.
|
||||
fn posix_join(base: &str, name: &str) -> String {
|
||||
format!("{}/{name}", base.trim_end_matches('/'))
|
||||
}
|
||||
@@ -473,10 +259,6 @@ impl AsyncWrite for RemoteLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every variant implements shutdown for real. A half-close is how the
|
||||
/// remote learns the client is finished rather than merely quiet, and a
|
||||
/// `poll_shutdown` that returned `Ready(Ok(()))` without acting would strand
|
||||
/// the remote waiting on a stream that will never carry another byte.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => {
|
||||
@@ -500,13 +282,6 @@ mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// **A WSL pane asks for the pane socket.** The remote listens twice and
|
||||
/// answers only the dialect it was asked for, so a pane that reaches the
|
||||
/// control socket writes its `Spawn` and is answered with nothing at all —
|
||||
/// the workspace connects, the window opens, and the pane inside it says it
|
||||
/// cannot reach the machine. Nothing in the transport reports an error,
|
||||
/// which is why this is pinned here rather than left to the one integration
|
||||
/// path that would catch it.
|
||||
#[test]
|
||||
fn only_a_pane_link_asks_for_the_pane_socket() {
|
||||
let server = "/home/me/.local/share/tty7/bin/tty7-server-26.7.6";
|
||||
@@ -520,11 +295,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A child process's stdio really is a duplex stream: bytes written reach
|
||||
/// the child's stdin and its stdout comes back, through the same
|
||||
/// `AsyncRead`/`AsyncWrite` the SSH variants use. This is the path the
|
||||
/// end-to-end test rides, so it has to work before there is a server to
|
||||
/// point it at.
|
||||
#[tokio::test]
|
||||
async fn a_local_stdio_child_round_trips_bytes() {
|
||||
let mut link = RemoteLink::local_stdio("cat", &[]).unwrap();
|
||||
@@ -540,9 +310,6 @@ mod tests {
|
||||
assert_eq!(&got, b"hello remote\n");
|
||||
}
|
||||
|
||||
/// Shutting the write half down is what tells the peer "no more input" —
|
||||
/// `cat` answers by closing its stdout, which surfaces here as EOF. A
|
||||
/// no-op `poll_shutdown` would hang this test forever.
|
||||
#[tokio::test]
|
||||
async fn shutdown_closes_the_write_half_and_the_peer_sees_eof() {
|
||||
let mut link = RemoteLink::local_stdio("cat", &[]).unwrap();
|
||||
@@ -554,25 +321,15 @@ mod tests {
|
||||
assert_eq!(rest, b"bye");
|
||||
}
|
||||
|
||||
/// Dropping the link reaps the child. Without `kill_on_drop` a failed test
|
||||
/// would leave a `tty7-server` running against a socket nobody holds.
|
||||
#[tokio::test]
|
||||
async fn dropping_the_link_kills_the_child() {
|
||||
let link = RemoteLink::local_stdio("sleep", &["300"]).unwrap();
|
||||
drop(link);
|
||||
// The child is reaped asynchronously by tokio; what matters is that the
|
||||
// handle is gone and nothing here leaks. A surviving process would show
|
||||
// up as a hung test run rather than an assertion, which is why this is
|
||||
// mostly a statement of intent — `kill_on_drop(true)` above is the
|
||||
// mechanism.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
/// The labels are the diagnostic value the four variants exist for, so they
|
||||
/// are pinned: a log line reading "streamlocal" has to keep meaning that.
|
||||
#[test]
|
||||
fn every_variant_has_a_distinct_label() {
|
||||
// Constructed without processes: only the discriminant is exercised.
|
||||
let labels = ["streamlocal", "session-exec", "wsl-stdio", "local-stdio"];
|
||||
let mut sorted = labels.to_vec();
|
||||
sorted.sort_unstable();
|
||||
@@ -580,10 +337,6 @@ mod tests {
|
||||
assert_eq!(sorted.len(), labels.len(), "labels must be distinguishable");
|
||||
}
|
||||
|
||||
// -- the way in ---------------------------------------------------------
|
||||
|
||||
/// The whole fallback policy, which a live sshd cannot be asked to
|
||||
/// demonstrate both halves of in one test run.
|
||||
#[test]
|
||||
fn the_entry_falls_back_exactly_when_streamlocal_cannot_be_used() {
|
||||
let cmd = "tty7-server --stdio";
|
||||
@@ -593,15 +346,12 @@ mod tests {
|
||||
socket: "/run/user/1000/tty7/daemon.sock".into()
|
||||
}
|
||||
);
|
||||
// `AllowStreamLocalForwarding no`: the path is known and useless.
|
||||
assert_eq!(
|
||||
choose_entry(Some("/run/user/1000/tty7/daemon.sock"), false, cmd),
|
||||
RemoteEntry::SessionExec {
|
||||
command: cmd.into()
|
||||
}
|
||||
);
|
||||
// No path to ask for. The bridge resolves it in the process that binds
|
||||
// it, so this is a fallback with *more* information, not less.
|
||||
assert_eq!(
|
||||
choose_entry(None, true, cmd),
|
||||
RemoteEntry::SessionExec {
|
||||
@@ -616,9 +366,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The probe's output is read by marker, so a remote that greets, warns, or
|
||||
/// prints a MOTD before the answer is still parsed correctly — and an unset
|
||||
/// variable stays absent rather than becoming the empty path.
|
||||
#[test]
|
||||
fn the_env_probe_survives_a_chatty_remote() {
|
||||
let out = "Welcome to Ubuntu!\n\
|
||||
@@ -634,11 +381,6 @@ mod tests {
|
||||
assert_eq!(env.tmpdir, None);
|
||||
}
|
||||
|
||||
/// The remote path, in the order `host::server::control_socket_path`
|
||||
/// resolves it. Pinned as literals: these strings are compared — over a
|
||||
/// wire, with no error message — against what a *different binary* on a
|
||||
/// *different machine* computed, so an "equivalent" refactor of either side
|
||||
/// is a silent connection failure.
|
||||
#[test]
|
||||
fn the_remote_socket_path_matches_the_servers_own_order() {
|
||||
let explicit = RemoteEnv {
|
||||
@@ -663,8 +405,6 @@ mod tests {
|
||||
Some("/run/user/1000/tty7/daemon.sock")
|
||||
);
|
||||
|
||||
// A trailing separator must not double up: the server derives its path
|
||||
// through `Path::join`, which collapses it.
|
||||
let trailing = RemoteEnv {
|
||||
xdg_runtime_dir: Some("/run/user/1000/".into()),
|
||||
..RemoteEnv::default()
|
||||
@@ -683,15 +423,9 @@ mod tests {
|
||||
Some("/home/me/.local/share/tty7/daemon.sock")
|
||||
);
|
||||
|
||||
// Nothing to derive from at all.
|
||||
assert_eq!(remote_control_socket(&RemoteEnv::default()), None);
|
||||
}
|
||||
|
||||
/// The hole `daemon::transport` was fixed for, on the remote side: the
|
||||
/// "short" hashed name is only short relative to the *config* dir, and a
|
||||
/// deep `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Returning
|
||||
/// an overlong path here would send `direct-streamlocal` at an address the
|
||||
/// server could never have bound.
|
||||
#[test]
|
||||
fn a_deep_runtime_dir_never_yields_an_unbindable_path() {
|
||||
let deep = format!("/run/user/1000/{}", "nested/".repeat(12));
|
||||
@@ -707,15 +441,11 @@ mod tests {
|
||||
"{path} ({} bytes) would be rejected by bind()",
|
||||
path.len()
|
||||
);
|
||||
// …and it lands in the temp dir, because the runtime dir itself is what
|
||||
// was too long.
|
||||
assert!(
|
||||
path.starts_with("/tmp/tty7-"),
|
||||
"unexpected fallback: {path}"
|
||||
);
|
||||
|
||||
// When *no* base is short enough, the honest answer is "no path" — the
|
||||
// session then takes the stdio bridge, which resolves it remotely.
|
||||
let hopeless = RemoteEnv {
|
||||
control_sock: None,
|
||||
xdg_runtime_dir: Some(deep.clone()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,3 @@
|
||||
//! Daemon server: the Unix-domain-socket listener, pane registry, and `--daemon`
|
||||
//! entry point.
|
||||
//!
|
||||
//! One process hosts many panes ([`DaemonPane`]); one socket connection drives one
|
||||
//! pane (matching the protocol's "one connection = one pane" model). The server:
|
||||
//! 1. resolves the socket path under the (config-dir-aware) config directory,
|
||||
//! so `cargo dev` / `--config-dir` isolation reaches the daemon too;
|
||||
//! 2. clears a *stale* socket (one that nothing is listening on) before binding;
|
||||
//! 3. accepts connections, spawning a thread per connection.
|
||||
//!
|
||||
//! Per-connection flow (see [`handle_conn`]): read the first `ClientMsg`.
|
||||
//! - `Spawn` → create a pane, reply `Spawned`, attach this connection, stream.
|
||||
//! - `Attach` → look the pane up; on hit attach + stream, on miss reply `Error`.
|
||||
//! - `List` → reply `PaneList`, then close.
|
||||
//! While streaming, a small writer thread drains the pane's `DaemonMsg` channel to
|
||||
//! the socket, while the main connection thread reads further client messages
|
||||
//! (`Input` / `Resize` / `Detach` / `Kill`). Connection close == detach (the pane
|
||||
//! keeps running headless).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -28,7 +9,6 @@ use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, RemoteKind};
|
||||
use crate::daemon::ssh::SshConnection;
|
||||
use crate::daemon::transport::{self, Stream};
|
||||
|
||||
/// Shared pane registry: id → pane, plus a monotonic id source.
|
||||
struct Registry {
|
||||
panes: Mutex<HashMap<u64, Arc<DaemonPane>>>,
|
||||
next_id: AtomicU64,
|
||||
@@ -46,10 +26,6 @@ impl Registry {
|
||||
self.next_id.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Never mint an id `machine`'s tree already names — see the caller in
|
||||
/// [`run`] for the aliasing failures this closes. The registry and the
|
||||
/// leaves are checked both: a pane record can outlive its leaf briefly,
|
||||
/// and either one aliased is one too many.
|
||||
fn seed_ids_past(&self, machine: &crate::core::machine::Machine) {
|
||||
let max = machine
|
||||
.panes
|
||||
@@ -64,12 +40,7 @@ impl Registry {
|
||||
)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
// Saturating: a tree (or a hostile seed) naming u64::MAX must not
|
||||
// panic the daemon at startup. The counter parking at the ceiling is
|
||||
// a bounded absurdity; overflowing is a dead process.
|
||||
let next = max.saturating_add(1);
|
||||
// fetch_max rather than store: harmless today (this runs before any
|
||||
// spawn), but a seed must never move the counter backwards.
|
||||
let before = self.next_id.fetch_max(next, Ordering::Relaxed);
|
||||
if next > before {
|
||||
log::info!("pane ids start at {next} (the tree names panes up to {max})");
|
||||
@@ -84,18 +55,10 @@ impl Registry {
|
||||
self.panes.lock().unwrap().get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Remove a pane from the registry (its `Arc` drop hangs up + reaps the child
|
||||
/// once the last connection releases it).
|
||||
fn remove(&self, id: u64) -> Option<Arc<DaemonPane>> {
|
||||
self.panes.lock().unwrap().remove(&id)
|
||||
}
|
||||
|
||||
/// Remove every pane and hang up its child. Used by the `Shutdown` control
|
||||
/// message right before the process exits: the children must be signalled now
|
||||
/// (SIGHUP → SIGKILL, via `pane.kill()`), or the exit would orphan them —
|
||||
/// reparented to launchd and still holding their PTYs — instead of ending the
|
||||
/// session cleanly. Drains under the lock, then kills with the lock released
|
||||
/// so a pane's teardown can't deadlock against the registry.
|
||||
fn drain_and_kill(&self) {
|
||||
let panes: Vec<Arc<DaemonPane>> = {
|
||||
let mut guard = self.panes.lock().unwrap();
|
||||
@@ -106,7 +69,6 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of all panes' metadata for `List`.
|
||||
fn list(&self) -> Vec<crate::daemon::protocol::PaneInfo> {
|
||||
self.panes
|
||||
.lock()
|
||||
@@ -117,21 +79,8 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the orphan sweep looks, which doubles as its grace period: a pane
|
||||
/// is only reported after it has been unreferenced across two consecutive
|
||||
/// looks, so a freshly-spawned pane whose adopting operation is still in
|
||||
/// flight is never flagged.
|
||||
const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Periodically report live panes the machine tree does not reference.
|
||||
///
|
||||
/// **Log-only, on purpose.** An unreferenced pane is not proof of a leak:
|
||||
/// a native-SSH pane opened inside a *remote* workspace's window runs in this
|
||||
/// (the client's) daemon while belonging to the other machine's tree, so it is
|
||||
/// unreferenced here by design — and a reclaim would kill a session the user
|
||||
/// is looking at. Until the tree provably references everything legitimate,
|
||||
/// the sweep's job is to make leaks observable, not to act on them; killing
|
||||
/// can be layered on once the log has shown the false-positive rate is zero.
|
||||
fn spawn_orphan_sweep(registry: Arc<Registry>) {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tty7-orphan-sweep".into())
|
||||
@@ -139,7 +88,6 @@ fn spawn_orphan_sweep(registry: Arc<Registry>) {
|
||||
let mut previous: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
loop {
|
||||
std::thread::sleep(ORPHAN_SWEEP_INTERVAL);
|
||||
// No tree served (a pane-only daemon) means no opinion.
|
||||
let Some(store) = crate::core::machine::observed_store() else {
|
||||
continue;
|
||||
};
|
||||
@@ -170,10 +118,6 @@ fn spawn_orphan_sweep(registry: Arc<Registry>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pane id to its live native-SSH connection, for the SFTP control
|
||||
/// handlers. Errors (as a client-facing string) when the pane is unknown or isn't
|
||||
/// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or
|
||||
/// one still authenticating).
|
||||
fn ssh_connection_for(
|
||||
registry: &Registry,
|
||||
pane_id: u64,
|
||||
@@ -186,33 +130,7 @@ fn ssh_connection_for(
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the *whole* daemon — panes **and** control — until killed. The one
|
||||
/// entry point behind both `tty7 --daemon` and `tty7-server --daemon`.
|
||||
///
|
||||
/// Local and remote are deliberately the same shape: a machine is a machine,
|
||||
/// whether the client sits on it or an ocean away, and the design's terminal
|
||||
/// state is "one machine = one daemon = one workspace tree". That tree is
|
||||
/// served over the control dialect, so the *local* daemon has to speak it too —
|
||||
/// which is why this lives here rather than staying a `tty7-server` detail.
|
||||
///
|
||||
/// Control comes up first, and on its own thread: a machine that cannot host
|
||||
/// panes (no pty, a locked-down container) should still be able to back a
|
||||
/// workspace's files, so a control failure is logged and stepped over rather
|
||||
/// than being fatal. The pane listener then owns this thread until the process
|
||||
/// is killed, exactly as [`run`] always has.
|
||||
///
|
||||
/// Both platforms serve it, over the transport each one's pane socket already
|
||||
/// uses: a Unix-domain socket gated by its file permissions, or a loopback
|
||||
/// `TcpListener` gated by the token in a user-private marker file. The tree is
|
||||
/// what a client's layout *is* now, so a platform without a control listener is
|
||||
/// a platform where tabs do not come back — which is not a difference a build
|
||||
/// gets to have.
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// Reported on **stderr**, not only the log: a headless server's log file is
|
||||
// off unless `TTY7_LOG` asks for it, and the bound path is this daemon's
|
||||
// one observable answer to "where do I connect". The remote-router test
|
||||
// reads this exact line back to prove the client's derivation and the
|
||||
// server's bind agree, so the prefix is part of the contract.
|
||||
#[cfg(any(unix, windows))]
|
||||
match crate::host::server::spawn_control_listener_with(
|
||||
crate::host::local::LocalHost::shared(),
|
||||
@@ -227,31 +145,11 @@ pub fn run_daemon() -> anyhow::Result<()> {
|
||||
run()
|
||||
}
|
||||
|
||||
/// What this machine offers over a control connection, beyond its filesystem.
|
||||
///
|
||||
/// The machine tree is why a daemon serves control at all: the workspace
|
||||
/// list, the tab/pane tree and each pane's facts live on **the machine the
|
||||
/// panes run on**, so that every client of this machine — the GUI on it, a
|
||||
/// laptop across the world — sees the same thing. Clients keep only their own
|
||||
/// view state.
|
||||
///
|
||||
/// A machine with no home directory to place the file in still serves files
|
||||
/// and panes — it simply omits `machine-tree` from its capabilities, and
|
||||
/// clients see the same "does not serve the machine tree" answer a server
|
||||
/// without one has always given.
|
||||
pub fn control_services() -> crate::host::server::Services {
|
||||
use crate::core::machine::MachineStore;
|
||||
// Reported on stderr as well as the log, like the socket line in
|
||||
// [`run_daemon`]: on a headless box the log file is off by default, and
|
||||
// "does this daemon actually serve the tree" is the first question a
|
||||
// capability mismatch raises.
|
||||
match MachineStore::shared() {
|
||||
Ok(machine) => {
|
||||
eprintln!("machine tree at {}", machine.path().display());
|
||||
// From here on the pane server's own observations — OSC 7 cwds,
|
||||
// agent identities, deaths — land on the tree's pane records, so
|
||||
// what a client revives from is what the machine saw, not what
|
||||
// some client last remembered to write.
|
||||
crate::core::machine::publish_observations(&machine);
|
||||
crate::host::server::Services::with_machine(machine)
|
||||
}
|
||||
@@ -262,13 +160,7 @@ pub fn control_services() -> crate::host::server::Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the daemon: bind the socket and serve connections forever. Returns `Err`
|
||||
/// only on a fatal setup failure (bad socket path, bind error); the accept loop
|
||||
/// itself runs until the process is killed.
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
// If an endpoint marker is already there, it's either a live daemon (we should
|
||||
// bail) or a stale leftover from a crash (we should clear it and take over).
|
||||
// Probe by connecting: success means someone's listening — don't double-run.
|
||||
if transport::endpoint_exists() {
|
||||
match transport::connect() {
|
||||
Ok(_) => {
|
||||
@@ -278,8 +170,6 @@ pub fn run() -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// Nothing listening: stale endpoint from a previous run. Clear it so
|
||||
// `bind` below can recreate it.
|
||||
transport::remove_stale_endpoint();
|
||||
}
|
||||
}
|
||||
@@ -288,68 +178,38 @@ pub fn run() -> anyhow::Result<()> {
|
||||
let listener = transport::bind()?;
|
||||
log::info!("daemon listening on {}", transport::endpoint_display());
|
||||
|
||||
// Record who owns this endpoint. If this process later becomes unreachable
|
||||
// (wedged, or a protocol the client no longer speaks), the client's takeover
|
||||
// paths read this back and reap us instead of stranding our panes.
|
||||
crate::daemon::pidfile::write_current();
|
||||
|
||||
let registry = Arc::new(Registry::new());
|
||||
|
||||
// Reap-by-signal path: a client that can't reach us over the socket sends
|
||||
// SIGTERM (see `spawn::reap_recorded_daemon`). Tear down exactly like the
|
||||
// `Shutdown` message — every pane's child gets the SIGHUP-grace-SIGKILL
|
||||
// treatment — rather than dying with the default action, which would only
|
||||
// HUP each PTY's foreground group and leave background jobs behind.
|
||||
#[cfg(unix)]
|
||||
serve_sigterm(registry.clone());
|
||||
|
||||
// Pane ids must never alias across restarts: the persisted tree still
|
||||
// names the previous process's panes, and a fresh process minting from 1
|
||||
// would hand a new shell an id some dead leaf claims — at which point the
|
||||
// record's `live` flag flips back on for the wrong pane, revival stalls on
|
||||
// "pane N is already part of this machine's tree", and a window attaching
|
||||
// by the stale id steals an unrelated workspace's stream. Starting past
|
||||
// everything the tree knows makes the id a name, not a slot.
|
||||
if let Some(store) = crate::core::machine::observed_store() {
|
||||
registry.seed_ids_past(&store.machine());
|
||||
// And let the store ask *us* whether a seeded pane is still alive at
|
||||
// registration time — the pane that dies between its spawn and its
|
||||
// adopting operation would otherwise be filed `live: true` with its
|
||||
// death observation already dropped, and nothing left to flip it.
|
||||
let probe = registry.clone();
|
||||
store.set_liveness_probe(Arc::new(move |id| {
|
||||
probe.get(id).is_some_and(|pane| pane.info().alive)
|
||||
}));
|
||||
}
|
||||
|
||||
// Now that the tree has an owner filling it, the daemon can *see* panes
|
||||
// nothing references any more — but it only reports them, deliberately.
|
||||
spawn_orphan_sweep(registry.clone());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
// Both directions get tuned: `transport::connect` covers the
|
||||
// GUI's end, this covers the daemon's (where the send buffer
|
||||
// carries the full output throughput).
|
||||
transport::tune(&stream);
|
||||
let registry = registry.clone();
|
||||
// One thread per connection; the connection owns its pane stream.
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-daemon-conn".to_string())
|
||||
.spawn(move || {
|
||||
// This thread relays client input (keystrokes) to the
|
||||
// PTY: interactive by definition.
|
||||
crate::core::threads::promote_to_user_interactive();
|
||||
if let Err(e) = handle_conn(stream, registry) {
|
||||
// A clean client disconnect surfaces as an EOF error; log
|
||||
// at debug so it isn't noise.
|
||||
log::debug!("connection ended: {e}");
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// A transient accept error shouldn't kill the daemon; log and continue.
|
||||
Err(e) => log::warn!("accept failed: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -357,21 +217,8 @@ pub fn run() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle SIGTERM as a graceful daemon stop, mirroring `ClientMsg::Shutdown`.
|
||||
///
|
||||
/// SIGTERM is blocked on the calling thread *before* any connection thread
|
||||
/// spawns (new threads inherit the mask), then a dedicated thread `sigwait`s
|
||||
/// for it — the one way to run non-trivial teardown (locks, allocation) in
|
||||
/// response to a signal without breaking async-signal-safety. Must be called
|
||||
/// from the daemon's main thread ahead of the accept loop.
|
||||
///
|
||||
/// If the watcher can't be set up, SIGTERM keeps (or reverts to) its default
|
||||
/// terminate action; the takeover path's SIGKILL escalation still covers a
|
||||
/// daemon that ignores it either way.
|
||||
#[cfg(unix)]
|
||||
fn serve_sigterm(registry: Arc<Registry>) {
|
||||
// SAFETY: building a local sigset and masking it on the current thread;
|
||||
// nothing here aliases or races.
|
||||
let set = unsafe {
|
||||
let mut set: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut set);
|
||||
@@ -386,8 +233,6 @@ fn serve_sigterm(registry: Arc<Registry>) {
|
||||
.name("tty7-daemon-sigterm".to_string())
|
||||
.spawn(move || {
|
||||
let mut sig: libc::c_int = 0;
|
||||
// SAFETY: `set` is the initialized sigset masked above; `sigwait`
|
||||
// blocks until one of its signals is delivered to the process.
|
||||
if unsafe { libc::sigwait(&set, &mut sig) } == 0 {
|
||||
log::info!("daemon shutting down on SIGTERM");
|
||||
registry.drain_and_kill();
|
||||
@@ -398,14 +243,6 @@ fn serve_sigterm(registry: Arc<Registry>) {
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// What every daemon exit owes the next one.
|
||||
///
|
||||
/// The tree's observations first: a pane's cwd and its agent session are
|
||||
/// deferred by design (`machine::Persist::Soon`) and are exactly what the next
|
||||
/// launch revives that pane from, so the last couple of seconds of them are
|
||||
/// worth one write on the way out. Then the endpoint markers — **both**
|
||||
/// dialects', since on Windows each listener has its own — and the pidfile, so
|
||||
/// nothing left on disk points at a process that is gone.
|
||||
fn on_shutdown() {
|
||||
if let Some(store) = crate::core::machine::observed_store() {
|
||||
store.flush();
|
||||
@@ -416,29 +253,13 @@ fn on_shutdown() {
|
||||
crate::daemon::pidfile::remove();
|
||||
}
|
||||
|
||||
/// Handle one connection start-to-finish. Reads the opening `ClientMsg` and
|
||||
/// dispatches; for the streaming variants it then runs [`stream_pane`].
|
||||
fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
let mut read_stream = stream;
|
||||
|
||||
// Authenticate before touching the protocol. On Windows the transport is
|
||||
// loopback TCP, reachable by any local process; the client proves it read the
|
||||
// user-private port file by presenting the daemon's token as a preamble. A
|
||||
// failed check drops the connection here, before any `ClientMsg` is parsed or a
|
||||
// pane is spawned. No-op on Unix (the socket's filesystem perms already gate it).
|
||||
transport::authenticate(&mut read_stream)?;
|
||||
|
||||
// Separate read/write halves so the writer thread and reader loop don't share a
|
||||
// `&mut` (the stream is just a socket; `try_clone` dups the handle — both
|
||||
// directions are independent).
|
||||
let write_stream = read_stream.try_clone()?;
|
||||
|
||||
// The opening frame decides whether this connection is *ours* at all. A
|
||||
// route header means the rest of it belongs to a remote `tty7-server`, and
|
||||
// this daemon becomes a byte pipe for the remainder of its life — see
|
||||
// `daemon::router`. Read at the frame level rather than through
|
||||
// `ClientMsg::read` because a routed connection's later bytes are not this
|
||||
// dialect, and nothing here may assume they are.
|
||||
let (first_kind, first_payload) = crate::daemon::protocol::read_frame(&mut read_stream)?;
|
||||
if first_kind == crate::daemon::router::ROUTE_KIND {
|
||||
drop(write_stream);
|
||||
@@ -458,13 +279,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
owner,
|
||||
} => {
|
||||
let id = registry.alloc_id();
|
||||
// Reclaim a pane whose child exits while *detached* (nobody attached,
|
||||
// so no connection's detach path will ever drop it): remove it from
|
||||
// the registry, freeing the ring and reaping the zombie child. The
|
||||
// removal runs on its own short-lived thread because `on_dead` fires
|
||||
// on the pane's reader thread, and dropping the last `Arc` there
|
||||
// would make `DaemonPane::drop`'s reader join wait (bounded) on the
|
||||
// very thread it is running on.
|
||||
let on_dead = {
|
||||
let registry = registry.clone();
|
||||
move || {
|
||||
@@ -479,7 +293,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
let pane = match DaemonPane::spawn(id, cwd, size, shell, owner, on_dead) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report the failure to the client and close.
|
||||
let mut w = write_stream;
|
||||
let _ = DaemonMsg::Error(format!("spawn failed: {e}")).encode(&mut w);
|
||||
return Err(e);
|
||||
@@ -487,7 +300,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
};
|
||||
registry.insert(pane.clone());
|
||||
|
||||
// Reply with the new id, then attach this connection and stream.
|
||||
{
|
||||
let mut w = &write_stream;
|
||||
DaemonMsg::Spawned { pane_id: id }.encode(&mut w)?;
|
||||
@@ -496,10 +308,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
ClientMsg::SpawnNativeSsh { cwd: _, size, spec } => {
|
||||
// A native russh-backed pane. Same lifecycle as `Spawn` (allocate,
|
||||
// reclaim-on-detached-death, reply `Spawned`, attach, stream); the pane
|
||||
// spawns fast and the connect/auth runs asynchronously, sending
|
||||
// `AuthPrompt`/`SshStatus` over this same connection.
|
||||
let id = registry.alloc_id();
|
||||
let on_dead = {
|
||||
let registry = registry.clone();
|
||||
@@ -529,10 +337,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
stream_pane(pane, id, read_stream, write_stream, registry)
|
||||
}
|
||||
|
||||
// The attach `size` is the client's pre-layout placeholder and is
|
||||
// deliberately ignored: the daemon reports the recorded geometry via
|
||||
// `DaemonMsg::Size` for the replay, and the client sends a real
|
||||
// `Resize` once laid out (see `DaemonPane::attach`).
|
||||
ClientMsg::Attach { pane_id, size: _ } => match registry.get(pane_id) {
|
||||
Some(pane) => {
|
||||
stream_pane_with_attach(pane, pane_id, read_stream, write_stream, registry)
|
||||
@@ -557,12 +361,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
ClientMsg::Shutdown => {
|
||||
// Force a full daemon stop (the GUI's "Restart Background Service"):
|
||||
// hang up every child so nothing is orphaned, drop the endpoint
|
||||
// marker so a fresh daemon binds cleanly, then exit. The accept loop
|
||||
// has no cooperative stop — a hard exit *is* the daemon's defined stop
|
||||
// (see `run`'s "runs until the process is killed"). This is the one
|
||||
// place the daemon terminates itself.
|
||||
log::info!("daemon shutting down on client request");
|
||||
registry.drain_and_kill();
|
||||
on_shutdown();
|
||||
@@ -570,8 +368,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
ClientMsg::Kill { pane_id } => {
|
||||
// A control-only `Kill` as the opening message: terminate + forget the
|
||||
// pane, then close (no stream).
|
||||
if let Some(pane) = registry.remove(pane_id) {
|
||||
pane.kill();
|
||||
}
|
||||
@@ -588,8 +384,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
DaemonMsg::Error("pane has no ssh remote context".to_string()).encode(&mut w)?;
|
||||
return Ok(());
|
||||
};
|
||||
// A loopback forward (FR-F4) is a Local `direct-tcpip` on the pane's
|
||||
// russh connection — native-SSH panes only.
|
||||
let result = if remote.kind == RemoteKind::NativeSsh {
|
||||
match pane.ssh_connection() {
|
||||
Some(conn) => crate::daemon::ssh::SshManager::global()
|
||||
@@ -637,8 +431,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
|
||||
ClientMsg::DeleteKnownHost(id) => {
|
||||
let mut w = write_stream;
|
||||
// Best effort: a delete failure still returns the (unchanged) list so
|
||||
// the UI reflects reality rather than hanging.
|
||||
let _ = crate::daemon::ssh::known_hosts::delete(&id);
|
||||
let list = crate::daemon::ssh::known_hosts::list();
|
||||
DaemonMsg::KnownHostsList(list).encode(&mut w)?;
|
||||
@@ -726,9 +518,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
|
||||
ClientMsg::QueryProcs { pane_id } => {
|
||||
let mut w = write_stream;
|
||||
// An unknown/dead pane answers empty rather than `Error`: the details
|
||||
// panel polls while the user watches, and a pane closing mid-flight is
|
||||
// ordinary, not a failure worth surfacing.
|
||||
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
|
||||
DaemonMsg::Procs(procs).encode(&mut w)?;
|
||||
Ok(())
|
||||
@@ -741,18 +530,12 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A remote workspace has no pane here to address, so its forwards and
|
||||
// SFTP go through one envelope that names the connection instead
|
||||
//. The whole answer — including every failure — is built by
|
||||
// `ssh::workspace::handle`, so this arm stays a pipe.
|
||||
ClientMsg::OnWorkspace(req) => {
|
||||
let mut w = write_stream;
|
||||
crate::daemon::ssh::workspace::handle(&req).encode(&mut w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// `Input` / `Resize` / `Detach` as an opening message are meaningless (no
|
||||
// pane is bound yet); ignore and close.
|
||||
other => {
|
||||
log::debug!("unexpected opening message: {other:?}");
|
||||
Ok(())
|
||||
@@ -760,9 +543,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pane to its live native-SSH connection for a managed-forward request,
|
||||
/// or a human-readable reason it can't (wrong pane, PTY/compat pane, or a
|
||||
/// still-authenticating / dropped connection).
|
||||
fn forward_pane_connection(
|
||||
registry: &Registry,
|
||||
pane_id: u64,
|
||||
@@ -774,10 +554,6 @@ fn forward_pane_connection(
|
||||
.ok_or_else(|| "pane is not a ready native-ssh session".to_string())
|
||||
}
|
||||
|
||||
/// `Attach` path: subscribe the connection to an existing pane (sending the
|
||||
/// recorded `Size` + `Snapshot` + known cwd/prompt), then stream. Splitting
|
||||
/// this out keeps the `Spawn` path (which mustn't re-snapshot before its
|
||||
/// `Spawned` reply ordering) distinct from `Attach`.
|
||||
fn stream_pane_with_attach(
|
||||
pane: Arc<DaemonPane>,
|
||||
id: u64,
|
||||
@@ -790,10 +566,6 @@ fn stream_pane_with_attach(
|
||||
run_stream(pane, id, epoch, rx, read_stream, write_stream, registry)
|
||||
}
|
||||
|
||||
/// `Spawn` path: the pane was just created (empty ring), so attaching now sends an
|
||||
/// empty `Snapshot` (plus the spawn geometry as `Size`) — harmless, and it keeps
|
||||
/// the single attach code path. The `Spawned` reply has already been written by
|
||||
/// the caller.
|
||||
fn stream_pane(
|
||||
pane: Arc<DaemonPane>,
|
||||
id: u64,
|
||||
@@ -806,12 +578,6 @@ fn stream_pane(
|
||||
run_stream(pane, id, epoch, rx, read_stream, write_stream, registry)
|
||||
}
|
||||
|
||||
/// Drive the bidirectional stream for an attached pane:
|
||||
/// - a writer thread drains the pane→client `DaemonMsg` channel to the socket;
|
||||
/// - this thread reads further `ClientMsg`s (`Input` / `Resize` / `Detach` /
|
||||
/// `Kill`) until the client disconnects or detaches.
|
||||
/// On exit we detach (never kill — the pane lives on headless) unless the client
|
||||
/// explicitly asked to `Kill`.
|
||||
fn run_stream(
|
||||
pane: Arc<DaemonPane>,
|
||||
id: u64,
|
||||
@@ -821,27 +587,19 @@ fn run_stream(
|
||||
write_stream: Stream,
|
||||
registry: Arc<Registry>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Writer thread: pull daemon messages and frame them onto the socket. It ends
|
||||
// when the channel's senders are all dropped (pane detached / replaced) or a
|
||||
// socket write fails (client gone).
|
||||
let writer = spawn_writer(rx, write_stream, pane.gate());
|
||||
|
||||
// Reader loop: process client→daemon messages until disconnect/detach.
|
||||
let mut killed = false;
|
||||
loop {
|
||||
match ClientMsg::read(&mut read_stream) {
|
||||
Ok(ClientMsg::Input(bytes)) => pane.write_input(&bytes),
|
||||
Ok(ClientMsg::Resize(size)) => pane.resize(size),
|
||||
// The GUI's reply to a native-SSH auth/host-key prompt; route it to the
|
||||
// pane's prompt broker (a no-op for non-native panes).
|
||||
Ok(ClientMsg::AuthResponse {
|
||||
request_id,
|
||||
response,
|
||||
}) => pane.deliver_auth_response(request_id, response),
|
||||
Ok(ClientMsg::Detach) => break,
|
||||
Ok(ClientMsg::Kill { pane_id }) => {
|
||||
// Honor a kill for *this* pane; for another id, just remove+kill it
|
||||
// and keep streaming this one.
|
||||
if pane_id == id {
|
||||
killed = true;
|
||||
break;
|
||||
@@ -849,19 +607,11 @@ fn run_stream(
|
||||
other.kill();
|
||||
}
|
||||
}
|
||||
// Re-`Attach` / `Spawn` / `List` mid-stream aren't part of v1's single
|
||||
// connection-per-pane model; ignore.
|
||||
Ok(_) => {}
|
||||
// EOF / error == the client went away: detach.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Detach this connection from the pane so its reader stops sending to our
|
||||
// channel. `detach` drops the pane's `Sender`; with no senders left, the
|
||||
// writer thread's `rx.recv()` returns `Err` and it exits on its own. Join it so
|
||||
// the socket fd it holds is released before we return. `detach` also reports
|
||||
// whether the pane is now reclaimable (child already exited + no subscriber).
|
||||
let reclaimable = pane.detach(epoch);
|
||||
let _ = writer.join();
|
||||
|
||||
@@ -870,34 +620,13 @@ fn run_stream(
|
||||
p.kill();
|
||||
}
|
||||
} else if reclaimable {
|
||||
// The shell exited while we were attached; now that the last client is
|
||||
// leaving, drop the dead pane instead of leaving it (and its ~8 MiB ring,
|
||||
// PTY fds, and unreaped child) in the registry forever. A `!alive` pane is
|
||||
// never re-attached — clients spawn fresh for it — so this is invisible to
|
||||
// them. The `Arc` we still hold reaps the child when this frame returns.
|
||||
registry.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// While coalescing, stop growing a merged `Output` frame past this size. Big
|
||||
/// enough to turn a flood's ~1 KiB PTY reads into a few large frames per client
|
||||
/// wake, small enough to keep any single socket write (and the client's
|
||||
/// apply-under-lock for it) bounded.
|
||||
const OUTPUT_COALESCE_CAP: usize = 256 * 1024;
|
||||
|
||||
/// Spawn the per-connection writer thread that frames pane `DaemonMsg`s onto the
|
||||
/// socket. The thread self-terminates when its channel closes (all senders dropped
|
||||
/// — i.e. the pane detached us) or a socket write fails (client gone).
|
||||
///
|
||||
/// Consecutive `Output` messages already queued are merged into one frame (up to
|
||||
/// [`OUTPUT_COALESCE_CAP`]) before encoding. macOS PTYs hand the pane reader
|
||||
/// ~1 KiB per read, so a flood otherwise becomes thousands of tiny frames per
|
||||
/// second, and the *client* pays per frame (term lock + parser call + wakeup);
|
||||
/// merging here collapses that to a handful of large frames. Only what is
|
||||
/// already in the channel is drained — `try_recv` never waits — so a lone
|
||||
/// keystroke echo still goes out immediately, and ordering with non-`Output`
|
||||
/// messages (Cwd/Prompt/Exited…) is preserved.
|
||||
fn spawn_writer(
|
||||
rx: Receiver<DaemonMsg>,
|
||||
mut write_stream: Stream,
|
||||
@@ -906,16 +635,11 @@ fn spawn_writer(
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-daemon-writer".to_string())
|
||||
.spawn(move || {
|
||||
// On the visible-output path (PTY reader → here → client socket):
|
||||
// keep it off the efficiency cores.
|
||||
crate::core::threads::promote_to_user_interactive();
|
||||
// A non-Output message that interrupted a coalescing run, waiting
|
||||
// its turn behind the merged frame it arrived after.
|
||||
let mut carried: Option<DaemonMsg> = None;
|
||||
loop {
|
||||
let msg = match carried.take() {
|
||||
Some(m) => m,
|
||||
// Block on the channel until the next message (or close).
|
||||
None => match rx.recv() {
|
||||
Ok(m) => m,
|
||||
Err(_) => break,
|
||||
@@ -925,8 +649,6 @@ fn spawn_writer(
|
||||
while buf.len() < OUTPUT_COALESCE_CAP {
|
||||
match rx.try_recv() {
|
||||
Ok(DaemonMsg::Output(more)) => buf.extend_from_slice(&more),
|
||||
// A different message ends the run; it must be
|
||||
// written *after* the bytes that preceded it.
|
||||
Ok(other) => {
|
||||
carried = Some(other);
|
||||
break;
|
||||
@@ -938,9 +660,6 @@ fn spawn_writer(
|
||||
} else {
|
||||
msg
|
||||
};
|
||||
// Credit the gate whether the write succeeds or not: either
|
||||
// way the bytes leave the queue, and the reader must not stay
|
||||
// throttled against them.
|
||||
let drained = match &msg {
|
||||
DaemonMsg::Output(b) => b.len(),
|
||||
_ => 0,
|
||||
@@ -952,8 +671,6 @@ fn spawn_writer(
|
||||
if !write_ok {
|
||||
break;
|
||||
}
|
||||
// Flush so interactive output isn't held in a buffer (socket
|
||||
// writes are unbuffered, but be explicit/future-proof).
|
||||
let _ = write_stream.flush();
|
||||
}
|
||||
})
|
||||
@@ -972,11 +689,6 @@ mod tests {
|
||||
assert_eq!(reg.alloc_id(), 3);
|
||||
}
|
||||
|
||||
/// Pane ids are names, not slots: a fresh process must never re-mint an id
|
||||
/// the persisted tree still references, or a stale leaf aliases a new
|
||||
/// shell — the tree marks the wrong pane live, revival's re-registration
|
||||
/// is refused forever, and an attach by the old id steals another
|
||||
/// workspace's stream.
|
||||
#[test]
|
||||
fn pane_ids_never_alias_what_the_persisted_tree_references() {
|
||||
use crate::core::machine::{MachineStore, PaneSeed};
|
||||
@@ -991,14 +703,10 @@ mod tests {
|
||||
reg.seed_ids_past(&store.machine());
|
||||
assert_eq!(reg.alloc_id(), 8, "past the highest id the tree names");
|
||||
|
||||
// A seed can only move the counter forward.
|
||||
reg.seed_ids_past(&store.machine());
|
||||
assert_eq!(reg.alloc_id(), 9);
|
||||
}
|
||||
|
||||
/// A tree naming `u64::MAX` (a corrupted file, an absurd client seed)
|
||||
/// must not panic the daemon at startup: `max + 1` overflowed in a debug
|
||||
/// build, taking every pane on the machine down with a bookkeeping add.
|
||||
#[test]
|
||||
fn a_tree_naming_the_maximum_pane_id_does_not_panic_the_seed() {
|
||||
use crate::core::machine::{Machine, PaneRecord};
|
||||
@@ -1007,7 +715,6 @@ mod tests {
|
||||
workspaces: Vec::new(),
|
||||
panes: vec![PaneRecord::new(u64::MAX)],
|
||||
});
|
||||
// The counter parks at the ceiling — a bounded absurdity, not a crash.
|
||||
assert_eq!(reg.alloc_id(), u64::MAX);
|
||||
}
|
||||
|
||||
@@ -1019,10 +726,6 @@ mod tests {
|
||||
assert!(reg.list().is_empty());
|
||||
}
|
||||
|
||||
// The connection-dispatch tests drive `handle_conn` over a real socket pair,
|
||||
// exercising only the branches that need no PTY (List / Attach-miss / Kill /
|
||||
// unexpected-open) plus the writer thread. Unix-only: the Windows transport is
|
||||
// loopback TCP, which has no `pair()` helper.
|
||||
#[cfg(unix)]
|
||||
mod conn {
|
||||
use super::super::{OUTPUT_COALESCE_CAP, Registry, handle_conn, spawn_writer};
|
||||
@@ -1038,8 +741,6 @@ mod tests {
|
||||
cell_h: 17,
|
||||
};
|
||||
|
||||
/// Run `handle_conn` on the server end of a socket pair; hand back the client
|
||||
/// end plus the server thread's join handle.
|
||||
fn serve() -> (UnixStream, thread::JoinHandle<()>) {
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
let reg = Arc::new(Registry::new());
|
||||
@@ -1082,7 +783,6 @@ mod tests {
|
||||
ClientMsg::Kill { pane_id: 123 }
|
||||
.encode(&mut client)
|
||||
.unwrap();
|
||||
// Kill as the opening message produces no reply — the server just closes.
|
||||
assert!(DaemonMsg::read(&mut client).is_err());
|
||||
h.join().unwrap();
|
||||
}
|
||||
@@ -1090,7 +790,6 @@ mod tests {
|
||||
#[test]
|
||||
fn unexpected_opening_message_is_ignored_and_closed() {
|
||||
let (mut client, h) = serve();
|
||||
// A `Resize` with no pane bound is meaningless; the server closes cleanly.
|
||||
ClientMsg::Resize(SIZE).encode(&mut client).unwrap();
|
||||
assert!(DaemonMsg::read(&mut client).is_err());
|
||||
h.join().unwrap();
|
||||
@@ -1113,27 +812,16 @@ mod tests {
|
||||
DaemonMsg::Exited { code: Some(0) }
|
||||
);
|
||||
|
||||
// Dropping the last sender ends the writer thread on its own.
|
||||
drop(tx);
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
/// Consecutive `Output`s already sitting in the channel leave the socket
|
||||
/// as a *single* merged frame with their bytes concatenated in order —
|
||||
/// the coalescing that collapses a flood's thousands of ~1 KiB PTY reads
|
||||
/// into a few large frames. The messages are queued (and the sender
|
||||
/// dropped) *before* the writer spawns, so all three are guaranteed
|
||||
/// visible inside one `recv` + `try_recv` window; a regression back to
|
||||
/// frame-per-message would deliver `Output("one")` first and fail the
|
||||
/// first assertion.
|
||||
#[test]
|
||||
fn spawn_writer_coalesces_queued_outputs_into_one_frame() {
|
||||
let (tx, rx) = mpsc::channel::<DaemonMsg>();
|
||||
tx.send(DaemonMsg::Output(b"one".to_vec())).unwrap();
|
||||
tx.send(DaemonMsg::Output(b"two".to_vec())).unwrap();
|
||||
tx.send(DaemonMsg::Output(b"three".to_vec())).unwrap();
|
||||
// Close the channel up front: the writer drains the backlog and then
|
||||
// exits, so the EOF below proves nothing trailed the merged frame.
|
||||
drop(tx);
|
||||
|
||||
let (mut client, server) = UnixStream::pair().unwrap();
|
||||
@@ -1144,22 +832,12 @@ mod tests {
|
||||
DaemonMsg::Output(b"onetwothree".to_vec()),
|
||||
"queued Outputs must merge into one frame, bytes in send order"
|
||||
);
|
||||
// EOF, not another frame: the three messages became exactly one.
|
||||
assert!(DaemonMsg::read(&mut client).is_err());
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
/// A queued `Output` backlog larger than `OUTPUT_COALESCE_CAP` is split
|
||||
/// into multiple frames — every byte delivered, in order — rather than
|
||||
/// merged into one unbounded write. The cap is checked before each
|
||||
/// append, so a frame may overshoot it by at most one message; anything
|
||||
/// bigger means the cap stopped bounding socket writes (and the
|
||||
/// client's apply-under-lock per frame).
|
||||
#[test]
|
||||
fn spawn_writer_splits_output_backlog_at_the_coalesce_cap() {
|
||||
// Six 64 KiB chunks: 384 KiB total against the 256 KiB cap. Each is
|
||||
// filled with a distinct byte so the concatenation check below also
|
||||
// proves the split kept the chunks in order.
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
let chunks: Vec<Vec<u8>> = (0u8..6).map(|i| vec![i; CHUNK]).collect();
|
||||
let expected: Vec<u8> = chunks.concat();
|
||||
@@ -1178,7 +856,6 @@ mod tests {
|
||||
match DaemonMsg::read(&mut client) {
|
||||
Ok(DaemonMsg::Output(bytes)) => frames.push(bytes),
|
||||
Ok(other) => panic!("expected only Output frames, got {other:?}"),
|
||||
// EOF: the writer drained the backlog and exited.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
@@ -1199,11 +876,6 @@ mod tests {
|
||||
assert_eq!(frames.concat(), expected, "no bytes lost or reordered");
|
||||
}
|
||||
|
||||
/// A non-`Output` message queued between `Output`s goes out in its
|
||||
/// original position: it ends the coalescing run, and the `Output`s on
|
||||
/// either side of it must not merge across it. Guards the `carried`
|
||||
/// handoff — dropping or reordering the interrupting message would tell
|
||||
/// the client (say) the shell exited around the wrong bytes.
|
||||
#[test]
|
||||
fn spawn_writer_does_not_coalesce_outputs_across_a_non_output_message() {
|
||||
let (tx, rx) = mpsc::channel::<DaemonMsg>();
|
||||
@@ -1233,35 +905,14 @@ mod tests {
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
/// A dead client (socket write fails) ends the writer thread even while
|
||||
/// the pane-side sender is still alive — otherwise every disconnect
|
||||
/// would leave a writer parked in `recv()` until the pane detached it,
|
||||
/// and `run_stream`'s join of the writer would inherit that wait.
|
||||
#[test]
|
||||
fn spawn_writer_exits_on_write_failure_while_sender_is_alive() {
|
||||
let (tx, rx) = mpsc::channel::<DaemonMsg>();
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
let writer = spawn_writer(rx, server, Arc::new(crate::daemon::pane::OutputGate::new()));
|
||||
|
||||
// Kill the client end first, then hand the writer messages: an
|
||||
// encode hits a broken pipe and the thread must bail on its own.
|
||||
drop(client);
|
||||
|
||||
// Bounded poll rather than a bare `join()`: the sender stays alive
|
||||
// for the whole wait, so only the write-failure path can finish the
|
||||
// thread — and a regression fails in bounded time instead of
|
||||
// hanging. The bound is generous because it is only a hang-catcher:
|
||||
// the passing case finishes in microseconds, while a loaded machine
|
||||
// running the whole suite in parallel can leave this thread
|
||||
// unscheduled for seconds. A tight bound turns that into a flake
|
||||
// that says nothing about the behaviour under test.
|
||||
//
|
||||
// Kept fed rather than sent one message: the first write into a
|
||||
// freshly-closed socket can *succeed* (the kernel has not
|
||||
// processed the peer's close yet, especially under load), and a
|
||||
// writer that swallowed it would park in `recv()` for the rest of
|
||||
// the deadline. Only a later write is guaranteed to see the
|
||||
// broken pipe, so the loop keeps offering them.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while !writer.is_finished() && std::time::Instant::now() < deadline {
|
||||
let _ = tx.send(DaemonMsg::Output(b"into the void".to_vec()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,3 @@
|
||||
//! GUI-side daemon launcher: make sure the persistent terminal daemon is running
|
||||
//! before the GUI tries to connect, auto-spawning it as a *detached* background
|
||||
//! process if it isn't.
|
||||
//!
|
||||
//! The daemon (`tty7 --daemon`, see `main.rs`) is a long-lived process that owns
|
||||
//! all PTYs and outlives the GUI. The GUI must not become its parent in any way
|
||||
//! that would let a GUI exit kill it, so we:
|
||||
//! - re-exec our own binary with `--daemon` (and the same `--config-dir`, so the
|
||||
//! spawned daemon shares the GUI's config-dir-isolated endpoint — dev and prod
|
||||
//! deliberately run separate daemons);
|
||||
//! - detach the child from the GUI's process group/session (`setsid()` on Unix;
|
||||
//! `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags on Windows);
|
||||
//! - give it no console of its own (std streams → the null device);
|
||||
//! - never `wait()` on it (it's meant to run forever).
|
||||
//! Then we poll the endpoint until it's connectable, so the caller can immediately
|
||||
//! proceed to connect.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -24,59 +7,27 @@ use crate::core::config;
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, PROTOCOL_VERSION};
|
||||
use crate::daemon::{pidfile, transport};
|
||||
|
||||
/// How long to wait for a freshly spawned daemon to start listening before we
|
||||
/// give up. Generous enough to cover a cold process start, short enough that a
|
||||
/// genuinely-broken daemon surfaces as an error quickly rather than hanging the
|
||||
/// GUI launch.
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Poll interval while waiting for the socket to come up.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
/// How long the version handshake with an already-running daemon may take.
|
||||
/// Local socket, tiny reply — a daemon that can't answer within this is wedged
|
||||
/// (or so old it dropped the connection), and gets replaced either way.
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// How long to wait for the old daemon to exit after we ask it to shut down.
|
||||
/// Generous on purpose: the daemon hangs up every pane's child (a ~200 ms SIGHUP
|
||||
/// grace each) before it exits, so a session with several panes needs a moment.
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
/// How long a SIGTERMed daemon gets to finish its graceful teardown (same
|
||||
/// per-pane SIGHUP grace as above) before we escalate to SIGKILL.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const REAP_TERM_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
/// How long a SIGKILLed daemon gets to disappear from the process table.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const REAP_KILL_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A live daemon `ensure_running` reused *despite* a protocol mismatch: killing
|
||||
/// it would end every persisted session, and the mismatch may well be benign
|
||||
/// for the messages actually exercised — so that call is the user's to make,
|
||||
/// not startup's. Recorded here and consumed by the first window
|
||||
/// ([`take_mismatched_daemon`]), which raises a keep-or-restart prompt.
|
||||
pub struct MismatchedDaemon {
|
||||
/// What the daemon answered, or `None` for one so old it predates the
|
||||
/// `Version` request entirely.
|
||||
pub version: Option<DaemonVersion>,
|
||||
}
|
||||
|
||||
static MISMATCHED_DAEMON: std::sync::Mutex<Option<MismatchedDaemon>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// The protocol mismatch recorded by [`ensure_running`] this launch, if any.
|
||||
/// Take-semantics so the prompt fires once per launch, not per window.
|
||||
pub fn take_mismatched_daemon() -> Option<MismatchedDaemon> {
|
||||
MISMATCHED_DAEMON.lock().ok()?.take()
|
||||
}
|
||||
|
||||
/// What the version handshake learned about the daemon currently serving this
|
||||
/// process's endpoint. Refreshed by every [`ensure_running`] — including the
|
||||
/// one `RemoteTerminal`'s spawn retry runs after a daemon death — and cleared
|
||||
/// when the daemon predates the handshake, so a reader never acts on the
|
||||
/// identity of a daemon that is no longer the one answering.
|
||||
static LOCAL_DAEMON: std::sync::Mutex<Option<DaemonVersion>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Whether the serving daemon advertises `feature`
|
||||
/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when
|
||||
/// nothing is known — the safe answer, because every capability gated on this
|
||||
/// has a legacy fallback.
|
||||
pub fn local_daemon_supports(feature: &str) -> bool {
|
||||
LOCAL_DAEMON
|
||||
.lock()
|
||||
@@ -91,34 +42,14 @@ fn note_local_daemon(version: Option<DaemonVersion>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a live daemon answered the version handshake.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum VersionProbe {
|
||||
/// It replied: it knows the handshake, at this dialect.
|
||||
Speaks(DaemonVersion),
|
||||
/// It hung up (or answered garbage) — a daemon from before the `Version`
|
||||
/// request existed errors on the unknown kind and drops the connection.
|
||||
/// Alive and serving its panes fine; just an older dialect.
|
||||
Legacy,
|
||||
/// It kept the connection open but never answered within
|
||||
/// [`HANDSHAKE_TIMEOUT`] (or the write itself failed): wedged. Unlike
|
||||
/// `Legacy`, this daemon can't serve anything — replace it outright.
|
||||
Unresponsive,
|
||||
}
|
||||
|
||||
/// Ensure a daemon is running for this process's config dir, spawning a detached
|
||||
/// one if needed. Returns `Ok(())` once the endpoint is connectable; `Err` if the
|
||||
/// endpoint can't be resolved or the daemon never came up within
|
||||
/// [`STARTUP_TIMEOUT`].
|
||||
pub fn ensure_running() -> anyhow::Result<()> {
|
||||
// Fast path: a live daemon answers `connect`. The daemon outlives the GUI
|
||||
// binary, so after an app upgrade the running daemon may be an older build
|
||||
// whose wire dialect differs. That daemon still holds every persisted
|
||||
// session, so we don't kill it here: reuse it, record the mismatch, and let
|
||||
// the first window ask the user whether to keep it or restart clean
|
||||
// (`take_mismatched_daemon`). Only a daemon that can't answer at all —
|
||||
// wedged mid-handshake — is replaced outright, since it can't serve its
|
||||
// panes either way.
|
||||
if let Ok(mut stream) = transport::connect() {
|
||||
match query_daemon_version(&mut stream) {
|
||||
VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => {
|
||||
@@ -133,8 +64,6 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
||||
v.protocol,
|
||||
PROTOCOL_VERSION
|
||||
);
|
||||
// Still the serving daemon: its identity and capability list
|
||||
// are true regardless of the dialect gap.
|
||||
note_local_daemon(Some(v.clone()));
|
||||
if let Ok(mut slot) = MISMATCHED_DAEMON.lock() {
|
||||
*slot = Some(MismatchedDaemon { version: Some(v) });
|
||||
@@ -155,26 +84,12 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
||||
log::info!("daemon did not answer the version handshake; restarting it");
|
||||
note_local_daemon(None);
|
||||
drop(stream);
|
||||
// `stop` shuts the old daemon down gracefully (`Shutdown`
|
||||
// predates versioning, so even the oldest daemon honors it),
|
||||
// escalating to a pid-based reap if it won't go, and clears the
|
||||
// endpoint marker.
|
||||
stop();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nobody answers — but "unreachable" is not "gone". If the pidfile
|
||||
// records a daemon that is still alive (wedged, or one whose endpoint
|
||||
// was lost), its panes are already beyond reach; reap it before
|
||||
// claiming the endpoint so it can't linger forever holding every
|
||||
// pane's PTY and children.
|
||||
reap_recorded_daemon();
|
||||
|
||||
// If an endpoint marker is sitting there, it's a stale leftover from a
|
||||
// crashed daemon (a *live* one would have answered the connect above),
|
||||
// so clear it. The daemon's own `run()` clears stale endpoints too, but
|
||||
// doing it here means our post-spawn polling connects on the first try
|
||||
// instead of racing the daemon's cleanup.
|
||||
if transport::endpoint_exists() {
|
||||
transport::remove_stale_endpoint();
|
||||
}
|
||||
@@ -182,15 +97,9 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
||||
|
||||
spawn_detached()?;
|
||||
|
||||
// Wait for the daemon to bind + start accepting. We re-probe with `connect`
|
||||
// rather than just checking for the endpoint marker, since the marker appears
|
||||
// (via `bind`) slightly before the accept loop is ready.
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
loop {
|
||||
if let Ok(mut stream) = transport::connect() {
|
||||
// Capture the fresh daemon's identity (instance + features). It is
|
||||
// our own build, but asking beats assuming — and this is the only
|
||||
// handshake a cold start ever runs.
|
||||
match query_daemon_version(&mut stream) {
|
||||
VersionProbe::Speaks(v) => note_local_daemon(Some(v)),
|
||||
_ => note_local_daemon(None),
|
||||
@@ -208,11 +117,6 @@ pub fn ensure_running() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a freshly connected daemon which protocol version it speaks, and
|
||||
/// classify every way that can go (see [`VersionProbe`]). The split that
|
||||
/// matters: a *hangup* is how a pre-versioning daemon reacts to the unknown
|
||||
/// kind — it's healthy, keep it; a *timeout* is a daemon that can't process
|
||||
/// messages at all — replace it.
|
||||
fn query_daemon_version(stream: &mut transport::Stream) -> VersionProbe {
|
||||
use std::io::Write as _;
|
||||
|
||||
@@ -234,48 +138,21 @@ fn query_daemon_version(stream: &mut transport::Stream) -> VersionProbe {
|
||||
{
|
||||
VersionProbe::Unresponsive
|
||||
}
|
||||
// EOF/reset (the pre-versioning hangup) — and, conservatively, any
|
||||
// other well-formed-but-unexpected reply: the daemon is alive enough
|
||||
// to answer, so it stays the user's call.
|
||||
_ => VersionProbe::Legacy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the daemon: ask the running one to shut down — which hangs up every
|
||||
/// live shell — wait for it to exit, then spawn a fresh one. Returns once the new
|
||||
/// daemon is listening.
|
||||
///
|
||||
/// The GUI exposes this as "Restart Background Service": a long-lived daemon
|
||||
/// process keeps whatever environment it started with, so a change it can't pick
|
||||
/// up live only takes effect on restart — a macOS permission granted after launch
|
||||
/// (e.g. Full Disk Access), or an updated PATH / env on any platform — and
|
||||
/// quitting/reopening the GUI alone doesn't touch the detached daemon. Safe with
|
||||
/// no daemon running — it just spawns a fresh one.
|
||||
pub fn restart() -> anyhow::Result<()> {
|
||||
stop();
|
||||
ensure_running()
|
||||
}
|
||||
|
||||
/// Stop the running daemon and leave nothing running: ask it to shut down —
|
||||
/// which hangs up every live shell — wait for it to exit, escalate to a
|
||||
/// pid-based reap if it won't, and clear its endpoint marker. A no-op when no
|
||||
/// daemon is running. Unlike [`restart`], this does not spawn a replacement.
|
||||
///
|
||||
/// This backs both the GUI's restart (which calls it, then respawns) and the
|
||||
/// `--stop-daemon` CLI entry point the Windows installer/uninstaller runs before
|
||||
/// replacing or deleting `tty7.exe`: the detached daemon is the running image of
|
||||
/// that same file, so Windows locks it until the daemon exits. Stopping it here
|
||||
/// releases the lock so the install/uninstall can overwrite/remove the binary.
|
||||
pub fn stop() {
|
||||
use std::io::Write as _;
|
||||
|
||||
// Ask a running daemon to stop. Best effort: a failed connect/write means
|
||||
// nothing is listening, so we fall through to the reap/clear below.
|
||||
if let Ok(mut stream) = transport::connect() {
|
||||
if ClientMsg::Shutdown.encode(&mut stream).is_ok() {
|
||||
let _ = stream.flush();
|
||||
// The old daemon is gone once the endpoint stops answering (its
|
||||
// process exited and the listener closed). Poll until then, bounded.
|
||||
let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
|
||||
while Instant::now() < deadline && transport::connect().is_ok() {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
@@ -283,36 +160,17 @@ pub fn stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// If the old daemon is still alive here, `Shutdown` didn't stop it — a
|
||||
// binary that predates the message, or a wedged teardown. Stopping *means*
|
||||
// the old daemon must go: quietly claiming its endpoint while it lives is
|
||||
// how sessions got stranded (unreachable daemon, panes and children still
|
||||
// running — issue #42). Escalate by recorded pid.
|
||||
reap_recorded_daemon();
|
||||
|
||||
// The daemon removes its own endpoint marker on shutdown, but clear
|
||||
// defensively in case it was killed mid-teardown.
|
||||
if transport::endpoint_exists() {
|
||||
transport::remove_stale_endpoint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap the daemon recorded in the pidfile, if it is still alive: the caller
|
||||
/// has decided that daemon must go (it stopped answering, or a restart was
|
||||
/// ordered and `Shutdown` didn't stop it), and leaving it running while a new
|
||||
/// daemon claims the endpoint would strand it — alive, unreachable, and
|
||||
/// holding every pane's PTY and children.
|
||||
///
|
||||
/// Never trusts the pidfile blindly: the pid must still be alive *and* its
|
||||
/// executable basename must match our own (the daemon is this same binary),
|
||||
/// or the pid was recycled and the file is just stale — cleared, not killed.
|
||||
/// Always ends with the pidfile removed; the daemon we spawn next writes its
|
||||
/// own.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn reap_recorded_daemon() {
|
||||
let Some(pid) = pidfile::read() else { return };
|
||||
if pid <= 1 || pid == std::process::id() {
|
||||
// A pidfile naming init or ourselves is corrupt, not a daemon.
|
||||
pidfile::remove();
|
||||
return;
|
||||
}
|
||||
@@ -323,10 +181,6 @@ fn reap_recorded_daemon() {
|
||||
pidfile::remove();
|
||||
}
|
||||
|
||||
/// Whether `pid` is alive and runs an executable with the same basename as our
|
||||
/// own (GUI and daemon are the same `tty7` binary). This is the guard that
|
||||
/// keeps a stale pidfile — daemon crashed, pid recycled by some unrelated
|
||||
/// process — from getting an innocent process killed.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn process_matches_own_exe(pid: libc::pid_t) -> bool {
|
||||
let ours = std::env::current_exe()
|
||||
@@ -336,11 +190,6 @@ fn process_matches_own_exe(pid: libc::pid_t) -> bool {
|
||||
matches!((ours, theirs), (Some(a), Some(b)) if a == b)
|
||||
}
|
||||
|
||||
/// Terminate `pid` with escalation: SIGTERM first — a current daemon tears
|
||||
/// down like `Shutdown`, giving every pane's child its SIGHUP grace (see
|
||||
/// `server::serve_sigterm`) — then SIGKILL if it outlives the grace window.
|
||||
/// Best effort: if it still won't die (unkillable, e.g. stuck in the kernel),
|
||||
/// log and move on; the new daemon binds a fresh endpoint regardless.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn reap_process(pid: libc::pid_t) {
|
||||
if signal_and_await_exit(pid, libc::SIGTERM, REAP_TERM_TIMEOUT) {
|
||||
@@ -351,11 +200,8 @@ fn reap_process(pid: libc::pid_t) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `sig` to `pid` and poll until it exits or `timeout` elapses. Returns
|
||||
/// whether the process is gone.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration) -> bool {
|
||||
// SAFETY: plain kill(2); a dead/foreign pid just returns an error.
|
||||
unsafe { libc::kill(pid, sig) };
|
||||
let deadline = Instant::now() + timeout;
|
||||
while process_alive(pid) {
|
||||
@@ -367,27 +213,17 @@ fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration)
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `pid` exists and is ours to signal (`kill(pid, 0)`). A pid held by
|
||||
/// another user's process reads as "not alive" (EPERM) — correct for the reap
|
||||
/// paths, which must then leave it alone.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn process_alive(pid: libc::pid_t) -> bool {
|
||||
// SAFETY: signal 0 probes deliverability without delivering anything.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Windows reap: same contract as the Unix version, built on the `winproc`
|
||||
/// process-table helpers the panes already use for hangup. There is no signal
|
||||
/// to ask for a graceful teardown, so this mirrors `DaemonPane`'s Windows
|
||||
/// hangup order instead: terminate the daemon's descendants deepest-first
|
||||
/// (while their parent links are still live), then the daemon itself.
|
||||
#[cfg(windows)]
|
||||
fn reap_recorded_daemon() {
|
||||
use crate::daemon::winproc;
|
||||
|
||||
let Some(pid) = pidfile::read() else { return };
|
||||
if pid <= 4 || pid == std::process::id() {
|
||||
// System idle/System pids or ourselves: corrupt, not a daemon.
|
||||
pidfile::remove();
|
||||
return;
|
||||
}
|
||||
@@ -410,14 +246,9 @@ fn reap_recorded_daemon() {
|
||||
pidfile::remove();
|
||||
}
|
||||
|
||||
/// No process-table access on other platforms: the reap is a best-effort
|
||||
/// rescue, so takeover there just keeps the pre-pidfile behavior.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn reap_recorded_daemon() {}
|
||||
|
||||
/// Re-exec our own binary as a detached `--daemon`, inheriting the resolved
|
||||
/// config dir. The child is fully severed from the GUI: its own session/process
|
||||
/// group (so a GUI quit can't signal it) and null std streams (no console).
|
||||
fn spawn_detached() -> anyhow::Result<()> {
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| anyhow::anyhow!("could not locate own executable: {e}"))?;
|
||||
@@ -425,31 +256,20 @@ fn spawn_detached() -> anyhow::Result<()> {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("--daemon");
|
||||
|
||||
// Forward the *resolved* config dir so the daemon uses the same endpoint we
|
||||
// just probed. If nothing resolves we omit the flag and let the child apply
|
||||
// its own default resolution (env var / home dir).
|
||||
if let Some(dir) = config::config_dir_path() {
|
||||
cmd.arg("--config-dir").arg(dir);
|
||||
}
|
||||
|
||||
if let Some(shell) = detect_parent_shell() {
|
||||
// The detached daemon's parent becomes launchd/systemd, so capture the
|
||||
// shell that launched the GUI before detaching and let the pane builder
|
||||
// prefer it over a stale `$SHELL` / passwd login-shell value.
|
||||
cmd.env(crate::daemon::DETECTED_SHELL_ENV, shell);
|
||||
}
|
||||
|
||||
// A daemon has no controlling terminal or console: send all three std streams
|
||||
// to the null device so nothing inherits the GUI's handles.
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
detach(&mut cmd);
|
||||
|
||||
// Spawn and intentionally drop the handle without waiting: the daemon is a
|
||||
// long-lived process, not a child we reap. Dropping the `Child` doesn't kill
|
||||
// it (Rust never auto-kills on drop), and the detach above reparents it.
|
||||
match cmd.spawn() {
|
||||
Ok(_child) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!("failed to spawn daemon process: {e}")),
|
||||
@@ -476,16 +296,12 @@ fn is_supported_shell(path: &Path) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The executable path of an arbitrary live process, used both to recognize
|
||||
/// the shell that launched the GUI and to verify a pidfile's pid is still a
|
||||
/// tty7 daemon before reaping it.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn process_path(pid: libc::pid_t) -> Option<PathBuf> {
|
||||
if pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: valid buffer, and `proc_pidpath` writes at most `buf.len()` bytes.
|
||||
let len =
|
||||
unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
|
||||
if len <= 0 {
|
||||
@@ -504,19 +320,10 @@ fn process_path(pid: libc::pid_t) -> Option<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).ok()
|
||||
}
|
||||
|
||||
/// Detach the child into its own session/process group so a GUI teardown can't
|
||||
/// take the daemon down with it.
|
||||
#[cfg(unix)]
|
||||
fn detach(cmd: &mut Command) {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
// `setsid()` in the child (post-fork, pre-exec) detaches it into a brand-new
|
||||
// session + process group. Without this the daemon stays in the GUI's process
|
||||
// group and a session teardown (GUI quit, terminal close) could take it down
|
||||
// with us — exactly what a persistent daemon must avoid.
|
||||
//
|
||||
// SAFETY: `pre_exec` runs in the forked child before `exec`. `setsid` is
|
||||
// async-signal-safe and we touch no shared state here, so this is sound.
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
@@ -527,12 +334,6 @@ fn detach(cmd: &mut Command) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows analogue of the Unix `setsid` detach. `DETACHED_PROCESS` severs the
|
||||
/// child from the GUI's console, `CREATE_NEW_PROCESS_GROUP` puts it in its own
|
||||
/// group (so a Ctrl-C / group signal to the GUI doesn't reach it), and
|
||||
/// `CREATE_NO_WINDOW` stops a console window from flashing up for the headless
|
||||
/// daemon. These are the raw `CreateProcess` flag values (no `windows-sys`
|
||||
/// dependency needed for three constants).
|
||||
#[cfg(windows)]
|
||||
fn detach(cmd: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
@@ -544,8 +345,6 @@ fn detach(cmd: &mut Command) {
|
||||
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
// The stale-endpoint assertion is Unix-socket specific (Windows uses a loopback
|
||||
// port file with different semantics), so this test only runs on Unix.
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -564,14 +363,6 @@ mod tests {
|
||||
assert!(!is_supported_shell(Path::new("/usr/bin/omp")));
|
||||
}
|
||||
|
||||
/// The reap guard: a live process whose executable is *not* ours must never
|
||||
/// match — this is what keeps a stale pidfile with a recycled pid from
|
||||
/// getting an innocent process killed. Driven with a real `sleep` child:
|
||||
/// alive, path readable, basename `sleep` ≠ the test binary's.
|
||||
///
|
||||
/// `spawn` returns after the fork, possibly before the child has exec'd —
|
||||
/// until then its executable path still reads as *this* test binary — so
|
||||
/// the path assertions poll until the exec is visible.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn reap_guard_rejects_a_live_process_of_another_executable() {
|
||||
@@ -604,11 +395,6 @@ mod tests {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// Escalation actually terminates a process that ignores the polite signal:
|
||||
/// `sleep` dies to the SIGTERM leg already, and the poll must observe the
|
||||
/// exit and report it. The child is reaped concurrently because a zombie
|
||||
/// still answers `kill(pid, 0)` — in production the daemon is launchd's
|
||||
/// child and vanishes on death, which is what the wait thread simulates.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn signal_and_await_exit_observes_the_death_it_caused() {
|
||||
@@ -629,8 +415,6 @@ mod tests {
|
||||
reaper.join().unwrap();
|
||||
}
|
||||
|
||||
/// A dead pid reads as not-alive, so the reap paths treat its pidfile as
|
||||
/// stale and clear it without signalling anything.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn process_alive_is_false_once_the_process_is_gone() {
|
||||
@@ -644,9 +428,6 @@ mod tests {
|
||||
assert!(!process_alive(pid));
|
||||
}
|
||||
|
||||
/// The handshake against a current daemon: the peer answers `Version` and
|
||||
/// the client reads it back. Driven over a socketpair so no real daemon is
|
||||
/// needed — `query_daemon_version` only sees a `Stream`.
|
||||
#[test]
|
||||
fn version_handshake_reads_a_matching_reply() {
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, PROTOCOL_VERSION};
|
||||
@@ -675,17 +456,12 @@ mod tests {
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
/// The handshake against a pre-versioning daemon: it reads an unknown kind
|
||||
/// and drops the connection without replying. That must classify as
|
||||
/// `Legacy` — a healthy daemon on an older dialect, the user's call to
|
||||
/// keep or replace — not hang, panic, or read as wedged.
|
||||
#[test]
|
||||
fn version_handshake_treats_a_hangup_as_legacy() {
|
||||
use crate::daemon::protocol::ClientMsg;
|
||||
|
||||
let (mut client, mut daemon) = UnixStream::pair().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
// An old daemon errors on the unknown kind and closes the socket.
|
||||
let _ = ClientMsg::read(&mut daemon);
|
||||
drop(daemon);
|
||||
});
|
||||
@@ -694,15 +470,9 @@ mod tests {
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
/// The handshake against a wedged daemon: the peer accepts the request but
|
||||
/// never answers. The read must time out ([`HANDSHAKE_TIMEOUT`]) and
|
||||
/// classify as `Unresponsive` — the one case `ensure_running` replaces the
|
||||
/// daemon without asking, since it can't serve its panes anyway.
|
||||
#[test]
|
||||
fn version_handshake_treats_silence_as_unresponsive() {
|
||||
let (mut client, daemon) = UnixStream::pair().unwrap();
|
||||
// Keep the daemon end open (no reply, no hangup) until the client
|
||||
// gives up.
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
query_daemon_version(&mut client),
|
||||
@@ -712,19 +482,11 @@ mod tests {
|
||||
drop(daemon);
|
||||
}
|
||||
|
||||
/// A stale socket file (one nothing is listening on) must be treated as "not
|
||||
/// running": connecting to it fails, which is our trigger to clean up + spawn.
|
||||
/// We assert the failure kind so the stale-cleanup branch stays exercised even
|
||||
/// without actually launching a process.
|
||||
#[test]
|
||||
fn connect_to_stale_socket_path_fails() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-spawn-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("daemon.sock");
|
||||
// No listener was ever bound here, so the file doesn't exist and connect
|
||||
// must fail (NotFound). If a leftover file existed with no listener it'd be
|
||||
// ConnectionRefused — both are non-`Ok`, which is all `ensure_running`
|
||||
// relies on to decide "spawn a fresh daemon".
|
||||
let err = UnixStream::connect(&path).unwrap_err();
|
||||
assert!(matches!(
|
||||
err.kind(),
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
//! The authentication flow for a native SSH connection.
|
||||
//!
|
||||
//! Ordering follows the Tabby reference (brief §2): a leading `none` probe (which
|
||||
//! also learns the server's remaining methods), then — for `Auto` — gssapi-with-mic
|
||||
//! (matching OpenSSH's default preference), publickey, agent, password,
|
||||
//! keyboard-interactive; a non-`Auto` mode restricts attempts to
|
||||
//! that one family. The server's advertised remaining-methods set gates which
|
||||
//! families are worth trying and is refreshed after each failure (only when the
|
||||
//! server actually sends a non-empty set). Passwords/passphrases come from the
|
||||
//! spec (pre-resolved from the keychain by the GUI) or, failing that, from the
|
||||
//! [`PromptBroker`]. Secrets are never logged.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
@@ -27,8 +15,6 @@ use crate::daemon::protocol::{AuthPromptKind, AuthResponse, KiPrompt, NativeSshS
|
||||
use super::broker::PromptBroker;
|
||||
use super::handler::ClientHandler;
|
||||
|
||||
/// Attempt authentication. `Ok(())` = authenticated; `Err(reason)` carries a
|
||||
/// user-facing reason for `SshStatus::Failed` (never a secret).
|
||||
pub async fn authenticate(
|
||||
handle: &mut Handle<ClientHandler>,
|
||||
spec: &NativeSshSpec,
|
||||
@@ -36,8 +22,6 @@ pub async fn authenticate(
|
||||
) -> Result<(), String> {
|
||||
let user = spec.user.clone();
|
||||
|
||||
// A `none` probe: some servers accept it, and either way it learns the
|
||||
// server's advertised remaining methods.
|
||||
let mut remaining = match handle
|
||||
.authenticate_none(&user)
|
||||
.await
|
||||
@@ -52,8 +36,6 @@ pub async fn authenticate(
|
||||
let mut last_reason = "authentication failed".to_string();
|
||||
|
||||
for family in method_order(spec.auth_mode) {
|
||||
// Respect the server's advertised set when it told us one: skip families
|
||||
// it won't accept. An empty set means "unknown" — try anyway.
|
||||
if !remaining.is_empty() && !remaining.contains(&family) {
|
||||
continue;
|
||||
}
|
||||
@@ -62,8 +44,6 @@ pub async fn authenticate(
|
||||
MethodKind::PublicKey => try_publickeys(handle, spec, broker).await,
|
||||
MethodKind::KeyboardInteractive => try_keyboard_interactive(handle, spec, broker).await,
|
||||
MethodKind::Password => try_password(handle, spec, broker).await,
|
||||
// Agent is folded into the publickey pass below via a distinct marker;
|
||||
// handled in `method_order` expansion.
|
||||
_ => Outcome::Skipped,
|
||||
};
|
||||
match outcome {
|
||||
@@ -88,10 +68,6 @@ pub async fn authenticate(
|
||||
Err(last_reason)
|
||||
}
|
||||
|
||||
/// The ordered families to try for a given auth mode. `Agent` is represented as a
|
||||
/// publickey attempt (it *is* publickey, signed by the agent), so it isn't a
|
||||
/// separate `MethodKind`; `try_publickeys` covers both files and agent for `Auto`
|
||||
/// and for the explicit `Agent`/`PublicKey` modes via `spec.auth_mode`.
|
||||
fn method_order(mode: SshAuthMode) -> Vec<MethodKind> {
|
||||
match mode {
|
||||
SshAuthMode::Auto => vec![
|
||||
@@ -189,10 +165,6 @@ impl GssapiAuthenticator for GssapiClient {
|
||||
mic: Some(mic.to_vec()),
|
||||
})
|
||||
} else {
|
||||
// An incomplete context that produced no token to send is a stalled
|
||||
// exchange; claiming completion here would send the server a MIC-less
|
||||
// exchange-complete it will reject with an opaque failure. Error out
|
||||
// instead so the real cause reaches the user.
|
||||
let Some(token) = output else {
|
||||
return Err(GssapiAuthError::Other(
|
||||
"gssapi context stalled: incomplete with no output token".to_string(),
|
||||
@@ -297,16 +269,6 @@ fn gssapi_service_hosts_blocking(host: &str) -> Vec<String> {
|
||||
gssapi_service_hosts_with_lookup(host, reverse_lookup_addr)
|
||||
}
|
||||
|
||||
/// Which host names to request a `host/<name>` Kerberos service ticket for: the
|
||||
/// host as typed, plus its reverse-DNS name when it was typed as a bare IP.
|
||||
///
|
||||
/// Deliberately gated on `unix` alone, **not** on `feature = "gssapi"`. It needs
|
||||
/// nothing from libgssapi — the caller injects the resolver — and gating it also
|
||||
/// gated its two unit tests, which then only ran because the GUI package enables
|
||||
/// `gssapi` and cargo unifies features across a `--workspace` test run. Narrowing
|
||||
/// to `cargo test -p tty7-core` (a bisect, a single-crate iteration) silently
|
||||
/// dropped them: green run, test never compiled. Without the feature the only
|
||||
/// caller is the test module below, hence the `allow`.
|
||||
#[cfg(unix)]
|
||||
#[cfg_attr(not(feature = "gssapi"), allow(dead_code))]
|
||||
fn gssapi_service_hosts_with_lookup(
|
||||
@@ -433,8 +395,6 @@ fn set_sockaddr_in6_len(addr: &mut libc::sockaddr_in6) {
|
||||
#[cfg(all(unix, feature = "gssapi"))]
|
||||
fn set_sockaddr_in6_len(_addr: &mut libc::sockaddr_in6) {}
|
||||
|
||||
/// Try identity files (unless mode is `Agent`) then the ssh-agent (unless mode is
|
||||
/// `PublicKey`), in that order.
|
||||
async fn try_publickeys(
|
||||
handle: &mut Handle<ClientHandler>,
|
||||
spec: &NativeSshSpec,
|
||||
@@ -490,8 +450,6 @@ async fn try_identity_file(
|
||||
Err(e) => return failed(format!("cannot read identity file {path}: {e}")),
|
||||
};
|
||||
|
||||
// `.pub` misconfiguration: if the file parses as a *public* key, the user
|
||||
// pointed us at the public half. Skip it with a warning rather than fail.
|
||||
if PublicKey::from_openssh(contents.trim()).is_ok() {
|
||||
log::warn!("identity file {path} is a public key; skipping");
|
||||
return Outcome::Skipped;
|
||||
@@ -500,8 +458,6 @@ async fn try_identity_file(
|
||||
let key = match russh::keys::decode_secret_key(&contents, None) {
|
||||
Ok(k) => k,
|
||||
Err(russh::keys::Error::KeyIsEncrypted) => {
|
||||
// Prefer a GUI-provided passphrase (keyed by the path as listed), else
|
||||
// prompt for one.
|
||||
let provided = spec
|
||||
.key_passphrases
|
||||
.as_ref()
|
||||
@@ -551,28 +507,20 @@ async fn try_identity_file(
|
||||
}
|
||||
|
||||
async fn try_agent(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) -> Outcome {
|
||||
// Agent transport is per-platform: a Unix-domain socket named by
|
||||
// SSH_AUTH_SOCK, or Windows OpenSSH's named pipe. The identity loop below
|
||||
// is shared via `try_agent_identities`, generic over the stream.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let agent = match AgentClient::connect_env().await {
|
||||
Ok(a) => a,
|
||||
// No agent available (SSH_AUTH_SOCK unset / unreachable): just skip.
|
||||
Err(_) => return Outcome::Skipped,
|
||||
};
|
||||
try_agent_identities(handle, spec, agent).await
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows OpenSSH's agent listens on a fixed named pipe; honor
|
||||
// SSH_AUTH_SOCK as an override for nonstandard setups. (A Cygwin/MSYS
|
||||
// socket *file* in that variable simply fails to open → skip.)
|
||||
let pipe = std::env::var("SSH_AUTH_SOCK")
|
||||
.unwrap_or_else(|_| r"\\.\pipe\openssh-ssh-agent".to_string());
|
||||
let agent = match AgentClient::connect_named_pipe(&pipe).await {
|
||||
Ok(a) => a,
|
||||
// No agent available: just skip.
|
||||
Err(_) => return Outcome::Skipped,
|
||||
};
|
||||
try_agent_identities(handle, spec, agent).await
|
||||
@@ -595,7 +543,6 @@ where
|
||||
for identity in identities {
|
||||
let pubkey: PublicKey = match &identity {
|
||||
AgentIdentity::PublicKey { key, .. } => key.clone(),
|
||||
// Certificate identities aren't handled in v1's agent path.
|
||||
AgentIdentity::Certificate { .. } => continue,
|
||||
};
|
||||
let hash_alg = rsa_hash_alg(&pubkey.algorithm());
|
||||
@@ -607,7 +554,6 @@ where
|
||||
Ok(AuthResult::Failure {
|
||||
remaining_methods, ..
|
||||
}) => last = Some(remaining_methods),
|
||||
// A signing error with this identity — try the next one.
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
@@ -622,20 +568,14 @@ async fn try_password(
|
||||
spec: &NativeSshSpec,
|
||||
broker: &Arc<PromptBroker>,
|
||||
) -> Outcome {
|
||||
// Try a spec-provided (keychain-resolved) password first.
|
||||
if let Some(pw) = &spec.password {
|
||||
match handle.authenticate_password(&spec.user, pw.clone()).await {
|
||||
Ok(AuthResult::Success) => return Outcome::Authenticated,
|
||||
Ok(AuthResult::Failure { .. }) => {
|
||||
// The stored password was explicitly rejected (FR-A6): re-prompt.
|
||||
// The GUI can treat a fresh prompt after a provided password as
|
||||
// "stored password rejected" and offer to overwrite it.
|
||||
}
|
||||
Ok(AuthResult::Failure { .. }) => {}
|
||||
Err(e) => return failed(format!("password auth error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt the user (possibly after a rejected stored password).
|
||||
let resp = broker
|
||||
.prompt(AuthPromptKind::Password {
|
||||
user: spec.user.clone(),
|
||||
@@ -671,11 +611,6 @@ async fn try_keyboard_interactive(
|
||||
Err(e) => return failed(format!("keyboard-interactive start error: {e}")),
|
||||
};
|
||||
|
||||
// Cap the round count (OpenSSH keeps a similar client-side device cap): a
|
||||
// hostile or looping server must not be able to spin this task forever with
|
||||
// zero-prompt or auto-filled requests. The stored password is auto-filled
|
||||
// once only — a server re-asking means it was rejected (PAM retries), so
|
||||
// later rounds fall through to prompting the user for the real one.
|
||||
const MAX_ROUNDS: u32 = 16;
|
||||
let mut rounds = 0u32;
|
||||
let mut stored_password_used = false;
|
||||
@@ -699,7 +634,6 @@ async fn try_keyboard_interactive(
|
||||
instructions,
|
||||
prompts,
|
||||
} => {
|
||||
// Zero-prompt request (OpenSSH quirk): reply with an empty answer.
|
||||
if prompts.is_empty() {
|
||||
resp = match handle
|
||||
.authenticate_keyboard_interactive_respond(Vec::new())
|
||||
@@ -738,11 +672,6 @@ async fn try_keyboard_interactive(
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer a keyboard-interactive info-request. When *every* prompt is a
|
||||
/// password-type field and a spec password is available (and this round may
|
||||
/// still use it — the first only; a re-ask means the server rejected it),
|
||||
/// auto-fill without bothering the GUI; otherwise surface the whole prompt set
|
||||
/// to the GUI.
|
||||
async fn collect_ki_answers(
|
||||
spec: &NativeSshSpec,
|
||||
broker: &Arc<PromptBroker>,
|
||||
@@ -776,15 +705,11 @@ async fn collect_ki_answers(
|
||||
.await;
|
||||
match resp {
|
||||
AuthResponse::Secrets(v) if v.len() == prompts.len() => Some(v),
|
||||
// A single-secret reply to a single prompt is also accepted.
|
||||
AuthResponse::Secret(s) if prompts.len() == 1 => Some(vec![s]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// RSA keys must be offered with a modern signature hash; russh maps `None` to
|
||||
/// legacy SHA-1 for RSA, so pick SHA-256. For all other key types `hash_alg` is
|
||||
/// ignored, so `None` is correct.
|
||||
fn rsa_hash_alg(algorithm: &Algorithm) -> Option<HashAlg> {
|
||||
if matches!(algorithm, Algorithm::Rsa { .. }) {
|
||||
Some(HashAlg::Sha256)
|
||||
@@ -793,7 +718,6 @@ fn rsa_hash_alg(algorithm: &Algorithm) -> Option<HashAlg> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand an identity-file path: `%h`→host, `%r`→user, and a leading `~/` → home.
|
||||
fn expand_identity_path(path: &str, host: &str, user: &str) -> String {
|
||||
let substituted = path.replace("%h", host).replace("%r", user);
|
||||
if let Some(rest) = substituted.strip_prefix("~/") {
|
||||
@@ -820,7 +744,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identity_path_expands_tokens_and_tilde() {
|
||||
// Tokens expand regardless of home resolution.
|
||||
let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy");
|
||||
assert_eq!(p, "/keys/deploy@example.com/id");
|
||||
}
|
||||
@@ -850,9 +773,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// `#[cfg(unix)]`, not `#[cfg(all(unix, feature = "gssapi"))]`: these exercise
|
||||
// pure host-list logic, so they must run under a plain
|
||||
// `cargo test -p tty7-core` too. See `gssapi_service_hosts_with_lookup`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gssapi_service_hosts_keep_original_host_before_reverse_dns() {
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
//! The interactive prompt broker: how the async russh auth/host-key flow reaches
|
||||
//! the GUI and blocks for an answer.
|
||||
//!
|
||||
//! During a native-SSH spawn the connect task needs decisions only the user can
|
||||
//! make (a password, a key passphrase, keyboard-interactive answers, a host-key
|
||||
//! confirmation). It emits a `DaemonMsg::AuthPrompt` over the pane's own
|
||||
//! connection and `.await`s a `oneshot` that `run_stream` fulfils when the
|
||||
//! matching `ClientMsg::AuthResponse` arrives (routed here through
|
||||
//! `DaemonPane::deliver_auth_response`). Status/banner frames are fire-and-forget.
|
||||
//!
|
||||
//! The broker is constructed by `DaemonPane` (which owns the subscriber the frames
|
||||
//! must reach) and handed an `emit` closure; keeping the type here puts it beside
|
||||
//! the auth code that drives it. Secrets returned in `AuthResponse` are never
|
||||
//! logged (its `Debug` redacts) and live only for the auth attempt.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -22,19 +7,11 @@ use tokio::sync::oneshot;
|
||||
|
||||
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, DaemonMsg, SshPhase};
|
||||
|
||||
/// How long an auth step waits for the user before failing cleanly.
|
||||
const PROMPT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// How long we keep re-offering a prompt frame while waiting for a subscriber to
|
||||
/// attach (the spawn's socket may not have finished attaching the instant the
|
||||
/// first prompt is ready). The frame is only actually sent once a subscriber
|
||||
/// exists, so this never duplicates a prompt in the GUI.
|
||||
const DELIVERY_WINDOW: Duration = Duration::from_secs(15);
|
||||
const DELIVERY_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
pub struct PromptBroker {
|
||||
/// Sends a `DaemonMsg` to the pane's *current* subscriber, returning whether
|
||||
/// one was present (and thus whether the frame actually went out). Provided by
|
||||
/// `DaemonPane`, which owns the subscriber behind its state lock.
|
||||
emit: Box<dyn Fn(DaemonMsg) -> bool + Send + Sync>,
|
||||
pending: Mutex<HashMap<u64, oneshot::Sender<AuthResponse>>>,
|
||||
next_id: AtomicU64,
|
||||
@@ -49,23 +26,15 @@ impl PromptBroker {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an interactive prompt is currently awaiting the user's reply.
|
||||
/// The connect watchdog reads this to stop billing the connect timeout
|
||||
/// while the user is thinking (e.g. reading a host-key fingerprint).
|
||||
pub fn has_pending(&self) -> bool {
|
||||
!self.pending.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// Send an interactive prompt to the GUI and block (async) for its reply.
|
||||
/// Returns [`AuthResponse::Cancelled`] on user cancel, timeout, or if no GUI
|
||||
/// ever attaches to receive it — every one of which fails the auth step
|
||||
/// cleanly rather than hanging the connection.
|
||||
pub async fn prompt(&self, kind: AuthPromptKind) -> AuthResponse {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
|
||||
// Deliver the frame, retrying only while no subscriber is attached yet.
|
||||
let frame = DaemonMsg::AuthPrompt {
|
||||
request_id: id,
|
||||
prompt: kind,
|
||||
@@ -97,7 +66,6 @@ impl PromptBroker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget server banner. No response is awaited.
|
||||
pub fn banner(&self, text: String) {
|
||||
let _ = (self.emit)(DaemonMsg::AuthPrompt {
|
||||
request_id: 0,
|
||||
@@ -105,13 +73,10 @@ impl PromptBroker {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget spawn-progress update.
|
||||
pub fn status(&self, phase: SshPhase) {
|
||||
let _ = (self.emit)(DaemonMsg::SshStatus { phase });
|
||||
}
|
||||
|
||||
/// Fulfil a pending prompt with the GUI's reply. Unknown ids are ignored (a
|
||||
/// late reply to a step that already timed out).
|
||||
pub fn deliver(&self, request_id: u64, response: AuthResponse) {
|
||||
if let Some(tx) = self.pending.lock().unwrap().remove(&request_id) {
|
||||
let _ = tx.send(response);
|
||||
@@ -129,7 +94,6 @@ mod tests {
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
// A always-succeeding emit sink; we ignore the frame and reply out of band.
|
||||
let broker = PromptBroker::new(Box::new(|_| true));
|
||||
rt.block_on(async {
|
||||
let b = broker.clone();
|
||||
@@ -137,7 +101,6 @@ mod tests {
|
||||
user: "u".into(),
|
||||
host: "h".into(),
|
||||
});
|
||||
// Reply to request id 1 (the first allocated) concurrently.
|
||||
let b2 = broker.clone();
|
||||
let replier = async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
@@ -155,7 +118,6 @@ mod tests {
|
||||
.start_paused(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
// An emit sink that never has a subscriber → never delivers.
|
||||
let broker = PromptBroker::new(Box::new(|_| false));
|
||||
rt.block_on(async {
|
||||
let resp = broker
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
//! Transport construction and russh `Config` for the native SSH engine.
|
||||
//!
|
||||
//! Every transport is reduced to a single [`Transport`] value implementing
|
||||
//! `AsyncRead + AsyncWrite`, which `russh::client::connect_stream` accepts:
|
||||
//!
|
||||
//! - **Direct** — a plain `TcpStream`.
|
||||
//! - **ProxyCommand** — spawn the command; its stdio is the transport. tty7
|
||||
//! substitutes `%h`/`%p`/`%r` itself (the gap Tabby left, PRD FR-C1 / #11058).
|
||||
//! - **SOCKS5 / HTTP CONNECT** — a `TcpStream` to the proxy, handshaked up to the
|
||||
//! target (no-auth SOCKS5; bare HTTP `CONNECT`), then used directly.
|
||||
//! - **Jump host** — a `direct-tcpip` channel opened on an already-authenticated
|
||||
//! jump [`SshConnection`], turned into a stream. Multi-level chains fall out of
|
||||
//! the manager establishing the jump connection recursively before calling here.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -25,8 +11,6 @@ use crate::daemon::protocol::{NativeSshSpec, SshAlgorithms, SshProxy};
|
||||
|
||||
use super::session::SshConnection;
|
||||
|
||||
/// A concrete transport stream for `connect_stream`. An enum (rather than a boxed
|
||||
/// trait object) so each variant's `AsyncRead`/`AsyncWrite` is a direct delegate.
|
||||
pub enum Transport {
|
||||
Tcp(TcpStream),
|
||||
Process(ProcessStream),
|
||||
@@ -77,30 +61,13 @@ impl AsyncWrite for Transport {
|
||||
}
|
||||
}
|
||||
|
||||
/// A spawned `ProxyCommand`'s stdio as one duplex stream. `kill_on_drop` reaps the
|
||||
/// process when the transport is dropped.
|
||||
pub struct ProcessStream {
|
||||
// Held so the child is reaped on drop; not otherwise read.
|
||||
_child: tokio::process::Child,
|
||||
/// `Option` so `poll_shutdown` can *drop* it.
|
||||
///
|
||||
/// This is the only way to half-close a pipe. `ChildStdin`'s own
|
||||
/// `poll_shutdown` returns `Ready(Ok(()))` without touching the file
|
||||
/// descriptor, so the child never sees EOF and keeps waiting for input that
|
||||
/// will never come — a `tty7-server --stdio` bridge would hang there
|
||||
/// forever instead of exiting. Closing the write half is a real operation
|
||||
/// and has to be modelled as one.
|
||||
stdin: Option<tokio::process::ChildStdin>,
|
||||
stdout: tokio::process::ChildStdout,
|
||||
}
|
||||
|
||||
impl ProcessStream {
|
||||
/// Assemble one from an already-spawned child and its taken pipes.
|
||||
///
|
||||
/// The fields stay private — a `ProcessStream` whose `_child` did not
|
||||
/// produce its own `stdin`/`stdout` would reap the wrong process on drop.
|
||||
/// `daemon::remote_link` needs this to wrap a `tty7-server --stdio` child
|
||||
/// the same way the `ProxyCommand` path wraps its own.
|
||||
pub fn from_parts(
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
@@ -113,7 +80,6 @@ impl ProcessStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// The write half, or a "already closed" error once it has been shut down.
|
||||
fn stdin_mut(&mut self) -> std::io::Result<&mut tokio::process::ChildStdin> {
|
||||
self.stdin.as_mut().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
@@ -148,16 +114,10 @@ impl AsyncWrite for ProcessStream {
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut().stdin_mut() {
|
||||
Ok(stdin) => Pin::new(stdin).poll_flush(cx),
|
||||
// Nothing buffered can remain once the half is closed.
|
||||
Err(_) => Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush, then **close** the write half by dropping the pipe.
|
||||
///
|
||||
/// Delegating to `ChildStdin::poll_shutdown` would be a no-op — it does not
|
||||
/// close the descriptor — so the peer would never reach EOF. Dropping is
|
||||
/// what actually closes it, which is why `stdin` is an `Option`.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
let Some(stdin) = this.stdin.as_mut() else {
|
||||
@@ -177,9 +137,6 @@ impl AsyncWrite for ProcessStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the transport for `spec`, given an already-established `jump` connection
|
||||
/// when the spec chains through one. Precedence mirrors OpenSSH/Tabby:
|
||||
/// ProxyCommand > jump host > SOCKS5 > HTTP > direct.
|
||||
pub async fn build_transport(
|
||||
spec: &NativeSshSpec,
|
||||
jump: Option<Arc<SshConnection>>,
|
||||
@@ -209,7 +166,6 @@ pub async fn build_transport(
|
||||
let stream = http_connect(host, *port, &spec.host, spec.port).await?;
|
||||
Ok(Transport::Tcp(stream))
|
||||
}
|
||||
// None (or Command, handled above): direct.
|
||||
_ => {
|
||||
let stream = TcpStream::connect((spec.host.as_str(), spec.port))
|
||||
.await
|
||||
@@ -238,9 +194,6 @@ fn spawn_proxy_command(
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
// The daemon is detached and has no console to lend this child, so without
|
||||
// the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a
|
||||
// console of its own that stays up for the whole session.
|
||||
crate::core::proc::hide_console_tokio(&mut cmd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
@@ -258,9 +211,6 @@ fn spawn_proxy_command(
|
||||
)))
|
||||
}
|
||||
|
||||
/// Split a ProxyCommand template into argv and substitute the OpenSSH tokens
|
||||
/// `%h` (host), `%p` (port), `%r` (remote user), and `%%` (a literal `%`). Public
|
||||
/// for unit testing.
|
||||
pub fn proxy_command_argv(template: &str, host: &str, port: u16, user: &str) -> Vec<String> {
|
||||
shell_split(template)
|
||||
.into_iter()
|
||||
@@ -278,7 +228,6 @@ fn substitute_tokens(tok: &str, host: &str, port: u16, user: &str) -> String {
|
||||
Some('p') => out.push_str(&port.to_string()),
|
||||
Some('r') => out.push_str(user),
|
||||
Some('%') => out.push('%'),
|
||||
// Unknown token: keep both characters verbatim.
|
||||
Some(other) => {
|
||||
out.push('%');
|
||||
out.push(other);
|
||||
@@ -292,8 +241,6 @@ fn substitute_tokens(tok: &str, host: &str, port: u16, user: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A minimal POSIX-ish word splitter for ProxyCommand: honors single quotes,
|
||||
/// double quotes, and backslash escaping; splits on unquoted whitespace.
|
||||
fn shell_split(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
@@ -336,7 +283,6 @@ fn shell_split(s: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// SOCKS5 CONNECT (no authentication) to `target:target_port` via `proxy`.
|
||||
async fn socks5_connect(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
@@ -348,14 +294,12 @@ async fn socks5_connect(
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("connect to SOCKS proxy {proxy_host}:{proxy_port} failed: {e}")
|
||||
})?;
|
||||
// Greeting: VER=5, one method, 0x00 = no auth.
|
||||
s.write_all(&[0x05, 0x01, 0x00]).await?;
|
||||
let mut reply = [0u8; 2];
|
||||
s.read_exact(&mut reply).await?;
|
||||
if reply[0] != 0x05 || reply[1] != 0x00 {
|
||||
anyhow::bail!("SOCKS5 proxy refused no-auth (got {reply:?})");
|
||||
}
|
||||
// CONNECT request with a domain-name address (ATYP=3).
|
||||
let host_bytes = target.as_bytes();
|
||||
if host_bytes.len() > 255 {
|
||||
anyhow::bail!("SOCKS5 target host too long");
|
||||
@@ -364,7 +308,6 @@ async fn socks5_connect(
|
||||
req.extend_from_slice(host_bytes);
|
||||
req.extend_from_slice(&target_port.to_be_bytes());
|
||||
s.write_all(&req).await?;
|
||||
// Reply: VER, REP, RSV, ATYP, BND.ADDR, BND.PORT.
|
||||
let mut head = [0u8; 4];
|
||||
s.read_exact(&mut head).await?;
|
||||
if head[1] != 0x00 {
|
||||
@@ -380,12 +323,11 @@ async fn socks5_connect(
|
||||
}
|
||||
other => anyhow::bail!("SOCKS5 unexpected bound ATYP {other}"),
|
||||
};
|
||||
let mut discard = vec![0u8; addr_len + 2]; // address + port
|
||||
let mut discard = vec![0u8; addr_len + 2];
|
||||
s.read_exact(&mut discard).await?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// HTTP `CONNECT` tunnel to `target:target_port` via `proxy`.
|
||||
async fn http_connect(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
@@ -401,8 +343,6 @@ async fn http_connect(
|
||||
"CONNECT {target}:{target_port} HTTP/1.1\r\nHost: {target}:{target_port}\r\nProxy-Connection: keep-alive\r\n\r\n"
|
||||
);
|
||||
s.write_all(req.as_bytes()).await?;
|
||||
// Read until the end of headers (\r\n\r\n). Bounded so a hostile proxy can't
|
||||
// make us buffer without limit.
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
@@ -428,8 +368,6 @@ async fn http_connect(
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Build the russh client config from the spec: keepalive, and algorithm
|
||||
/// preferences (empty list per family = russh's secure default for that family).
|
||||
pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> {
|
||||
let mut cfg = russh::client::Config {
|
||||
preferred: build_preferred(&spec.algorithms),
|
||||
@@ -444,10 +382,6 @@ pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> {
|
||||
Arc::new(cfg)
|
||||
}
|
||||
|
||||
/// Start from russh's default preference and override only the families the user
|
||||
/// specified. Unparseable entries are dropped; if a user list parses to nothing,
|
||||
/// that family keeps the default rather than becoming empty (which would offer no
|
||||
/// algorithms and fail negotiation).
|
||||
fn build_preferred(a: &SshAlgorithms) -> russh::Preferred {
|
||||
let mut p = russh::Preferred::DEFAULT;
|
||||
if !a.kex.is_empty() {
|
||||
@@ -540,7 +474,6 @@ mod tests {
|
||||
fn build_preferred_keeps_defaults_for_empty_lists() {
|
||||
let a = SshAlgorithms::default();
|
||||
let p = build_preferred(&a);
|
||||
// Empty spec → unchanged russh default.
|
||||
assert_eq!(p.kex, russh::Preferred::DEFAULT.kex);
|
||||
assert_eq!(p.cipher, russh::Preferred::DEFAULT.cipher);
|
||||
}
|
||||
@@ -552,7 +485,6 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let p = build_preferred(&a);
|
||||
// The unknown entry is filtered; only the known one is applied.
|
||||
let aes = russh::cipher::Name::try_from("aes256-ctr").unwrap();
|
||||
assert_eq!(p.cipher.as_ref(), &[aes]);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,3 @@
|
||||
//! Port forwarding for native-SSH panes (Workstream 4).
|
||||
//!
|
||||
//! Three forward types ride the pane's shared [`SshConnection`] (russh channels,
|
||||
//! never a control socket — every forward is native):
|
||||
//!
|
||||
//! - **Local** (FR-F1): a TCP listener on `bind_host:bind_port`; each accepted
|
||||
//! connection opens a `direct-tcpip` channel to `target_host:target_port` on the
|
||||
//! connection and [`bridge`]s the two with exact EOF/close propagation.
|
||||
//! - **Dynamic / SOCKS5** (FR-F1): a local listener speaking a minimal, hand-rolled
|
||||
//! SOCKS5 (no-auth greeting, CONNECT for IPv4/IPv6/domain; BIND/UDP rejected).
|
||||
//! Each request opens a `direct-tcpip` to the negotiated target and bridges.
|
||||
//! - **Remote** (FR-F1): a `tcpip-forward` global request on the connection;
|
||||
//! incoming `forwarded-tcpip` channels (via the [`super::handler::ClientHandler`])
|
||||
//! are matched against [`RemoteForwardTable`] and bridged to a fresh local TCP
|
||||
//! connection to the registered target. Unmatched channels are rejected.
|
||||
//!
|
||||
//! **Registry keying & blast radius.** [`SshForwardRegistry`] keys active forwards
|
||||
//! by [`ForwardOwner`] — *what has to die for this forward to die* — but each
|
||||
//! forward task holds an `Arc<SshConnection>`, so a forward keeps the shared
|
||||
//! connection alive exactly like `ssh -N`.
|
||||
//!
|
||||
//! There are two owners, because tty7 has two unrelated features that both open
|
||||
//! forwards:
|
||||
//!
|
||||
//! | | SSH pane ("连一下") | remote workspace ("在上面开发") |
|
||||
//! |---|---|---|
|
||||
//! | owner | [`ForwardOwner::Pane`] | [`ForwardOwner::Workspace`] |
|
||||
//! | unit | one pane | one window's workspace |
|
||||
//! | pane dies | forward dies with it | **forward survives** |
|
||||
//! | torn down by | [`SshForwardRegistry::teardown_pane`] | [`SshForwardRegistry::teardown_workspace`] |
|
||||
//!
|
||||
//! The two are exclusive by construction rather than by convention: an owner is
|
||||
//! one variant or the other, and `teardown_pane(id)` can only ever reach
|
||||
//! `Pane(id)`. A remote workspace's panes come and go — a tab closed, a pane
|
||||
//! respawned after a reconnect — and the `localhost:3000` forward the user
|
||||
//! ⌘-clicked has to outlive all of that, while an SSH pane's forwards must still
|
||||
//! vanish the moment the pane does.
|
||||
//!
|
||||
//! When a pane dies the daemon calls [`SshForwardRegistry::teardown_pane`],
|
||||
//! which aborts its listener tasks and cancels its remote bindings; dropping the
|
||||
//! last `Arc` then tears the connection down. When the *transport* drops, every
|
||||
//! pane sharing the connection dies as a unit (FR-C2), so every forward
|
||||
//! attributed to those panes is torn down together.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -60,11 +16,6 @@ use crate::daemon::protocol::{
|
||||
use super::session::SshConnection;
|
||||
use super::{ConnectionKey, SshManager};
|
||||
|
||||
/// Accept a connection, retrying transient errors instead of killing the
|
||||
/// listener: ECONNABORTED (client gave up mid-handshake) and EMFILE/ENFILE
|
||||
/// (fd pressure) are momentary, and exiting the accept loop on them would
|
||||
/// leave the forward dead while its status still says "listening". `None`
|
||||
/// only on errors that persist after a backoff (listener genuinely broken).
|
||||
async fn accept_retrying(listener: &TcpListener) -> Option<(TcpStream, std::net::SocketAddr)> {
|
||||
let mut failures = 0u32;
|
||||
loop {
|
||||
@@ -79,16 +30,6 @@ async fn accept_retrying(listener: &TcpListener) -> Option<(TcpStream, std::net:
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bidirectional socket<->channel bridge (Tabby brief §5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bridge two duplex streams, propagating EOF and close in both directions: when
|
||||
/// one side's read half hits EOF, the other side's write half is shut down (a
|
||||
/// half-close), and once both directions have closed the bridge returns. This
|
||||
/// mirrors Tabby's `setupSocketChannelEvents` (channel.eof→socket.end,
|
||||
/// socket.end→channel.eof, close→destroy) so neither a socket nor a russh channel
|
||||
/// is left half-open.
|
||||
pub(super) async fn bridge<A, B>(a: A, b: B) -> io::Result<()>
|
||||
where
|
||||
A: AsyncRead + AsyncWrite + Unpin,
|
||||
@@ -99,8 +40,6 @@ where
|
||||
|
||||
let a_to_b = async {
|
||||
tokio::io::copy(&mut ar, &mut bw).await?;
|
||||
// Source EOF'd: signal it downstream so the peer sees a clean close
|
||||
// rather than a stall.
|
||||
bw.shutdown().await
|
||||
};
|
||||
let b_to_a = async {
|
||||
@@ -108,32 +47,17 @@ where
|
||||
aw.shutdown().await
|
||||
};
|
||||
|
||||
// Run both directions until each has hit EOF (or one errors). `try_join`
|
||||
// surfaces the first error and drops the other future, which closes its
|
||||
// half — the connection cannot be left half-open.
|
||||
tokio::try_join!(a_to_b, b_to_a)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal SOCKS5 (RFC 1928) for Dynamic forwards.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Negotiate a SOCKS5 CONNECT request on `s`: read the (no-auth) greeting, reply
|
||||
/// with the no-auth method, read the CONNECT request, and return the requested
|
||||
/// `(host, port)`. Rejects SOCKS4 (version byte `0x04`), any command other than
|
||||
/// CONNECT (so BIND/UDP-ASSOCIATE are refused), and unknown address types. The
|
||||
/// caller opens the upstream channel and then writes the final reply with
|
||||
/// [`socks5_reply`].
|
||||
pub(super) async fn socks5_negotiate<S>(s: &mut S) -> io::Result<(String, u16)>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// Greeting: VER, NMETHODS, METHODS...
|
||||
let mut head = [0u8; 2];
|
||||
s.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
// A SOCKS4 client sends 0x04 here; anything but 0x05 is unsupported.
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unsupported SOCKS version (only SOCKS5 is accepted)",
|
||||
@@ -143,7 +67,6 @@ where
|
||||
let mut methods = vec![0u8; nmethods];
|
||||
s.read_exact(&mut methods).await?;
|
||||
if !methods.contains(&0x00) {
|
||||
// No acceptable methods (0xFF) — we only implement no-auth.
|
||||
let _ = s.write_all(&[0x05, 0xFF]).await;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -152,7 +75,6 @@ where
|
||||
}
|
||||
s.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
// Request: VER, CMD, RSV, ATYP, ADDR, PORT.
|
||||
let mut req = [0u8; 4];
|
||||
s.read_exact(&mut req).await?;
|
||||
if req[0] != 0x05 {
|
||||
@@ -162,8 +84,7 @@ where
|
||||
));
|
||||
}
|
||||
if req[1] != 0x01 {
|
||||
// Only CONNECT (0x01); reject BIND (0x02) / UDP-ASSOCIATE (0x03).
|
||||
socks5_reply(s, 0x07).await?; // command not supported
|
||||
socks5_reply(s, 0x07).await?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"SOCKS5 command not supported (only CONNECT)",
|
||||
@@ -190,7 +111,7 @@ where
|
||||
})?
|
||||
}
|
||||
other => {
|
||||
socks5_reply(s, 0x08).await?; // address type not supported
|
||||
socks5_reply(s, 0x08).await?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("SOCKS5 unsupported address type {other}"),
|
||||
@@ -202,8 +123,6 @@ where
|
||||
Ok((host, u16::from_be_bytes(port)))
|
||||
}
|
||||
|
||||
/// Write a SOCKS5 reply with reply code `rep` (0x00 = success), a fixed
|
||||
/// `0.0.0.0:0` bound address (clients ignore it for CONNECT).
|
||||
pub(super) async fn socks5_reply<S>(s: &mut S, rep: u8) -> io::Result<()>
|
||||
where
|
||||
S: AsyncWrite + Unpin,
|
||||
@@ -212,25 +131,12 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote-forward table (consulted by the connection's Handler).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The set of `tcpip-forward` bindings registered on one connection, mapping a
|
||||
/// remote bind address/port to the local target to connect incoming
|
||||
/// `forwarded-tcpip` channels to. Shared (cheaply cloned `Arc`) between the
|
||||
/// [`SshConnection`] and its [`super::handler::ClientHandler`]; a reused
|
||||
/// connection keeps its bindings across panes.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RemoteForwardTable {
|
||||
inner: Arc<Mutex<HashMap<(String, u16), (String, u16)>>>,
|
||||
}
|
||||
|
||||
impl RemoteForwardTable {
|
||||
/// Register a binding, refusing a duplicate: overwriting would hijack the
|
||||
/// existing forward's routing, and the caller's on-failure rollback would
|
||||
/// then delete the *original* entry, leaving its live server binding
|
||||
/// unroutable. Returns whether the key was free.
|
||||
pub(super) fn register(
|
||||
&self,
|
||||
bind_host: &str,
|
||||
@@ -259,8 +165,6 @@ impl RemoteForwardTable {
|
||||
.remove(&(bind_host.to_string(), bind_port));
|
||||
}
|
||||
|
||||
/// Move a binding to a new (server-assigned) port when the client requested
|
||||
/// port 0.
|
||||
pub(super) fn rekey(&self, bind_host: &str, from_port: u16, to_port: u16) {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
if let Some(target) = map.remove(&(bind_host.to_string(), from_port)) {
|
||||
@@ -268,12 +172,6 @@ impl RemoteForwardTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an incoming `forwarded-tcpip` channel's connected address/port to a
|
||||
/// local target. Tries the exact `(address, port)` first, then a port-only
|
||||
/// match (the server may report `127.0.0.1` for a `localhost` bind, or
|
||||
/// `0.0.0.0` for an empty bind address) — but only when the port match is
|
||||
/// unambiguous: with two bindings on the same port and different addresses,
|
||||
/// guessing could bridge traffic to the wrong local target.
|
||||
pub(super) fn lookup(
|
||||
&self,
|
||||
connected_address: &str,
|
||||
@@ -291,25 +189,13 @@ impl RemoteForwardTable {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Managed-forward registry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A live forward's teardown handle.
|
||||
enum ForwardCancel {
|
||||
/// A Local/Dynamic accept loop. Held as the full `JoinHandle` (not just an
|
||||
/// `AbortHandle`) so [`SshForwardRegistry::cancel_entry`] can `abort()` *and*
|
||||
/// `await` it: `abort()` only *requests* cancellation, so awaiting is what
|
||||
/// guarantees the task — and the `TcpListener` it owns — is fully dropped,
|
||||
/// freeing the bound port before `remove`/`teardown_pane` returns.
|
||||
Task(JoinHandle<()>),
|
||||
/// A Remote binding to cancel via `cancel_tcpip_forward` on teardown.
|
||||
Remote {
|
||||
conn: Weak<SshConnection>,
|
||||
bind_host: String,
|
||||
bind_port: u16,
|
||||
},
|
||||
/// The forward never came up (bind/request failed); nothing to cancel.
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -323,23 +209,12 @@ struct ForwardEntry {
|
||||
description: Option<String>,
|
||||
status: ForwardStatus,
|
||||
cancel: ForwardCancel,
|
||||
/// True for a forward auto-created by a Cmd-clicked `localhost:PORT` link
|
||||
/// (FR-F4). Such entries are eligible for reuse when the same target is
|
||||
/// clicked again, and read as a plain Local row in the unified forwards list.
|
||||
auto_local: bool,
|
||||
}
|
||||
|
||||
/// What a managed forward belongs to — the thing whose death takes it down.
|
||||
///
|
||||
/// The registry is keyed on this rather than on a bare `pane_id` so that the two
|
||||
/// features that open forwards can coexist without either one's teardown being
|
||||
/// able to reach the other's entries (see the module docs).
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ForwardOwner {
|
||||
/// A native-SSH pane. Its forwards die with it, via [`SshForwardRegistry::teardown_pane`].
|
||||
Pane(u64),
|
||||
/// A remote workspace. Its forwards outlive every individual pane and die
|
||||
/// only with the workspace, via [`SshForwardRegistry::teardown_workspace`].
|
||||
Workspace(WorkspaceId),
|
||||
}
|
||||
|
||||
@@ -359,7 +234,6 @@ impl ForwardEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-process registry of managed forwards, owned by [`super::SshManager`].
|
||||
#[derive(Default)]
|
||||
pub struct SshForwardRegistry {
|
||||
owners: Mutex<HashMap<ForwardOwner, Vec<ForwardEntry>>>,
|
||||
@@ -367,16 +241,6 @@ pub struct SshForwardRegistry {
|
||||
}
|
||||
|
||||
impl SshForwardRegistry {
|
||||
// ---- Pane-owned forwards (native-SSH panes) -----------------------------
|
||||
//
|
||||
// These signatures are exactly what they were before workspaces existed, and
|
||||
// every one of them pins its owner to `ForwardOwner::Pane`. A workspace
|
||||
// forward is unreachable from here, which is the compatibility guarantee.
|
||||
|
||||
/// Establish a managed forward for `rule` on `conn`, attribute it to `pane_id`,
|
||||
/// and return the resulting [`ManagedForward`] (with a resolved bind port and a
|
||||
/// live status). Failures are reported as `ForwardStatus::Error`, never a hard
|
||||
/// error — a preconfigured forward that fails must not kill the session.
|
||||
pub async fn establish(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
@@ -387,34 +251,19 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The managed forwards attributed to `pane_id`, sorted by id (creation order).
|
||||
pub fn list(&self, pane_id: u64) -> Vec<ManagedForward> {
|
||||
self.list_owned(&ForwardOwner::Pane(pane_id), pane_id)
|
||||
}
|
||||
|
||||
/// Remove one managed forward by id from `pane_id`, tearing down its listener
|
||||
/// or remote binding. Returns the pane's remaining forwards.
|
||||
pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> {
|
||||
self.remove_owned(&ForwardOwner::Pane(pane_id), pane_id, forward_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tear down every forward attributed to `pane_id` (called when the pane dies —
|
||||
/// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are
|
||||
/// aborted synchronously; remote bindings are cancelled best-effort.
|
||||
///
|
||||
/// A *remote workspace's* forwards are untouched by this even when the dying
|
||||
/// pane belonged to that workspace: they are filed under
|
||||
/// [`ForwardOwner::Workspace`], which this key can never name.
|
||||
pub async fn teardown_pane(&self, pane_id: u64) {
|
||||
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
|
||||
}
|
||||
|
||||
// ---- Workspace-owned forwards (remote workspaces) ----------------------
|
||||
|
||||
/// [`establish`](Self::establish) for a remote workspace. `view_pane` is only
|
||||
/// stamped into the returned row for the GUI's per-pane list; ownership — and
|
||||
/// therefore lifetime — is the workspace's.
|
||||
pub async fn establish_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -426,12 +275,10 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The forwards a workspace owns, stamped with `view_pane` for display.
|
||||
pub fn list_workspace(&self, workspace: WorkspaceId, view_pane: u64) -> Vec<ManagedForward> {
|
||||
self.list_owned(&ForwardOwner::Workspace(workspace), view_pane)
|
||||
}
|
||||
|
||||
/// Remove one of a workspace's forwards by id; returns the rest.
|
||||
pub async fn remove_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -442,16 +289,11 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tear down every forward a workspace owns — the workspace was closed. The
|
||||
/// counterpart of [`teardown_pane`](Self::teardown_pane), and the *only* thing
|
||||
/// that collects a workspace forward.
|
||||
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
|
||||
self.teardown_owned(&ForwardOwner::Workspace(workspace))
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---- Owner-generic core -------------------------------------------------
|
||||
|
||||
async fn establish_owned(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
@@ -528,10 +370,6 @@ impl SshForwardRegistry {
|
||||
async fn cancel_entry(entry: ForwardEntry) {
|
||||
match entry.cancel {
|
||||
ForwardCancel::Task(handle) => {
|
||||
// `abort()` only *schedules* cancellation; awaiting the handle
|
||||
// drives the task to completion so its `TcpListener` is dropped
|
||||
// (socket closed) before we return. The task was cancelled, so
|
||||
// the `JoinError` is expected and ignored.
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
@@ -589,8 +427,6 @@ impl SshForwardRegistry {
|
||||
Ok(channel) => {
|
||||
let _ = bridge(sock, channel.into_stream()).await;
|
||||
}
|
||||
// Remote refused (or the connection died): drop the client
|
||||
// socket. No secrets in the log.
|
||||
Err(e) => {
|
||||
log::info!("local forward to {target_host}:{target_port} rejected: {e}")
|
||||
}
|
||||
@@ -651,7 +487,6 @@ impl SshForwardRegistry {
|
||||
let _ = bridge(sock, channel.into_stream()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
// 0x05 = connection refused by destination host.
|
||||
let _ = socks5_reply(&mut sock, 0x05).await;
|
||||
log::info!("dynamic forward to {host}:{port} rejected: {e}");
|
||||
}
|
||||
@@ -693,18 +528,6 @@ impl SshForwardRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Native loopback (FR-F4) --------------------------------------------
|
||||
|
||||
/// Ensure a native-SSH loopback forward `127.0.0.1:<ephemeral> → host:port`
|
||||
/// exists for `pane_id`, reusing an existing auto-created one for the same
|
||||
/// target. The forward is registered in the *same* managed registry as
|
||||
/// [`Self::establish`], so it shows up as a plain Local row in `list(pane_id)`
|
||||
/// — there is no separate loopback bookkeeping. Returns the `LoopbackForward`
|
||||
/// reply shape the GUI's Cmd-click flow consumes (just the local port), so the
|
||||
/// wire reply is unchanged.
|
||||
///
|
||||
/// `_target` (the pane's remote hostname) is retained for call-site
|
||||
/// compatibility; dedup keys on the concrete `remote_host:remote_port`.
|
||||
pub async fn ensure_loopback(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
@@ -717,11 +540,6 @@ impl SshForwardRegistry {
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`ensure_loopback`](Self::ensure_loopback) for a remote workspace: the
|
||||
/// ⌘-clicked `localhost:PORT` in a remote-workspace pane.
|
||||
///
|
||||
/// The forward is owned by the workspace, so clicking the link in one pane
|
||||
/// and then closing that pane leaves the browser tab working.
|
||||
pub async fn ensure_loopback_workspace(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -745,10 +563,6 @@ impl SshForwardRegistry {
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> io::Result<LoopbackForward> {
|
||||
// Dedup: a live auto-forward to the same target is reused rather than
|
||||
// duplicated (preserving the old `ensure_loopback` behavior). Scoped to
|
||||
// the owner, so two workspaces on one machine don't share — and can't
|
||||
// break — each other's forward.
|
||||
if let Some(local_port) = self.find_auto_local(owner, remote_host, remote_port) {
|
||||
return Ok(LoopbackForward { local_port });
|
||||
}
|
||||
@@ -762,8 +576,6 @@ impl SshForwardRegistry {
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (bind_port, status, cancel) = self.start_local(&conn, &rule).await;
|
||||
// A bind failure must surface to the Cmd-click caller (it previously
|
||||
// propagated via `?`), and no dead entry is registered.
|
||||
if let ForwardStatus::Error(e) = &status {
|
||||
return Err(io::Error::other(e.clone()));
|
||||
}
|
||||
@@ -790,8 +602,6 @@ impl SshForwardRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
/// The local port of a live auto-created loopback forward owned by `owner`
|
||||
/// targeting `remote_host:remote_port`, if one exists (dedup for Cmd-click).
|
||||
fn find_auto_local(
|
||||
&self,
|
||||
owner: &ForwardOwner,
|
||||
@@ -813,39 +623,15 @@ impl SshForwardRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace-scoped entry points on the manager.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The blocking, workspace-scoped half of [`SshManager`]'s forward API.
|
||||
///
|
||||
/// Written here rather than in `ssh/mod.rs` deliberately: these are the sync
|
||||
/// wrappers for *this* file's registry, and keeping them beside it means the
|
||||
/// pane-scoped wrappers next door stay untouched — a workspace forward cannot
|
||||
/// be reached by editing one of them by mistake. Private fields of `SshManager`
|
||||
/// are in scope because this module is a descendant of the one that defines it.
|
||||
impl SshManager {
|
||||
/// The already-authenticated connection for `spec`'s host, if this daemon
|
||||
/// has one — **never** connecting.
|
||||
///
|
||||
/// A workspace-scoped request rides the connection the workspace itself
|
||||
/// opened, so the right answer to "no connection" is an error the user can
|
||||
/// act on ("the workspace is not connected"), not a silent second connect
|
||||
/// that would prompt for credentials from a context with nowhere to put a
|
||||
/// dialog. That is also why `spec` may be — and from the GUI always is —
|
||||
/// secret-free: [`ConnectionKey::from_spec`] reads only host, user, port,
|
||||
/// proxy and jump chain, so a stripped spec hashes to the same slot.
|
||||
pub fn existing_connection(&self, spec: &NativeSshSpec) -> Option<Arc<SshConnection>> {
|
||||
let key = ConnectionKey::from_spec(spec);
|
||||
let slot = self.conns.lock().unwrap().get(&key).cloned()?;
|
||||
// `blocking_lock` would panic on a runtime worker; `try_lock` failing
|
||||
// just means a connect for this key is in flight, which is "not ready".
|
||||
let guard = slot.try_lock().ok()?;
|
||||
let conn = guard.upgrade()?;
|
||||
conn.is_alive().then_some(conn)
|
||||
}
|
||||
|
||||
/// Establish a workspace-owned managed forward; returns the workspace's list.
|
||||
pub fn add_workspace_forward(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -861,7 +647,6 @@ impl SshManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove one workspace-owned forward; returns the rest.
|
||||
pub fn remove_workspace_forward(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -874,7 +659,6 @@ impl SshManager {
|
||||
)
|
||||
}
|
||||
|
||||
/// A workspace's managed forwards.
|
||||
pub fn list_workspace_forwards(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -883,14 +667,11 @@ impl SshManager {
|
||||
self.forwards.list_workspace(workspace, view_pane)
|
||||
}
|
||||
|
||||
/// Drop every forward a workspace owns (the workspace was closed).
|
||||
pub fn teardown_workspace_forwards(&self, workspace: WorkspaceId) {
|
||||
self.runtime
|
||||
.block_on(self.forwards.teardown_workspace(workspace));
|
||||
}
|
||||
|
||||
/// Ensure the on-demand loopback forward behind a ⌘-clicked `localhost:PORT`
|
||||
/// in a remote-workspace pane.
|
||||
pub fn ensure_workspace_loopback(
|
||||
&self,
|
||||
workspace: WorkspaceId,
|
||||
@@ -913,7 +694,6 @@ mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A SOCKS4 client (version byte `0x04`) is rejected outright.
|
||||
#[tokio::test]
|
||||
async fn socks5_rejects_v4() {
|
||||
let (mut client, mut server) = tokio::io::duplex(64);
|
||||
@@ -922,12 +702,9 @@ mod tests {
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// A well-formed v5 CONNECT to an IPv4 address is parsed and the method reply is
|
||||
/// the no-auth selection.
|
||||
#[tokio::test]
|
||||
async fn socks5_v5_connect_ipv4() {
|
||||
let (mut client, mut server) = tokio::io::duplex(64);
|
||||
// Greeting (1 method: no-auth) + CONNECT to 1.2.3.4:80.
|
||||
client.write_all(&[0x05, 0x01, 0x00]).await.unwrap();
|
||||
client
|
||||
.write_all(&[0x05, 0x01, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50])
|
||||
@@ -936,13 +713,11 @@ mod tests {
|
||||
let (host, port) = socks5_negotiate(&mut server).await.unwrap();
|
||||
assert_eq!(host, "1.2.3.4");
|
||||
assert_eq!(port, 80);
|
||||
// Method-selection reply is VER=5, METHOD=0 (no auth).
|
||||
let mut reply = [0u8; 2];
|
||||
client.read_exact(&mut reply).await.unwrap();
|
||||
assert_eq!(reply, [0x05, 0x00]);
|
||||
}
|
||||
|
||||
/// A v5 CONNECT with a domain-name address (ATYP=3).
|
||||
#[tokio::test]
|
||||
async fn socks5_v5_connect_domain() {
|
||||
let (mut client, mut server) = tokio::io::duplex(64);
|
||||
@@ -952,8 +727,6 @@ mod tests {
|
||||
req.extend_from_slice(host);
|
||||
req.extend_from_slice(&443u16.to_be_bytes());
|
||||
client.write_all(&req).await.unwrap();
|
||||
// Negotiate before draining the reply: on a single-threaded test runtime
|
||||
// the writer must run first, or the reply read would deadlock.
|
||||
let (host, port) = socks5_negotiate(&mut server).await.unwrap();
|
||||
assert_eq!(host, "example.com");
|
||||
assert_eq!(port, 443);
|
||||
@@ -962,7 +735,6 @@ mod tests {
|
||||
assert_eq!(reply, [0x05, 0x00]);
|
||||
}
|
||||
|
||||
/// A v5 CONNECT with an IPv6 address (ATYP=4).
|
||||
#[tokio::test]
|
||||
async fn socks5_v5_connect_ipv6() {
|
||||
let (mut client, mut server) = tokio::io::duplex(64);
|
||||
@@ -971,7 +743,6 @@ mod tests {
|
||||
req.extend_from_slice(&std::net::Ipv6Addr::LOCALHOST.octets());
|
||||
req.extend_from_slice(&22u16.to_be_bytes());
|
||||
client.write_all(&req).await.unwrap();
|
||||
// Negotiate before draining the reply (see the domain test).
|
||||
let (host, port) = socks5_negotiate(&mut server).await.unwrap();
|
||||
assert_eq!(host, "::1");
|
||||
assert_eq!(port, 22);
|
||||
@@ -980,7 +751,6 @@ mod tests {
|
||||
assert_eq!(reply, [0x05, 0x00]);
|
||||
}
|
||||
|
||||
/// A v5 BIND command (0x02) is rejected with a "command not supported" reply.
|
||||
#[tokio::test]
|
||||
async fn socks5_rejects_bind_command() {
|
||||
let (mut client, mut server) = tokio::io::duplex(64);
|
||||
@@ -991,7 +761,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let err = socks5_negotiate(&mut server).await.unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
// Method reply then a 0x07 (command not supported) reply.
|
||||
let mut method = [0u8; 2];
|
||||
client.read_exact(&mut method).await.unwrap();
|
||||
assert_eq!(method, [0x05, 0x00]);
|
||||
@@ -1000,16 +769,12 @@ mod tests {
|
||||
assert_eq!(rep[1], 0x07);
|
||||
}
|
||||
|
||||
/// The bridge forwards bytes A→B and propagates the A-side EOF as a clean close
|
||||
/// on the B side (and streams a reply back B→A).
|
||||
#[tokio::test]
|
||||
async fn bridge_propagates_data_and_eof_both_directions() {
|
||||
// client_a <-> a ...bridge... b <-> server_b
|
||||
let (mut client_a, a) = tokio::io::duplex(64);
|
||||
let (b, mut server_b) = tokio::io::duplex(64);
|
||||
let bridged = tokio::spawn(async move { bridge(a, b).await });
|
||||
|
||||
// A→B data, then close A's write half.
|
||||
client_a.write_all(b"ping").await.unwrap();
|
||||
client_a.shutdown().await.unwrap();
|
||||
|
||||
@@ -1020,7 +785,6 @@ mod tests {
|
||||
"A→B data delivered and A-side EOF closed B read"
|
||||
);
|
||||
|
||||
// B→A reply after the far side EOF'd — must still flow, then close.
|
||||
server_b.write_all(b"pong").await.unwrap();
|
||||
server_b.shutdown().await.unwrap();
|
||||
let mut back = Vec::new();
|
||||
@@ -1033,8 +797,6 @@ mod tests {
|
||||
bridged.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// The remote-forward table resolves exact matches and falls back to any binding
|
||||
/// on the same port (server may report a different bind address).
|
||||
#[test]
|
||||
fn remote_forward_table_lookup() {
|
||||
let table = RemoteForwardTable::default();
|
||||
@@ -1043,7 +805,6 @@ mod tests {
|
||||
table.lookup("localhost", 9000),
|
||||
Some(("127.0.0.1".to_string(), 3000))
|
||||
);
|
||||
// The server reported 127.0.0.1 for a localhost bind → port fallback.
|
||||
assert_eq!(
|
||||
table.lookup("127.0.0.1", 9000),
|
||||
Some(("127.0.0.1".to_string(), 3000))
|
||||
@@ -1053,10 +814,6 @@ mod tests {
|
||||
assert_eq!(table.lookup("localhost", 9000), None);
|
||||
}
|
||||
|
||||
/// The registry's add/list/remove/teardown bookkeeping, independent of a live
|
||||
/// connection (entries are inserted directly, bypassing `establish` which needs
|
||||
/// an authenticated `SshConnection`). Aborting the cancel task on remove/teardown
|
||||
/// is what a real listener teardown does.
|
||||
#[tokio::test]
|
||||
async fn registry_add_list_remove_teardown_bookkeeping() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
@@ -1081,43 +838,24 @@ mod tests {
|
||||
entries.push(make(0, 8000));
|
||||
entries.push(make(1, 8001));
|
||||
}
|
||||
// list is per-pane and sorted by id.
|
||||
let list = reg.list(7);
|
||||
assert_eq!(list.iter().map(|m| m.id).collect::<Vec<_>>(), vec![0, 1]);
|
||||
assert!(reg.list(99).is_empty(), "other panes see nothing");
|
||||
|
||||
// remove drops just the one forward and returns the remainder.
|
||||
let remaining = reg.remove(7, 0).await;
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].id, 1);
|
||||
|
||||
// teardown clears the pane entirely (blast-radius on death).
|
||||
reg.teardown_pane(7).await;
|
||||
assert!(reg.list(7).is_empty());
|
||||
}
|
||||
|
||||
/// Removing (or tearing down) a Local/Dynamic forward must fully drop its
|
||||
/// accept-loop task — and the `TcpListener` it owns — *before* the call
|
||||
/// returns, so the bound port is freed synchronously. A plain
|
||||
/// `AbortHandle::abort()` only *requests* cancellation, so the task (and its
|
||||
/// socket) can outlive the call and leak the port; `cancel_entry` must abort
|
||||
/// *and* await.
|
||||
///
|
||||
/// The assertion is race-free: the accept task owns both a real bound
|
||||
/// `TcpListener` (fidelity with `start_local`) and a clone of an `Arc` guard.
|
||||
/// Once the task's future is dropped, the guard clone is dropped, so the
|
||||
/// registry-side `Arc` becomes uniquely owned. With only `abort()` (no await)
|
||||
/// the task has not been polled on this current-thread runtime when the call
|
||||
/// returns, so the guard is still held (`strong_count == 2`) — the bug.
|
||||
#[tokio::test]
|
||||
async fn remove_frees_listening_socket_synchronously() {
|
||||
async fn spawn_listener_entry(id: u64, guard: &Arc<()>) -> ForwardEntry {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let guard = guard.clone();
|
||||
// Mirror start_local's accept loop: the task owns the listener, so
|
||||
// only fully dropping the task closes the socket. `guard` rides along
|
||||
// and is dropped exactly when the task's future is dropped.
|
||||
let handle = tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
loop {
|
||||
@@ -1142,7 +880,6 @@ mod tests {
|
||||
|
||||
let reg = SshForwardRegistry::default();
|
||||
|
||||
// remove() path: the task's future (holding the listener) must be gone.
|
||||
let guard = Arc::new(());
|
||||
let entry = spawn_listener_entry(0, &guard).await;
|
||||
reg.owners
|
||||
@@ -1163,7 +900,6 @@ mod tests {
|
||||
"remove() must drop the accept task (and its TcpListener) synchronously"
|
||||
);
|
||||
|
||||
// teardown_pane() path (pane death / connection loss) frees it too.
|
||||
let guard2 = Arc::new(());
|
||||
let entry2 = spawn_listener_entry(1, &guard2).await;
|
||||
reg.owners
|
||||
@@ -1180,10 +916,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A live listener entry filed under `owner`, mirroring what `start_local`
|
||||
/// registers. Returns a guard whose strong count drops to 1 once the accept
|
||||
/// task (and its `TcpListener`) is fully torn down — the same race-free trick
|
||||
/// `remove_frees_listening_socket_synchronously` uses.
|
||||
async fn push_listener(reg: &SshForwardRegistry, owner: ForwardOwner, id: u64) -> Arc<()> {
|
||||
let guard = Arc::new(());
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
@@ -1214,9 +946,6 @@ mod tests {
|
||||
guard
|
||||
}
|
||||
|
||||
/// **SSH pane ownership (existing behaviour, must not regress).** A forward
|
||||
/// opened by a native-SSH pane dies with the pane: `teardown_pane` empties the
|
||||
/// list *and* frees the listening socket.
|
||||
#[tokio::test]
|
||||
async fn ssh_pane_forwards_die_with_the_pane() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
@@ -1233,25 +962,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Remote-workspace ownership.** The panes of a remote
|
||||
/// workspace are transient — a tab closed, a pane respawned after a reconnect
|
||||
/// — so a forward the user ⌘-clicked into existence must outlive them. Only
|
||||
/// closing the *workspace* collects it.
|
||||
///
|
||||
/// The two teardowns are exercised against one registry on purpose: this is
|
||||
/// the exact case where a single `pane_id`-keyed map would have taken the
|
||||
/// workspace's forward down with the pane.
|
||||
#[tokio::test]
|
||||
async fn remote_workspace_forwards_survive_their_panes() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
let ws = WorkspaceId::new();
|
||||
// A pane of the workspace, id 7, and a same-numbered SSH-pane forward:
|
||||
// the ids collide deliberately, since a bare u64 key could not tell them
|
||||
// apart.
|
||||
let pane_guard = push_listener(®, ForwardOwner::Pane(7), 0).await;
|
||||
let ws_guard = push_listener(®, ForwardOwner::Workspace(ws), 1).await;
|
||||
|
||||
// Pane 7 dies.
|
||||
reg.teardown_pane(7).await;
|
||||
|
||||
assert!(reg.list(7).is_empty(), "the SSH pane's forward went away");
|
||||
@@ -1267,15 +984,11 @@ mod tests {
|
||||
"…and its listener is still bound"
|
||||
);
|
||||
|
||||
// Only closing the workspace collects it.
|
||||
reg.teardown_workspace(ws).await;
|
||||
assert!(reg.list_workspace(ws, 7).is_empty());
|
||||
assert_eq!(Arc::strong_count(&ws_guard), 1);
|
||||
}
|
||||
|
||||
/// Two workspaces on the *same machine* share one `SshConnection` but own
|
||||
/// their forwards separately: closing one leaves the other's alone, and the
|
||||
/// ⌘-click dedup does not hand one workspace the other's local port.
|
||||
#[tokio::test]
|
||||
async fn workspaces_on_one_host_do_not_share_forwards() {
|
||||
let reg = SshForwardRegistry::default();
|
||||
@@ -1283,7 +996,6 @@ mod tests {
|
||||
push_listener(®, ForwardOwner::Workspace(a), 0).await;
|
||||
push_listener(®, ForwardOwner::Workspace(b), 1).await;
|
||||
|
||||
// Dedup is owner-scoped: A's forward to 127.0.0.1:3000 is invisible to B.
|
||||
assert!(
|
||||
reg.find_auto_local(&ForwardOwner::Workspace(a), "127.0.0.1", 3000)
|
||||
.is_some()
|
||||
@@ -1303,7 +1015,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `rekey` moves a binding to the server-assigned port (bind_port 0 case).
|
||||
#[test]
|
||||
fn remote_forward_table_rekey() {
|
||||
let table = RemoteForwardTable::default();
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
//! The russh client [`Handler`]: host-key verification, auth banners, and
|
||||
//! incoming forwarded channels.
|
||||
//!
|
||||
//! russh invokes `check_server_key` during the handshake (once per connection —
|
||||
//! reused connections never re-run it) and `auth_banner` if the server sends one.
|
||||
//! Both route through the [`PromptBroker`] so the *GUI* makes the trust decision
|
||||
//! and sees the banner; the daemon owns the `known_hosts` storage per PRD §3.4.
|
||||
//!
|
||||
//! `server_channel_open_forwarded_tcpip` implements the Remote-forward
|
||||
//! (`tcpip-forward`) receive side (WS4): incoming channels are matched against the
|
||||
//! connection's [`RemoteForwardTable`] and bridged to a local socket.
|
||||
//!
|
||||
//! **X11 seam (P1, FR-X2 — deferred).** WS2 carries `NativeSshSpec.x11` but never
|
||||
//! requests `x11-req` on the shell channel, so no X11 channels arrive and the
|
||||
//! default `server_channel_open_x11` (auto-reject on drop) is correct. Wiring X11
|
||||
//! would add: `channel.request_x11(..)` at shell start (with a MIT-MAGIC-COOKIE-1
|
||||
//! cookie), a `server_channel_open_x11` override here that resolves the local
|
||||
//! display (`$DISPLAY` → `/tmp/.X11-unix/X<n>` unix socket or `localhost:6000+n`),
|
||||
//! and `forward::bridge` to that socket — mirroring the forwarded-tcpip path below.
|
||||
//! Left unimplemented deliberately (macOS needs XQuartz; low priority).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use russh::Channel;
|
||||
@@ -38,17 +17,10 @@ pub struct ClientHandler {
|
||||
pub verify_host_keys: bool,
|
||||
pub skip_banner: bool,
|
||||
pub broker: Arc<PromptBroker>,
|
||||
/// The connection's Remote-forward bindings (WS4). Shared with its
|
||||
/// [`super::session::SshConnection`]; incoming `forwarded-tcpip` channels are
|
||||
/// matched against it and bridged to the registered local target.
|
||||
pub remote_forwards: RemoteForwardTable,
|
||||
}
|
||||
|
||||
impl ClientHandler {
|
||||
/// Turn a GUI host-key decision into an accept/reject, appending to
|
||||
/// `known_hosts` when the user chose to remember it. A remember-append failure
|
||||
/// is logged but does not veto the (already-granted) session — the user
|
||||
/// approved this key for this connection either way.
|
||||
fn apply_decision(&self, resp: AuthResponse, key: &PublicKey) -> bool {
|
||||
match resp {
|
||||
AuthResponse::HostKeyDecision {
|
||||
@@ -62,7 +34,6 @@ impl ClientHandler {
|
||||
}
|
||||
true
|
||||
}
|
||||
// Explicit reject, a cancel, or a mismatched response kind: refuse.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -75,10 +46,6 @@ impl russh::client::Handler for ClientHandler {
|
||||
&mut self,
|
||||
server_public_key: &PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
// A per-profile / global opt-out (FR-S4): trust without prompting — but
|
||||
// still honor `@revoked` markers, like OpenSSH under
|
||||
// `StrictHostKeyChecking no`: an explicitly revoked key is never
|
||||
// acceptable, opt-out or not.
|
||||
if !self.verify_host_keys {
|
||||
let revoked = matches!(
|
||||
known_hosts::check(&self.host, self.port, server_public_key),
|
||||
@@ -99,7 +66,6 @@ impl russh::client::Handler for ClientHandler {
|
||||
|
||||
match known_hosts::check(&self.host, self.port, server_public_key) {
|
||||
HostKeyStatus::Known => Ok(true),
|
||||
// A revoked key is a hard reject — never even offer to trust it.
|
||||
HostKeyStatus::Revoked => Ok(false),
|
||||
HostKeyStatus::Unknown => {
|
||||
let resp = self
|
||||
@@ -142,11 +108,6 @@ impl russh::client::Handler for ClientHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An incoming connection on a Remote (`tcpip-forward`) binding. Match it
|
||||
/// against this connection's registered forwards; on a hit, accept the channel
|
||||
/// and bridge it to a fresh local TCP connection to the target. An unmatched
|
||||
/// channel is rejected (dropping `reply` rejects) — a remote forward we don't
|
||||
/// own must not be tunneled anywhere.
|
||||
async fn server_channel_open_forwarded_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
@@ -164,7 +125,6 @@ impl russh::client::Handler for ClientHandler {
|
||||
log::info!(
|
||||
"rejecting unmatched forwarded-tcpip channel on {connected_address}:{connected_port}"
|
||||
);
|
||||
// Dropping `reply` rejects the channel.
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
//! OpenSSH `known_hosts` reading + trust decisions for the native russh path.
|
||||
//!
|
||||
//! Scope (v1, per PRD §3.4 — WS3 hardens this later): read `~/.ssh/known_hosts`,
|
||||
//! decide trust for **plaintext** hosts, **hashed** hosts (`|1|salt|hash`, HMAC-
|
||||
//! SHA1), and `@revoked` lines (hard reject). `@cert-authority` lines are skipped
|
||||
//! (treated as no-match) so a CA entry never produces a false "changed key"
|
||||
//! warning — the connection just falls through to the unknown-host confirmation.
|
||||
//!
|
||||
//! The parser **never rewrites** the file: [`append_trusted`] only appends a
|
||||
//! single new line, preserving every existing line (comments, hashed entries, CA
|
||||
//! and revoked markers) byte-for-byte.
|
||||
//!
|
||||
//! SHA-1 / HMAC-SHA1 / base64 are hand-rolled here rather than pulled in as
|
||||
//! dependencies: it keeps host-key matching self-contained and unit-testable
|
||||
//! against RFC vectors, and the volume (one HMAC per known_hosts line at connect
|
||||
//! time) is trivial.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use russh::keys::ssh_key::{HashAlg, PublicKey};
|
||||
|
||||
/// The outcome of checking a presented host key against `known_hosts`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HostKeyStatus {
|
||||
/// An entry for this host + key type matches this exact key: trusted.
|
||||
Known,
|
||||
/// No entry for this host + key type: a first connection (confirm + maybe add).
|
||||
Unknown,
|
||||
/// An entry for this host + key type exists but the key differs: possible MITM.
|
||||
Changed {
|
||||
/// SHA256 fingerprint of the stored (old) key, for the warning UI.
|
||||
old_fingerprint_sha256: String,
|
||||
},
|
||||
/// A matching `@revoked` line: reject hard, never offer to trust.
|
||||
Changed { old_fingerprint_sha256: String },
|
||||
Revoked,
|
||||
}
|
||||
|
||||
/// The default OpenSSH user known_hosts path, `~/.ssh/known_hosts`.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
home_dir().map(|h| h.join(".ssh").join("known_hosts"))
|
||||
}
|
||||
@@ -55,8 +29,6 @@ fn home_dir() -> Option<PathBuf> {
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// The host token OpenSSH keys a `known_hosts` entry under: the bare host for the
|
||||
/// default port 22, else the bracketed `[host]:port` form.
|
||||
pub fn host_token(host: &str, port: u16) -> String {
|
||||
if port == 22 {
|
||||
host.to_string()
|
||||
@@ -65,7 +37,6 @@ pub fn host_token(host: &str, port: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check `host:port`'s presented `key` against the default known_hosts file.
|
||||
pub fn check(host: &str, port: u16, key: &PublicKey) -> HostKeyStatus {
|
||||
match default_path() {
|
||||
Some(path) => check_in_file(&path, host, port, key),
|
||||
@@ -73,8 +44,6 @@ pub fn check(host: &str, port: u16, key: &PublicKey) -> HostKeyStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check against a specific file (the testable core of [`check`]). A missing or
|
||||
/// unreadable file means "no entries" → `Unknown`.
|
||||
pub fn check_in_file(path: &Path, host: &str, port: u16, key: &PublicKey) -> HostKeyStatus {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
@@ -83,15 +52,10 @@ pub fn check_in_file(path: &Path, host: &str, port: u16, key: &PublicKey) -> Hos
|
||||
check_in_str(&contents, host, port, key)
|
||||
}
|
||||
|
||||
/// Trust decision over the text of a known_hosts file. Split out so the matcher
|
||||
/// is unit-testable against fixture strings without touching the filesystem.
|
||||
pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> HostKeyStatus {
|
||||
let token = host_token(host, port);
|
||||
let our_alg = key.algorithm();
|
||||
|
||||
// First pass — revocation wins outright. A `@revoked` line matching this exact
|
||||
// key anywhere in the file rejects it, even if a trusted line for the same
|
||||
// host+key appears earlier: a revoked key must never read as trusted.
|
||||
for line in contents.lines() {
|
||||
let Some(entry) = KnownHostsLine::parse(line) else {
|
||||
continue;
|
||||
@@ -106,7 +70,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass — normal known/changed resolution (revocation already handled).
|
||||
let mut changed: Option<String> = None;
|
||||
let mut changed_other_alg: Option<String> = None;
|
||||
for line in contents.lines() {
|
||||
@@ -117,20 +80,11 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
continue;
|
||||
}
|
||||
match entry.marker {
|
||||
// A host-CA line certifies keys signed by this CA; russh doesn't do
|
||||
// host-cert verification here, so skip rather than mis-flag it as a
|
||||
// changed key (PRD §3.4). Falls through to Unknown → confirm.
|
||||
Some(Marker::CertAuthority) => continue,
|
||||
// Revocation was resolved in the first pass; ignore here.
|
||||
Some(Marker::Revoked) => continue,
|
||||
None => {
|
||||
let Some(stored) = entry.key() else { continue };
|
||||
if stored.algorithm() != our_alg {
|
||||
// The host is known, just via a different key type. If no
|
||||
// same-type line resolves this below, report Changed, like
|
||||
// OpenSSH: a MITM can present a key of an algorithm absent
|
||||
// from the file precisely to downgrade the changed-key
|
||||
// warning to a benign first-connect prompt.
|
||||
if changed_other_alg.is_none() {
|
||||
changed_other_alg = Some(fingerprint_sha256(&stored));
|
||||
}
|
||||
@@ -139,9 +93,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
if &stored == key {
|
||||
return HostKeyStatus::Known;
|
||||
}
|
||||
// Same host + key type, different key: a candidate "changed"
|
||||
// result — but keep scanning in case a later line matches
|
||||
// exactly (a host can list several keys of the same type).
|
||||
if changed.is_none() {
|
||||
changed = Some(fingerprint_sha256(&stored));
|
||||
}
|
||||
@@ -157,9 +108,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a trust line for `host:port` + `key` to the default known_hosts file,
|
||||
/// creating `~/.ssh` (mode 0700) and the file (0600) if needed. Never rewrites
|
||||
/// existing lines — a plain append.
|
||||
pub fn append_trusted(host: &str, port: u16, key: &PublicKey) -> std::io::Result<()> {
|
||||
let path = default_path().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir for known_hosts")
|
||||
@@ -167,7 +115,6 @@ pub fn append_trusted(host: &str, port: u16, key: &PublicKey) -> std::io::Result
|
||||
append_trusted_to(&path, host, port, key)
|
||||
}
|
||||
|
||||
/// The testable core of [`append_trusted`]: append to a specific path.
|
||||
pub fn append_trusted_to(
|
||||
path: &Path,
|
||||
host: &str,
|
||||
@@ -185,16 +132,12 @@ pub fn append_trusted_to(
|
||||
let key_openssh = key
|
||||
.to_openssh()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
// `to_openssh` yields `<algo> <base64>` (with the key's comment, if any). Take
|
||||
// just the algo + base64 so the appended line is a clean host entry.
|
||||
let mut parts = key_openssh.split_whitespace();
|
||||
let algo = parts.next().unwrap_or_default();
|
||||
let b64 = parts.next().unwrap_or_default();
|
||||
let token = host_token(host, port);
|
||||
let line = format!("{token} {algo} {b64}\n");
|
||||
|
||||
// Make sure we start on a fresh line so we never join onto a file that lacks a
|
||||
// trailing newline (which would corrupt the last existing entry).
|
||||
let needs_leading_newline = match std::fs::read(path) {
|
||||
Ok(bytes) => !bytes.is_empty() && bytes.last() != Some(&b'\n'),
|
||||
Err(_) => false,
|
||||
@@ -215,15 +158,12 @@ pub fn append_trusted_to(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA256 fingerprint of a public key in OpenSSH `SHA256:base64` form.
|
||||
pub fn fingerprint_sha256(key: &PublicKey) -> String {
|
||||
key.fingerprint(HashAlg::Sha256).to_string()
|
||||
}
|
||||
|
||||
pub use crate::daemon::protocol::{KnownHostEntry, KnownHostId};
|
||||
|
||||
/// List every parseable entry in the default `known_hosts` file, in file order.
|
||||
/// A missing/unreadable file lists as empty.
|
||||
pub fn list() -> Vec<KnownHostEntry> {
|
||||
match default_path() {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
@@ -234,7 +174,6 @@ pub fn list() -> Vec<KnownHostEntry> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The testable core of [`list`]: parse entries out of file text.
|
||||
pub fn list_in_str(contents: &str) -> Vec<KnownHostEntry> {
|
||||
let mut out = Vec::new();
|
||||
for line in contents.lines() {
|
||||
@@ -263,10 +202,6 @@ pub fn list_in_str(contents: &str) -> Vec<KnownHostEntry> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete the entry matching `id` from the default `known_hosts` file. Every
|
||||
/// other line — comments, blanks, unrelated entries, and the file's exact line
|
||||
/// endings — is preserved verbatim. A no-op (Ok) when the file is absent or the
|
||||
/// entry isn't found.
|
||||
pub fn delete(id: &KnownHostId) -> std::io::Result<()> {
|
||||
let Some(path) = default_path() else {
|
||||
return Ok(());
|
||||
@@ -274,7 +209,6 @@ pub fn delete(id: &KnownHostId) -> std::io::Result<()> {
|
||||
delete_in_file(&path, id)
|
||||
}
|
||||
|
||||
/// The testable core of [`delete`]: rewrite `path` without the matching entry.
|
||||
pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
@@ -285,11 +219,6 @@ pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> {
|
||||
if !removed {
|
||||
return Ok(());
|
||||
}
|
||||
// Write a sibling temp then rename over the original: an in-place truncating
|
||||
// write would leave a truncated known_hosts behind a crash mid-write. (A
|
||||
// concurrent O_APPEND from another connection's TOFU accept can still be
|
||||
// lost to the read-modify-write window — data-loss only; a lost entry fails
|
||||
// toward re-prompting, never toward trusting.)
|
||||
let tmp = path.with_extension("tty7-tmp");
|
||||
std::fs::write(&tmp, new_contents)?;
|
||||
#[cfg(unix)]
|
||||
@@ -302,18 +231,11 @@ pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the line matching `id` from `contents`, preserving all other lines and
|
||||
/// their exact terminators byte-for-byte. Returns the new text and whether a line
|
||||
/// was removed. Only the first matching line is dropped (ids are unique in
|
||||
/// practice).
|
||||
pub fn delete_in_str(contents: &str, id: &KnownHostId) -> (String, bool) {
|
||||
let mut out = String::with_capacity(contents.len());
|
||||
let mut removed = false;
|
||||
// Split keeping terminators so we never alter unrelated bytes (CRLF, a
|
||||
// missing final newline, blank lines, comment spacing).
|
||||
for segment in split_keep_terminators(contents) {
|
||||
if !removed {
|
||||
// Match against the line's text without its terminator/leading space.
|
||||
let line = segment.trim_end_matches(['\n', '\r']);
|
||||
if let Some(entry) = KnownHostsLine::parse(line) {
|
||||
if entry.hosts == id.host
|
||||
@@ -330,9 +252,6 @@ pub fn delete_in_str(contents: &str, id: &KnownHostId) -> (String, bool) {
|
||||
(out, removed)
|
||||
}
|
||||
|
||||
/// Split text into segments that each still carry their trailing `\n` (and any
|
||||
/// `\r`), so rejoining is byte-identical to the input. The final segment has no
|
||||
/// terminator when the file doesn't end in a newline.
|
||||
fn split_keep_terminators(text: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
@@ -355,8 +274,6 @@ enum Marker {
|
||||
Revoked,
|
||||
}
|
||||
|
||||
/// One parsed known_hosts line: an optional marker, the host field (raw), and the
|
||||
/// key type + base64 blob. Comment/whitespace/blank lines parse to `None`.
|
||||
struct KnownHostsLine<'a> {
|
||||
marker: Option<Marker>,
|
||||
hosts: &'a str,
|
||||
@@ -377,7 +294,6 @@ impl<'a> KnownHostsLine<'a> {
|
||||
marker = Some(match m {
|
||||
"cert-authority" => Marker::CertAuthority,
|
||||
"revoked" => Marker::Revoked,
|
||||
// Unknown marker: skip the whole line rather than misinterpret it.
|
||||
_ => return None,
|
||||
});
|
||||
rest = tail.trim_start();
|
||||
@@ -385,7 +301,6 @@ impl<'a> KnownHostsLine<'a> {
|
||||
let (hosts, tail) = rest.split_once(char::is_whitespace)?;
|
||||
let tail = tail.trim_start();
|
||||
let (keytype, keyblob) = tail.split_once(char::is_whitespace)?;
|
||||
// The blob may carry a trailing comment; keep only the base64 token.
|
||||
let keyblob = keyblob.split_whitespace().next().unwrap_or(keyblob);
|
||||
Some(Self {
|
||||
marker,
|
||||
@@ -395,22 +310,10 @@ impl<'a> KnownHostsLine<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconstruct the stored public key (`<type> <base64>`), or `None` if it
|
||||
/// doesn't parse (an entry we can't compare against).
|
||||
fn key(&self) -> Option<PublicKey> {
|
||||
PublicKey::from_openssh(&format!("{} {}", self.keytype, self.keyblob)).ok()
|
||||
}
|
||||
|
||||
/// Does this line's host field cover `token`? Handles plaintext host lists
|
||||
/// (comma-separated), OpenSSH glob patterns (`*` / `?`), `!` negations, and
|
||||
/// the `|1|salt|hash` hashed form.
|
||||
///
|
||||
/// OpenSSH semantics: the field is a comma-separated pattern list; a leading
|
||||
/// `!` negates. If *any* negated pattern matches the host, the line does not
|
||||
/// apply at all (even when a positive pattern also matches); otherwise the
|
||||
/// line applies iff at least one positive pattern matches. Hostname matching
|
||||
/// is case-insensitive. A hashed entry carries exactly one host and never
|
||||
/// globs.
|
||||
fn matches_host(&self, token: &str) -> bool {
|
||||
let mut matched = false;
|
||||
for pattern in self.hosts.split(',') {
|
||||
@@ -429,8 +332,6 @@ impl<'a> KnownHostsLine<'a> {
|
||||
};
|
||||
if hit {
|
||||
if negated {
|
||||
// A negated match disqualifies the whole line, regardless of
|
||||
// any positive match elsewhere on it.
|
||||
return false;
|
||||
}
|
||||
matched = true;
|
||||
@@ -440,10 +341,6 @@ impl<'a> KnownHostsLine<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a single OpenSSH host pattern (which may contain `*` / `?` wildcards)
|
||||
/// against a host token, case-insensitively. `*` matches any run of characters
|
||||
/// (including empty), `?` matches exactly one character — OpenSSH's `match_pattern`
|
||||
/// glob, not a regex. Wildcard-free patterns are a plain case-insensitive compare.
|
||||
fn host_glob_matches(pattern: &str, token: &str) -> bool {
|
||||
if !pattern.as_bytes().iter().any(|&b| b == b'*' || b == b'?') {
|
||||
return pattern.eq_ignore_ascii_case(token);
|
||||
@@ -451,8 +348,6 @@ fn host_glob_matches(pattern: &str, token: &str) -> bool {
|
||||
glob_match(pattern.as_bytes(), token.as_bytes())
|
||||
}
|
||||
|
||||
/// Iterative backtracking glob for `*`/`?`, ASCII-case-insensitive (host names
|
||||
/// fold case in OpenSSH). Linear-ish with a single backtrack pointer for `*`.
|
||||
fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
|
||||
let (mut p, mut t) = (0usize, 0usize);
|
||||
let mut star: Option<usize> = None;
|
||||
@@ -466,7 +361,6 @@ fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
|
||||
star_t = t;
|
||||
p += 1;
|
||||
} else if let Some(sp) = star {
|
||||
// Backtrack: let the last `*` swallow one more character.
|
||||
p = sp + 1;
|
||||
star_t += 1;
|
||||
t = star_t;
|
||||
@@ -480,8 +374,6 @@ fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
|
||||
p == pattern.len()
|
||||
}
|
||||
|
||||
/// Check a `|1|salt|hash` hashed-host field (base64 salt + base64 HMAC-SHA1)
|
||||
/// against a host token: OpenSSH stores `HMAC-SHA1(key=salt, msg=token)`.
|
||||
fn hashed_host_matches(hashed: &str, token: &str) -> bool {
|
||||
let Some((salt_b64, hash_b64)) = hashed.split_once('|') else {
|
||||
return false;
|
||||
@@ -492,8 +384,6 @@ fn hashed_host_matches(hashed: &str, token: &str) -> bool {
|
||||
hmac_sha1(&salt, token.as_bytes()).as_slice() == hash.as_slice()
|
||||
}
|
||||
|
||||
// --- SHA-1 (FIPS 180-1) --------------------------------------------------
|
||||
|
||||
fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
||||
let ml = (data.len() as u64).wrapping_mul(8);
|
||||
@@ -571,8 +461,6 @@ fn hmac_sha1(key: &[u8], msg: &[u8]) -> [u8; 20] {
|
||||
sha1(&outer)
|
||||
}
|
||||
|
||||
// --- standard base64 decode (for the hashed-host salt/hash fields) -------
|
||||
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
fn val(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
@@ -619,7 +507,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hmac_sha1_matches_rfc2202_vector() {
|
||||
// RFC 2202 test case 1: key = 0x0b*20, data = "Hi There".
|
||||
let key = [0x0bu8; 20];
|
||||
assert_eq!(
|
||||
hex(&hmac_sha1(&key, b"Hi There")),
|
||||
@@ -629,7 +516,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn base64_decode_round_trips_openssh_salt() {
|
||||
// "hello" -> aGVsbG8=
|
||||
assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
|
||||
assert_eq!(base64_decode("").unwrap(), b"");
|
||||
}
|
||||
@@ -640,7 +526,6 @@ mod tests {
|
||||
assert_eq!(host_token("example.com", 2222), "[example.com]:2222");
|
||||
}
|
||||
|
||||
// A fixed ed25519 public key and a second, different one, both valid OpenSSH.
|
||||
const KEY_A: &str =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPXO/kBX63iuiTczoR6uNdl3wAFK7tGWz70jCKkKlw5r";
|
||||
const KEY_B: &str =
|
||||
@@ -672,16 +557,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn different_key_type_for_a_known_host_reports_changed_not_unknown() {
|
||||
// The host is known via ed25519 only; a presented ECDSA key must raise
|
||||
// the changed-key warning, not the benign first-connect prompt — a MITM
|
||||
// can pick an algorithm absent from the file to get the softer dialog.
|
||||
const KEY_ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCdv5xfuuCGyVbYZSTqcFjQWE7YtIsx8fqlXF1+v728j1RUnELLVrmgsC6gZ0zObXAzJ39JEynaQv9tf/v16V58=";
|
||||
let file = format!("example.com {KEY_A}\n");
|
||||
match check_in_str(&file, "example.com", 22, &key(KEY_ECDSA)) {
|
||||
HostKeyStatus::Changed { .. } => {}
|
||||
other => panic!("expected Changed, got {other:?}"),
|
||||
}
|
||||
// A same-type exact match elsewhere still wins over the mismatch.
|
||||
let file = format!("example.com {KEY_A}\nexample.com {KEY_ECDSA}\n");
|
||||
assert_eq!(
|
||||
check_in_str(&file, "example.com", 22, &key(KEY_ECDSA)),
|
||||
@@ -697,7 +578,6 @@ mod tests {
|
||||
check_in_str(&file, "example.com", 2222, &ka),
|
||||
HostKeyStatus::Known
|
||||
);
|
||||
// Same host on the default port is a different token → unknown.
|
||||
assert_eq!(
|
||||
check_in_str(&file, "example.com", 22, &ka),
|
||||
HostKeyStatus::Unknown
|
||||
@@ -716,8 +596,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn revoked_takes_precedence_over_an_earlier_trusted_line() {
|
||||
// A trusted line for the exact key appears FIRST, then a `@revoked` line
|
||||
// for the same host+key. Revocation must win — the key is never trusted.
|
||||
let ka = key(KEY_A);
|
||||
let file = format!("example.com {KEY_A}\n@revoked example.com {KEY_A}\n");
|
||||
assert_eq!(
|
||||
@@ -729,8 +607,6 @@ mod tests {
|
||||
#[test]
|
||||
fn cert_authority_line_is_skipped_not_flagged_as_changed() {
|
||||
let ka = key(KEY_A);
|
||||
// A CA line whose key differs from the presented key must NOT read as
|
||||
// "changed" — it should fall through to Unknown.
|
||||
let file = format!("@cert-authority example.com {KEY_B}\n");
|
||||
assert_eq!(
|
||||
check_in_str(&file, "example.com", 22, &ka),
|
||||
@@ -750,10 +626,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hashed_host_matches_via_hmac_sha1() {
|
||||
// Build a hashed entry the way OpenSSH would: salt is arbitrary bytes,
|
||||
// hash = HMAC-SHA1(salt, token). Encode both with our base64.
|
||||
let token = "example.com";
|
||||
let salt = b"0123456789abcdef1234"; // 20 bytes
|
||||
let salt = b"0123456789abcdef1234";
|
||||
let hash = hmac_sha1(salt, token.as_bytes());
|
||||
let line = format!("|1|{}|{} {KEY_A}\n", b64(salt), b64(&hash),);
|
||||
let ka = key(KEY_A);
|
||||
@@ -772,16 +646,13 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-kh-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("known_hosts");
|
||||
// Pre-seed a file WITHOUT a trailing newline to prove we don't corrupt it.
|
||||
std::fs::write(&path, format!("first.com {KEY_B}")).unwrap();
|
||||
|
||||
let ka = key(KEY_A);
|
||||
append_trusted_to(&path, "example.com", 2222, &ka).unwrap();
|
||||
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
// The original line is intact...
|
||||
assert!(contents.contains(&format!("first.com {KEY_B}")));
|
||||
// ...and the new host is trusted at its bracketed token.
|
||||
assert_eq!(
|
||||
check_in_str(&contents, "example.com", 2222, &ka),
|
||||
HostKeyStatus::Known
|
||||
@@ -801,7 +672,6 @@ mod tests {
|
||||
check_in_str(&file, "a.b.example.com", 22, &ka),
|
||||
HostKeyStatus::Known
|
||||
);
|
||||
// `*` does not cross into a different domain suffix.
|
||||
assert_eq!(
|
||||
check_in_str(&file, "web1.example.org", 22, &ka),
|
||||
HostKeyStatus::Unknown
|
||||
@@ -813,7 +683,6 @@ mod tests {
|
||||
let ka = key(KEY_A);
|
||||
let file = format!("host? {KEY_A}\n");
|
||||
assert_eq!(check_in_str(&file, "host1", 22, &ka), HostKeyStatus::Known);
|
||||
// `?` is exactly one char — "host" (zero) and "host12" (two) don't match.
|
||||
assert_eq!(check_in_str(&file, "host", 22, &ka), HostKeyStatus::Unknown);
|
||||
assert_eq!(
|
||||
check_in_str(&file, "host12", 22, &ka),
|
||||
@@ -824,8 +693,6 @@ mod tests {
|
||||
#[test]
|
||||
fn negated_pattern_disqualifies_the_line() {
|
||||
let ka = key(KEY_A);
|
||||
// Matches the whole domain except the negated host — even though the
|
||||
// positive `*.example.com` would otherwise cover it.
|
||||
let file = format!("*.example.com,!secret.example.com {KEY_A}\n");
|
||||
assert_eq!(
|
||||
check_in_str(&file, "web.example.com", 22, &ka),
|
||||
@@ -871,8 +738,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delete_removes_only_the_matching_entry_byte_for_byte() {
|
||||
// A file with CRLF, a comment, a blank line, and no trailing newline on
|
||||
// the last entry — deletion must preserve every unrelated byte.
|
||||
let contents =
|
||||
format!("# my hosts\r\nkeep.example.com {KEY_B}\n\ndrop.example.com {KEY_A}");
|
||||
let entries = list_in_str(&contents);
|
||||
@@ -884,10 +749,8 @@ mod tests {
|
||||
.clone();
|
||||
let (after, removed) = delete_in_str(&contents, &target);
|
||||
assert!(removed);
|
||||
// Everything except the dropped line is preserved exactly.
|
||||
let expected = format!("# my hosts\r\nkeep.example.com {KEY_B}\n\n");
|
||||
assert_eq!(after, expected);
|
||||
// And the dropped host is now unknown.
|
||||
let ka = key(KEY_A);
|
||||
assert_eq!(
|
||||
check_in_str(&after, "drop.example.com", 22, &ka),
|
||||
@@ -923,7 +786,6 @@ mod tests {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
// A minimal standard-base64 encoder for the test fixtures only.
|
||||
fn b64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
//! Native SSH session engine (Workstream 2).
|
||||
//!
|
||||
//! A single [`SshManager`] owns one tokio runtime and the registry of live
|
||||
//! [`SshConnection`]s. The rest of the daemon is std-threads and never enters this
|
||||
//! runtime; a native-SSH pane crosses the boundary only through the blocking
|
||||
//! `Read`/`Write` adapters in [`session`] (fed by the async channel driver) and
|
||||
//! the [`PromptBroker`] (auth/host-key round-trips).
|
||||
//!
|
||||
//! ## Connection reuse & the API WS4/WS5 build on (FR-C2)
|
||||
//! Connections are keyed by [`ConnectionKey`] (host/port/user/proxy/jump chain).
|
||||
//! A spawn for a key with a live connection reuses it — a new tab opens a fresh
|
||||
//! *channel*, never a fresh authentication. Port-forwards (WS4) and SFTP (WS5)
|
||||
//! reach a pane's connection through the same registry and open their own channels
|
||||
//! on it: [`SshConnection::open_direct_tcpip`] (Local/Dynamic forwards, and the
|
||||
//! jump transport) and [`SshConnection::open_session_channel`] (SFTP subsystem).
|
||||
//! `DaemonPane::ssh_connection` (in `daemon::pane`) exposes a pane's connection.
|
||||
|
||||
pub mod broker;
|
||||
pub mod forward;
|
||||
pub mod known_hosts;
|
||||
pub mod session;
|
||||
pub mod sftp;
|
||||
/// Workspace-scoped control requests — see `workspace::handle`.
|
||||
pub mod workspace;
|
||||
|
||||
mod auth;
|
||||
mod connect;
|
||||
mod handler;
|
||||
|
||||
/// A child process's stdio as one duplex stream. Re-exported (rather than
|
||||
/// opening `connect` as a whole) because `daemon::remote_link` wraps a
|
||||
/// `tty7-server --stdio` child in exactly the shape the `ProxyCommand` path
|
||||
/// already uses.
|
||||
pub use connect::ProcessStream;
|
||||
|
||||
pub use broker::PromptBroker;
|
||||
@@ -57,25 +35,12 @@ use forward::RemoteForwardTable;
|
||||
use handler::ClientHandler;
|
||||
use session::drive_channel;
|
||||
|
||||
/// Default connect+auth budget when the spec doesn't set one.
|
||||
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Identifies a reusable connection: same key ⇒ same authenticated transport.
|
||||
/// Includes the full proxy configuration and (recursively) the jump chain, so two
|
||||
/// specs that differ only in how they *reach* the host don't collide.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct ConnectionKey(String);
|
||||
|
||||
impl ConnectionKey {
|
||||
/// The key as a string, for callers that need to *name* a connection —
|
||||
/// a log line, an error message, an installer's "which host am I writing
|
||||
/// to". Exposed because the alternative callers reach for is peeling the
|
||||
/// derived `Debug` output apart, which silently breaks the day anything
|
||||
/// about the formatting changes.
|
||||
///
|
||||
/// It is a connection identity, not a display name: it carries the proxy
|
||||
/// and jump chain, and no user-facing label. Where the user has their own
|
||||
/// name for a host, prefer that and keep this for disambiguation.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
@@ -97,26 +62,16 @@ impl ConnectionKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-key reuse slot: a `Weak` behind an async mutex, so establishing a new
|
||||
/// connection for a key serializes (no duplicate connects) without serializing
|
||||
/// *different* keys.
|
||||
type ConnSlot = Arc<tokio::sync::Mutex<Weak<SshConnection>>>;
|
||||
|
||||
pub struct SshManager {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
conns: Mutex<HashMap<ConnectionKey, ConnSlot>>,
|
||||
/// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback),
|
||||
/// driven on this manager's runtime.
|
||||
forwards: SshForwardRegistry,
|
||||
/// Memoized remote shell-integration probes, keyed like connections. A
|
||||
/// present `None` means "probed, nothing to inject" — cached just as firmly
|
||||
/// as a hit so an unintegrable host isn't re-probed on every new tab. See
|
||||
/// [`SshManager::remote_bootstrap`].
|
||||
probes: Mutex<HashMap<ConnectionKey, Option<(remote::RemoteShell, String)>>>,
|
||||
}
|
||||
|
||||
impl SshManager {
|
||||
/// The process-wide engine. Built lazily on first native-SSH spawn.
|
||||
pub fn global() -> &'static SshManager {
|
||||
static MANAGER: OnceLock<SshManager> = OnceLock::new();
|
||||
MANAGER.get_or_init(|| {
|
||||
@@ -135,23 +90,10 @@ impl SshManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// A handle to the engine's tokio runtime. The SFTP layer (`ssh::sftp`) uses
|
||||
/// it to `block_on` one-shot operations and `spawn` background transfer jobs
|
||||
/// from the daemon's std threads (the server connection threads) without owning
|
||||
/// a second runtime. Safe to call from a non-async thread; `block_on` on the
|
||||
/// returned handle drives the future on the caller and panics only if called
|
||||
/// from *within* a runtime worker (the server threads never are).
|
||||
pub fn handle(&self) -> tokio::runtime::Handle {
|
||||
self.runtime.handle().clone()
|
||||
}
|
||||
|
||||
// ---- Synchronous forward API for the (std-thread) daemon server ----------
|
||||
//
|
||||
// The server dispatch runs on plain std threads; these block on the runtime
|
||||
// for the async establishment/teardown while returning results synchronously.
|
||||
|
||||
/// Establish a managed forward on `conn` for `pane_id`; returns the pane's
|
||||
/// forwards after the add.
|
||||
pub fn add_forward(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
@@ -164,27 +106,21 @@ impl SshManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a managed forward by id; returns the pane's remaining forwards.
|
||||
pub fn remove_forward(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> {
|
||||
self.runtime
|
||||
.block_on(self.forwards.remove(pane_id, forward_id))
|
||||
}
|
||||
|
||||
/// List a pane's managed forwards.
|
||||
pub fn list_forwards(&self, pane_id: u64) -> Vec<ManagedForward> {
|
||||
self.forwards.list(pane_id)
|
||||
}
|
||||
|
||||
/// Tear down every forward attributed to `pane_id` (pane death / blast radius).
|
||||
/// Detached on the runtime so a pane's `Drop` (which runs on a connection
|
||||
/// thread) never blocks on a remote `cancel_tcpip_forward` round-trip.
|
||||
pub fn teardown_pane_forwards(&'static self, pane_id: u64) {
|
||||
self.runtime.spawn(async move {
|
||||
self.forwards.teardown_pane(pane_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Ensure a native-SSH loopback forward for a Cmd-clicked `localhost` URL (FR-F4).
|
||||
pub fn ensure_loopback_forward(
|
||||
&self,
|
||||
pane_id: u64,
|
||||
@@ -202,28 +138,14 @@ impl SshManager {
|
||||
))
|
||||
}
|
||||
|
||||
/// Loopback forwards are no longer tracked separately — a Cmd-clicked
|
||||
/// `localhost` link registers a plain Local managed forward (see
|
||||
/// [`SshForwardRegistry::ensure_loopback`]), surfaced through `list_forwards`.
|
||||
/// This wire endpoint is kept for protocol compatibility and always empty.
|
||||
pub fn list_loopback_forwards(&self) -> Vec<LoopbackForwardInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// No-op: there is no separate loopback registry to close from (kept for
|
||||
/// protocol compatibility). Auto forwards are removed via the managed list.
|
||||
pub fn close_loopback_forward(&self, _id: &LoopbackForwardId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Kick off a native-SSH shell for a pane. Returns immediately; the connect →
|
||||
/// auth → shell sequence runs on the runtime and drives the pane through the
|
||||
/// provided bridge ends. All progress/prompt frames go via `broker`.
|
||||
///
|
||||
/// On any failure the task emits `SshStatus::Failed`, writes a one-line
|
||||
/// diagnostic into the output stream, and drops `data_tx` — which EOFs the
|
||||
/// pane's reader and surfaces as the usual `Exited`, so a failed connect looks
|
||||
/// to the rest of the daemon exactly like a shell that exited.
|
||||
pub fn spawn_native_session(
|
||||
&'static self,
|
||||
pane_id: u64,
|
||||
@@ -250,11 +172,8 @@ impl SshManager {
|
||||
broker.status(SshPhase::Failed {
|
||||
reason: reason.clone(),
|
||||
});
|
||||
// A visible, human-readable line so the pane isn't just a blank
|
||||
// that vanishes — even before WS3 renders SshStatus.
|
||||
let line = format!("\r\n\x1b[31mtty7: SSH connection failed: {reason}\x1b[0m\r\n");
|
||||
let _ = data_tx.send(line.into_bytes()).await;
|
||||
// Dropping data_tx (and cmd_rx already moved) EOFs the reader.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -271,28 +190,15 @@ impl SshManager {
|
||||
) -> Result<(), String> {
|
||||
broker.status(SshPhase::Connecting);
|
||||
|
||||
// Note: the connect timeout is applied *inside* `open_connection`, around
|
||||
// the transport + SSH handshake only — never around interactive auth,
|
||||
// which the user may reasonably take a while to complete (the broker
|
||||
// enforces its own per-prompt timeout).
|
||||
let (mut conn, reused) = self
|
||||
.open_connection(spec, broker)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
|
||||
// Publish the connection so the pane (and WS4/WS5) can open further
|
||||
// channels on it. A `Weak`, so this never keeps the connection alive past
|
||||
// the strong `Arc` the driver holds below for the shell's lifetime.
|
||||
*conn_slot.lock().unwrap() = Arc::downgrade(&conn);
|
||||
|
||||
broker.status(SshPhase::Connected);
|
||||
|
||||
// Open the shell channel on the (possibly shared) connection. This is also
|
||||
// the first liveness probe of a *reused* connection: if its transport died
|
||||
// silently — a parked forward/loopback accept loop holds an `Arc`, so the
|
||||
// dead connection's `Drop` (and `mark_dead`) never ran — the first channel
|
||||
// open errors. Self-heal: mark it dead, evict its registry slot, and
|
||||
// reconnect fresh once. A fresh connection that fails here is a real error.
|
||||
let channel = match conn.open_session_channel().await {
|
||||
Ok(channel) => channel,
|
||||
Err(e) if reused => {
|
||||
@@ -316,10 +222,6 @@ impl SshManager {
|
||||
Err(e) => return Err(format!("open shell channel failed: {e}")),
|
||||
};
|
||||
|
||||
// Establish the profile's preconfigured forwards (FR-F2) now that the
|
||||
// connection is authenticated *and* confirmed live. Failures are non-fatal —
|
||||
// each surfaces as a `ForwardStatus::Error` on the forward row, never a
|
||||
// killed session.
|
||||
for rule in &spec.forwards {
|
||||
self.forwards.establish(pane_id, conn.clone(), rule).await;
|
||||
}
|
||||
@@ -342,19 +244,9 @@ impl SshManager {
|
||||
.map_err(|e| format!("pty-req failed: {e}"))?;
|
||||
|
||||
if spec.agent_forward {
|
||||
// Best effort: some servers refuse; a refusal shouldn't abort the shell.
|
||||
let _ = channel.agent_forward(false).await;
|
||||
}
|
||||
|
||||
// Shell integration (OSC 133 + cwd reporting) for the remote shell. When
|
||||
// the remote is one we know how to bootstrap, the shell is started by an
|
||||
// `exec` request carrying a setup script that ends in `exec <shell>`,
|
||||
// rather than by a bare `shell` request; see `shell_integration::remote`.
|
||||
// Anything unrecognized — or a probe that couldn't be run — falls through
|
||||
// to the plain shell request, which is exactly what every session did
|
||||
// before this existed.
|
||||
// Opting out short-circuits the probe too, not just the bootstrap: a
|
||||
// profile with the switch off should cost nothing and touch nothing.
|
||||
let bootstrap = match spec.shell_integration {
|
||||
true => self.remote_bootstrap(&conn).await,
|
||||
false => None,
|
||||
@@ -370,40 +262,16 @@ impl SshManager {
|
||||
.map_err(|e| format!("shell request failed: {e}"))?,
|
||||
}
|
||||
|
||||
// Login script: each line verbatim + newline, in order, no expect-logic.
|
||||
for line in &spec.login_script {
|
||||
let mut bytes = line.clone().into_bytes();
|
||||
bytes.push(b'\n');
|
||||
let _ = channel.data(&bytes[..]).await;
|
||||
}
|
||||
|
||||
// Hand the channel to the pump. `conn` moves in so the shared connection
|
||||
// stays alive for this shell's lifetime (and remains reusable meanwhile).
|
||||
drive_channel(channel, data_tx, cmd_rx, conn).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Remote workspaces: one logical stream to a remote `tty7-server` ----
|
||||
|
||||
/// Open one logical stream from this daemon to the `tty7-server` on `spec`'s
|
||||
/// host, reusing (or establishing) the machine's single authenticated
|
||||
/// connection.
|
||||
///
|
||||
/// **One authentication per machine.** The connection comes from the same
|
||||
/// [`ConnectionKey`] registry the SSH panes use, so a workspace opened
|
||||
/// against a host the user already has a pane on costs no prompt at all, and
|
||||
/// a second workspace on the same host costs no second prompt — each stream
|
||||
/// is a new *channel*, never a new authentication. One channel
|
||||
/// per pane, one per workspace control stream; no multiplexing of our own on
|
||||
/// top of SSH's.
|
||||
///
|
||||
/// The returned `Arc<SshConnection>` must be held for as long as the link is
|
||||
/// used: it is the last strong reference that keeps the shared connection
|
||||
/// (and therefore the channel) alive.
|
||||
/// **What `setup` buys.** Everything below this line may need a user: the
|
||||
/// authentication, the consent to write a binary onto the machine, the
|
||||
/// discovery that the daemon already there is a different build. `setup`
|
||||
/// carries the one client that can answer — see [`RouteSetup`].
|
||||
pub async fn open_remote_link(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
@@ -412,21 +280,6 @@ impl SshManager {
|
||||
) -> anyhow::Result<(RemoteLink, Arc<SshConnection>)> {
|
||||
let (conn, _reused) = self.open_connection(spec, &setup.broker).await?;
|
||||
|
||||
// Before the first stream to a host, make sure the remote is actually
|
||||
// serving: the right version of `tty7-server`, installed and running.
|
||||
// Idempotent and cheap on the common path (two commands and one SFTP
|
||||
// stat, no download, no prompt), which is what makes it safe to call
|
||||
// before *every* link rather than once per connection.
|
||||
//
|
||||
// A `?` here means no link is opened at all, so "this machine has no
|
||||
// tty7-server" arrives as a route ack with a reason. B1 deliberately
|
||||
// left this un-stubbed rather than always-Ok for exactly that: an empty
|
||||
// implementation would turn a missing server into an opaque channel
|
||||
// failure much later.
|
||||
//
|
||||
// On a blocking thread because `Installer` is blocking start to finish
|
||||
// and one step of it waits on a human; running it on a runtime worker
|
||||
// would park the reactor that has to carry the answer back.
|
||||
let installed = {
|
||||
let install_conn = conn.clone();
|
||||
setup
|
||||
@@ -434,13 +287,6 @@ impl SshManager {
|
||||
.await??
|
||||
};
|
||||
|
||||
// The installed binary's **absolute** path, not the bare name. Nothing
|
||||
// puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the
|
||||
// file there is `tty7-server-c<control>p<protocol>` — so
|
||||
// `exec tty7-server --stdio` is a `command not found` on a machine the
|
||||
// install just succeeded on.
|
||||
// The install pass we just ran is what knows the path, so it hands it
|
||||
// over rather than leaving the transport to guess.
|
||||
let base = match server_command {
|
||||
Some(explicit) => explicit.to_string(),
|
||||
None => format!(
|
||||
@@ -450,9 +296,6 @@ impl SshManager {
|
||||
};
|
||||
let command = setup.channel.bridge_command(&base);
|
||||
|
||||
// A pane connection never takes the cached `direct-streamlocal` entry:
|
||||
// that entry names the *control* socket, and the pane dialect is served
|
||||
// on a different one. See `RouteChannel::bridge_command`.
|
||||
let entry = match setup.channel {
|
||||
RouteChannel::Pane => RemoteEntry::SessionExec {
|
||||
command: command.clone(),
|
||||
@@ -461,10 +304,6 @@ impl SshManager {
|
||||
conn.remote_entry_or_init(|| async {
|
||||
let env = probe_remote_env(&conn).await;
|
||||
let socket = env.as_ref().and_then(remote_link::remote_control_socket);
|
||||
// Optimistic: `AllowStreamLocalForwarding` defaults to `yes`
|
||||
// and the only way to learn otherwise is to be refused,
|
||||
// which the demotion below turns into a permanent,
|
||||
// connection-wide answer.
|
||||
remote_link::choose_entry(socket.as_deref(), true, &command)
|
||||
})
|
||||
.await
|
||||
@@ -475,8 +314,6 @@ impl SshManager {
|
||||
match conn.open_direct_streamlocal(socket).await {
|
||||
Ok(channel) => return Ok((RemoteLink::stream_local(channel), conn)),
|
||||
Err(e) => {
|
||||
// The refusal every later stream on this connection must not
|
||||
// repeat: cache the fallback before taking it.
|
||||
log::info!(
|
||||
"ssh {:?}: direct-streamlocal to {socket} refused ({e}); \
|
||||
falling back to `{command}`",
|
||||
@@ -499,45 +336,18 @@ impl SshManager {
|
||||
Ok((RemoteLink::session_exec(channel), conn))
|
||||
}
|
||||
|
||||
/// Replace the `tty7-server` running on `spec`'s host with this client's
|
||||
/// build — "Restart Server", and **it drops every pane
|
||||
/// that server is hosting**.
|
||||
///
|
||||
/// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction)
|
||||
/// header, which a client only writes after a user has answered the
|
||||
/// keep-or-restart prompt with "Restart Server". Nothing in the connect path
|
||||
/// calls this: an older daemon on the far side keeps serving, because it owns
|
||||
/// live work and only its owner can decide to throw that away.
|
||||
///
|
||||
/// Deliberately **not** an `ensure_remote_server` first. The mismatch that
|
||||
/// raises the prompt is discovered by an install pass that has already put
|
||||
/// this build's binary in place, so there is nothing left to install — and a
|
||||
/// second pass would rediscover the very mismatch the user is answering and
|
||||
/// relay a fresh prompt for it the moment the restart finished.
|
||||
pub async fn restart_remote_server(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
setup: &RouteSetup,
|
||||
) -> anyhow::Result<()> {
|
||||
let (conn, _reused) = self.open_connection(spec, &setup.broker).await?;
|
||||
// Blocking start to finish (SIGTERM, poll for the socket to go, launch,
|
||||
// poll for it to answer) and it may stop to ask the user for a password
|
||||
// on the way in — the same reason `open_remote_link` keeps the installer
|
||||
// off the runtime's workers.
|
||||
setup
|
||||
.blocking(move || crate::daemon::install::restart_remote_daemon(&conn))
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reinstall this client's `tty7-server` on `spec`'s host over whatever is at
|
||||
/// its path, then restart the daemon onto it — "Replace Server", and **it
|
||||
/// drops every pane that server is hosting**.
|
||||
///
|
||||
/// Unlike [`restart_remote_server`](Self::restart_remote_server) this *does*
|
||||
/// write: it is the answer to a handshake that failed against a binary whose
|
||||
/// name promised a dialect it does not speak, so the file itself is what has
|
||||
/// to change. See [`crate::daemon::install::Installer::replace`].
|
||||
pub async fn replace_remote_server(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
@@ -550,9 +360,6 @@ impl SshManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`open_remote_link`](Self::open_remote_link) for the daemon's std threads
|
||||
/// (the router runs on one). Safe from any thread that is not itself a
|
||||
/// runtime worker — the server's connection threads never are.
|
||||
pub fn open_remote_link_blocking(
|
||||
&self,
|
||||
spec: &NativeSshSpec,
|
||||
@@ -563,28 +370,10 @@ impl SshManager {
|
||||
.block_on(self.open_remote_link(spec, setup, server_command))
|
||||
}
|
||||
|
||||
/// Drop a connection key's registry slot so the next `open_connection` for it
|
||||
/// establishes a fresh connection instead of upgrading a stale `Weak`. Called
|
||||
/// by the self-healing reuse path when a reused connection turns out dead.
|
||||
fn evict_connection(&self, key: &ConnectionKey) {
|
||||
self.conns.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
/// The shell-integration bootstrap script for `conn`'s next shell, or `None`
|
||||
/// to start that shell bare.
|
||||
///
|
||||
/// Deciding costs one `exec` round-trip against the remote (see
|
||||
/// [`probe_remote_shell`]), so the answer is memoized on the connection key —
|
||||
/// the same identity connections are reused under. Opening a second tab to a
|
||||
/// host therefore pays nothing, and a *reconnect* to a host probed earlier
|
||||
/// pays nothing either: which shell a login lands in doesn't change between
|
||||
/// connections, so the cache deliberately outlives them.
|
||||
///
|
||||
/// Two panes racing to a not-yet-probed host may both probe. That is a
|
||||
/// duplicated round-trip on a cold connection, not a correctness problem —
|
||||
/// the probe has no side effects and both arrive at the same answer — so it
|
||||
/// isn't worth serializing every spawn behind a per-key lock the way
|
||||
/// connection establishment is.
|
||||
async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> {
|
||||
let key = conn.key().clone();
|
||||
let cached = { self.probes.lock().unwrap().get(&key).cloned() };
|
||||
@@ -605,12 +394,6 @@ impl SshManager {
|
||||
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path))
|
||||
}
|
||||
|
||||
/// Establish (or reuse) the connection for `spec`, recursing through the jump
|
||||
/// chain. Boxed because it is `async`-recursive. The returned `bool` is `true`
|
||||
/// when an existing connection was reused (no fresh authentication) — the
|
||||
/// caller uses it to self-heal: a reused connection whose transport silently
|
||||
/// died errors on its first channel open, and only then is it worth evicting
|
||||
/// and reconnecting.
|
||||
fn open_connection<'a>(
|
||||
&'a self,
|
||||
spec: &'a NativeSshSpec,
|
||||
@@ -627,17 +410,10 @@ impl SshManager {
|
||||
let mut guard = slot.lock().await;
|
||||
if let Some(conn) = guard.upgrade() {
|
||||
if conn.is_alive() {
|
||||
// Reuse: a new channel on the existing authenticated connection.
|
||||
return Ok((conn, true));
|
||||
}
|
||||
}
|
||||
|
||||
// Establish the jump connection first (recursively) so its
|
||||
// `direct-tcpip` channel can be this connection's transport — unless
|
||||
// a ProxyCommand is also configured: it outranks the jump in
|
||||
// `build_transport`, and establishing (and interactively
|
||||
// authenticating) a jump connection that would then be discarded
|
||||
// wastes the user's prompts.
|
||||
let has_proxy_command =
|
||||
matches!(&spec.proxy, crate::daemon::protocol::SshProxy::Command(_));
|
||||
let jump = match &spec.jump {
|
||||
@@ -647,15 +423,11 @@ impl SshManager {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Transport + SSH handshake under the connect-timeout budget. Auth is
|
||||
// deliberately outside it (see `run_session`).
|
||||
let budget = spec
|
||||
.connect_timeout_s
|
||||
.filter(|v| *v > 0)
|
||||
.map(|v| Duration::from_secs(u64::from(v)))
|
||||
.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
|
||||
// The connection's Remote-forward table, shared with its handler so
|
||||
// incoming `forwarded-tcpip` channels resolve to a local target (WS4).
|
||||
let remote_forwards = RemoteForwardTable::default();
|
||||
let handler = ClientHandler {
|
||||
host: spec.host.clone(),
|
||||
@@ -672,12 +444,6 @@ impl SshManager {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ssh handshake failed: {e}"))
|
||||
};
|
||||
// Watchdog rather than a flat `timeout(budget, ...)`: russh raises the
|
||||
// host-key confirmation *inside* connect_stream (via
|
||||
// `check_server_key`), and the user reading a fingerprint must not
|
||||
// race the network timeout. Ticks are only billed against the budget
|
||||
// while no broker prompt is pending; the broker's own per-prompt
|
||||
// timeout still bounds an unanswered dialog.
|
||||
let mut handshake = std::pin::pin!(handshake);
|
||||
let mut remaining = budget;
|
||||
const TICK: Duration = Duration::from_millis(200);
|
||||
@@ -707,27 +473,10 @@ impl SshManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait for the shell probe before giving up on integrating a
|
||||
/// remote. Generous, because the probe runs under the remote's login shell and
|
||||
/// therefore behind whatever its `.zshenv` does; short enough that a host which
|
||||
/// never answers costs a pause, not a hang. Expiring is not an error — the
|
||||
/// session continues with a plain shell.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Cap on probe output, in case the remote's startup files are chatty. Far more
|
||||
/// than the two lines we asked for; a remote that exceeds it has already told us
|
||||
/// everything [`remote::parse_probe`] could use.
|
||||
const PROBE_OUTPUT_LIMIT: usize = 8 * 1024;
|
||||
|
||||
/// Ask the remote which login shell it would start, on a throwaway channel.
|
||||
///
|
||||
/// This is a non-PTY `exec`, so it runs and exits without touching the session
|
||||
/// the user is about to get; nothing here can break that session, and every
|
||||
/// failure path returns `None`, meaning "start the shell bare".
|
||||
///
|
||||
/// stderr is folded in with stdout because the marker-based parse tolerates
|
||||
/// noise, and a remote whose startup files complain on stderr would otherwise
|
||||
/// have its (perfectly good) answer thrown away.
|
||||
async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell, String)> {
|
||||
let mut channel = conn.open_session_channel().await.ok()?;
|
||||
channel.exec(true, remote::PROBE_COMMAND).await.ok()?;
|
||||
@@ -747,24 +496,11 @@ async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell
|
||||
}
|
||||
}
|
||||
};
|
||||
// A timeout doesn't discard what did arrive: the answer is on the second
|
||||
// line, so a remote that printed it and then stalled before closing the
|
||||
// channel is still perfectly readable.
|
||||
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
|
||||
|
||||
remote::parse_probe(&String::from_utf8_lossy(&out))
|
||||
}
|
||||
|
||||
/// Read the four environment variables the remote's control socket path is
|
||||
/// derived from, on a throwaway `exec` channel.
|
||||
///
|
||||
/// `None` when the remote said nothing usable — the caller then takes the
|
||||
/// `--stdio` bridge, which resolves the path in the process that binds it, so a
|
||||
/// failed probe costs a slower transport and never a failed connection.
|
||||
///
|
||||
/// stderr is folded in for the same reason the shell probe does it: the parse is
|
||||
/// marker-based and tolerates noise, and discarding a good answer because the
|
||||
/// remote's startup files complained would be gratuitous.
|
||||
async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv> {
|
||||
let mut channel = conn.open_session_channel().await.ok()?;
|
||||
channel
|
||||
@@ -793,9 +529,6 @@ async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv
|
||||
(env != remote_link::RemoteEnv::default()).then_some(env)
|
||||
}
|
||||
|
||||
/// A conservative set of PTY modes for the shell channel — an interactive TTY
|
||||
/// with canonical input, echo, and signal handling on, and standard baud codes.
|
||||
/// The remote line discipline uses these as its starting point.
|
||||
fn sane_terminal_modes() -> Vec<(Pty, u32)> {
|
||||
vec![
|
||||
(Pty::ISIG, 1),
|
||||
@@ -858,14 +591,9 @@ mod tests {
|
||||
};
|
||||
assert_ne!(a, ConnectionKey::from_spec(&c));
|
||||
|
||||
// Identical connection params → identical key (reuse).
|
||||
assert_eq!(a, ConnectionKey::from_spec(&base_spec()));
|
||||
}
|
||||
|
||||
/// `as_str` is what names a connection in prompts, logs and the installer's
|
||||
/// "which host am I writing to". It must carry the jump chain: two hosts
|
||||
/// reached through different bastions are different connections, and a
|
||||
/// label that collapsed them would put an install prompt on the wrong box.
|
||||
#[test]
|
||||
fn the_key_string_names_the_whole_chain() {
|
||||
assert_eq!(ConnectionKey::from_spec(&base_spec()).as_str(), "u@h:22");
|
||||
@@ -882,9 +610,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn evict_connection_clears_the_registry_slot() {
|
||||
// The self-heal path evicts a dead connection's key so the next
|
||||
// `open_connection` establishes fresh instead of upgrading a stale `Weak`.
|
||||
// Exercise just the registry map — no live server needed.
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.expect("build test runtime");
|
||||
@@ -934,8 +659,6 @@ mod tests {
|
||||
spec.port = port;
|
||||
spec.auth_mode = SshAuthMode::Gssapi;
|
||||
spec.connect_timeout_s = Some(10);
|
||||
// Prove GSSAPI itself without requiring a GUI host-key prompt or mutating
|
||||
// the user's known_hosts from this live test.
|
||||
spec.verify_host_keys = false;
|
||||
|
||||
let manager = SshManager::global();
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
//! The async↔blocking bridge for a native-SSH pane, plus the connection wrapper.
|
||||
//!
|
||||
//! The daemon's pane reader/writer threads are plain std threads doing *blocking*
|
||||
//! `Read`/`Write` (see `daemon::pane`). russh is async. This module is the seam:
|
||||
//!
|
||||
//! - [`SshReader`] is a blocking `Read` over a **bounded** channel fed by the
|
||||
//! channel driver. A full channel makes the driver's `data_tx.send().await`
|
||||
//! pause, which stops it draining `channel.wait()`, which lets russh's own
|
||||
//! window management apply backpressure to the SSH channel — so a slow client
|
||||
//! (via `OutputGate`) throttles the remote exactly like a full PTY throttles a
|
||||
//! local child, with no unbounded spool in between. Channel EOF/close drops
|
||||
//! `data_tx`, so `blocking_recv()` returns `None` and the read returns `Ok(0)`
|
||||
//! — the same liveness signal a PTY hangup gives, feeding the existing death
|
||||
//! path.
|
||||
//! - [`SshWriter`] is a blocking `Write` that forwards bytes to the driver over an
|
||||
//! unbounded command channel (keystrokes are low-volume; never block input).
|
||||
//! - [`ChannelCmd`] carries input / resize / close from the pane's std threads to
|
||||
//! the async driver.
|
||||
//! - [`drive_channel`] is the per-pane async task pumping the shell channel.
|
||||
//! - [`SshConnection`] wraps one authenticated `russh::client::Handle`. It is the
|
||||
//! unit of reuse and of the FR-C2 blast radius (see the doc comment there), and
|
||||
//! the API surface WS4 (port-forwards) and WS5 (SFTP) reuse to open further
|
||||
//! channels on a pane's existing connection.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
@@ -35,46 +11,25 @@ use crate::daemon::remote_link::RemoteEntry;
|
||||
use super::ConnectionKey;
|
||||
use super::forward::RemoteForwardTable;
|
||||
|
||||
/// Bounded depth (in messages) of the driver→reader data channel. Each message is
|
||||
/// one russh data chunk (≤ the channel's max packet size, ~32 KiB), so this caps
|
||||
/// the in-flight spool at a few hundred KiB before backpressure engages — small
|
||||
/// enough to keep memory bounded, large enough not to stall a healthy client.
|
||||
const DATA_CHANNEL_DEPTH: usize = 16;
|
||||
|
||||
/// A slot the connect task publishes the pane's established connection into, as a
|
||||
/// `Weak` so it never keeps the connection alive past the shell's own strong
|
||||
/// `Arc` (held by the channel driver). WS4 (forwards) and WS5 (SFTP) upgrade it —
|
||||
/// via `DaemonPane::ssh_connection()` — to open further channels on the pane's
|
||||
/// shared connection. Empty until the connection authenticates.
|
||||
pub type SharedConnection = Arc<Mutex<Weak<SshConnection>>>;
|
||||
|
||||
/// A command from the pane's std threads to the async channel driver.
|
||||
pub enum ChannelCmd {
|
||||
/// Bytes to write to the shell channel (keyboard input / paste / login script).
|
||||
Data(Vec<u8>),
|
||||
/// A terminal resize → `window-change` request.
|
||||
Resize(WinSize),
|
||||
/// Close the channel (kill/hangup). The driver then exits and its EOF reaches
|
||||
/// the reader.
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The pane-facing handle for a native-SSH session: where resize/close/input
|
||||
/// commands are sent. Cloned into the [`SshWriter`] and held by the pane's
|
||||
/// backend so `resize`/`kill` reach the driver.
|
||||
pub struct SshSessionHandle {
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<ChannelCmd>,
|
||||
}
|
||||
|
||||
impl SshSessionHandle {
|
||||
pub fn resize(&self, size: WinSize) {
|
||||
// A closed channel just means the driver already exited (the pane is
|
||||
// dying); dropping the resize is correct.
|
||||
let _ = self.cmd_tx.send(ChannelCmd::Resize(size));
|
||||
}
|
||||
|
||||
/// Ask the driver to close the shell channel. Idempotent — a second send after
|
||||
/// the driver exited is a harmless no-op.
|
||||
pub fn close(&self) {
|
||||
let _ = self.cmd_tx.send(ChannelCmd::Close);
|
||||
}
|
||||
@@ -86,7 +41,6 @@ impl SshSessionHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking `Read` half of the bridge — see the module comment.
|
||||
pub struct SshReader {
|
||||
rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
|
||||
leftover: Vec<u8>,
|
||||
@@ -95,18 +49,13 @@ pub struct SshReader {
|
||||
|
||||
impl Read for SshReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
// Drain any partial chunk left from a previous read first.
|
||||
while self.pos >= self.leftover.len() {
|
||||
match self.rx.blocking_recv() {
|
||||
Some(data) if !data.is_empty() => {
|
||||
self.leftover = data;
|
||||
self.pos = 0;
|
||||
}
|
||||
// An empty chunk shouldn't occur (the driver only forwards
|
||||
// non-empty data), but if it did, just wait for the next.
|
||||
Some(_) => continue,
|
||||
// Sender dropped: channel EOF/close. Report clean EOF so the
|
||||
// pane's death path fires exactly as on a PTY hangup.
|
||||
None => return Ok(0),
|
||||
}
|
||||
}
|
||||
@@ -117,7 +66,6 @@ impl Read for SshReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking `Write` half of the bridge — forwards input bytes to the driver.
|
||||
pub struct SshWriter {
|
||||
handle: Arc<SshSessionHandle>,
|
||||
}
|
||||
@@ -133,9 +81,6 @@ impl Write for SshWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the paired ends of the bridge for one pane: the blocking reader/writer
|
||||
/// the daemon threads use, the shared handle for resize/close, and the two channel
|
||||
/// ends the async driver takes (`data_tx` to push output, `cmd_rx` to pull input).
|
||||
pub struct BridgeEnds {
|
||||
pub reader: SshReader,
|
||||
pub writer: SshWriter,
|
||||
@@ -163,7 +108,6 @@ pub fn make_bridge() -> BridgeEnds {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel dims for a `window-change`, mirroring `pty_size` in `daemon::pane`.
|
||||
fn pixels(size: WinSize) -> (u32, u32) {
|
||||
(
|
||||
u32::from(size.cols).saturating_mul(u32::from(size.cell_w)),
|
||||
@@ -171,11 +115,6 @@ fn pixels(size: WinSize) -> (u32, u32) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The per-pane async task: pump shell-channel output to the reader and channel
|
||||
/// commands to the remote. Ends (dropping `data_tx`, EOFing the reader) on channel
|
||||
/// EOF/close, an explicit `Close`, or the command sender being dropped (pane
|
||||
/// gone). `_conn` is held for the session's lifetime so the shared connection
|
||||
/// isn't dropped (and disconnected) while this shell is still open.
|
||||
pub async fn drive_channel(
|
||||
mut channel: Channel<Msg>,
|
||||
data_tx: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
@@ -186,35 +125,21 @@ pub async fn drive_channel(
|
||||
tokio::select! {
|
||||
msg = channel.wait() => match msg {
|
||||
Some(ChannelMsg::Data { data }) => {
|
||||
// Awaiting here is the backpressure point: a full bounded
|
||||
// channel pauses us, which pauses `channel.wait()`, which lets
|
||||
// russh throttle the SSH window. An error means the reader was
|
||||
// dropped (pane gone) — stop.
|
||||
if data_tx.send(data.to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Merge stderr (extended data) into the same byte stream: a shell
|
||||
// channel's stderr is part of the terminal output the user expects
|
||||
// to see inline, exactly as a PTY interleaves them.
|
||||
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
||||
if data_tx.send(data.to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Exit status/signal arrive before the final Eof/Close; record
|
||||
// nothing special — the daemon's existing `Exited{code:None}` path
|
||||
// (driven by the reader's EOF below) is what the GUI consumes, and
|
||||
// it doesn't depend on the code. Keep looping for any trailing data.
|
||||
Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::ExitSignal { .. }) => {}
|
||||
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
|
||||
Some(_) => {}
|
||||
},
|
||||
cmd = cmd_rx.recv() => match cmd {
|
||||
Some(ChannelCmd::Data(bytes)) => {
|
||||
// `&[u8]` implements tokio's AsyncRead; this writes one data
|
||||
// message. A failure means the channel is gone — let the
|
||||
// wait() side observe the close.
|
||||
let _ = channel.data(&bytes[..]).await;
|
||||
}
|
||||
Some(ChannelCmd::Resize(size)) => {
|
||||
@@ -223,8 +148,6 @@ pub async fn drive_channel(
|
||||
.window_change(u32::from(size.cols), u32::from(size.rows), pw, ph)
|
||||
.await;
|
||||
}
|
||||
// Explicit close, or the pane dropped its command sender: tear the
|
||||
// channel down and exit so the reader EOFs.
|
||||
Some(ChannelCmd::Close) | None => {
|
||||
let _ = channel.eof().await;
|
||||
let _ = channel.close().await;
|
||||
@@ -233,49 +156,14 @@ pub async fn drive_channel(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Falling out of the loop drops `data_tx`; the reader's next `blocking_recv`
|
||||
// returns `None` → `read` returns `Ok(0)` → the pane reports `Exited`.
|
||||
}
|
||||
|
||||
/// One authenticated russh connection, shared by every pane (and later every SFTP
|
||||
/// session / port-forward) that resolved to the same [`ConnectionKey`].
|
||||
///
|
||||
/// **Blast radius (FR-C2).** All shell channels for a given key share this one
|
||||
/// `Handle`. If the underlying transport drops, every channel opened on it EOFs
|
||||
/// at once, so *every* pane sharing the connection sees `Exited` together — the
|
||||
/// PRD's documented "all shared panes go disconnected as a unit" semantics. The
|
||||
/// connection stays in the registry as a `Weak`; when the last shell/SFTP/forward
|
||||
/// that holds an `Arc<SshConnection>` drops, this `Drop` disconnects the session.
|
||||
/// A subsequent spawn for the same key finds either a live connection (reuse, no
|
||||
/// re-auth — new tabs are instant) or a dead/absent one (a fresh connect).
|
||||
pub struct SshConnection {
|
||||
/// The authenticated russh handle. `russh::client::Handle` is `Send` but not
|
||||
/// `Sync` (it owns an `UnboundedReceiver`), yet the connection registry and the
|
||||
/// static [`super::SshManager`] must be `Sync`. A `tokio::Mutex` makes the
|
||||
/// whole `SshConnection` `Send + Sync`; the lock is uncontended (channel opens
|
||||
/// are infrequent) and holding it across the open `.await` is exactly what
|
||||
/// tokio mutexes are for.
|
||||
handle: tokio::sync::Mutex<russh::client::Handle<super::handler::ClientHandler>>,
|
||||
/// The key this connection is registered under. Retained for diagnostics and
|
||||
/// as the stable identity WS4/WS5 will match against.
|
||||
#[allow(dead_code)]
|
||||
key: ConnectionKey,
|
||||
/// The connection's active `tcpip-forward` bindings (WS4 Remote forwards).
|
||||
/// Shared with this connection's [`super::handler::ClientHandler`] so incoming
|
||||
/// `forwarded-tcpip` channels resolve to a local target. Empty for a connection
|
||||
/// with no remote forwards.
|
||||
remote_forwards: RemoteForwardTable,
|
||||
alive: AtomicBool,
|
||||
/// How this host's `tty7-server` is reached — probed once, then reused by
|
||||
/// every remote workspace stream on this connection.
|
||||
///
|
||||
/// Per *connection*, not per channel: deciding costs a round trip (an `exec`
|
||||
/// to read the remote's environment, and on a host with
|
||||
/// `AllowStreamLocalForwarding no` a rejected channel open on top), and the
|
||||
/// answer cannot change while the connection lives. Behind a `tokio::Mutex`
|
||||
/// held across the probe, so two workspaces opening at once produce one
|
||||
/// probe rather than two — unlike [`super::SshManager`]'s shell-integration
|
||||
/// cache, where a duplicated probe is merely wasted work.
|
||||
remote_entry: tokio::sync::Mutex<Option<RemoteEntry>>,
|
||||
}
|
||||
|
||||
@@ -294,23 +182,11 @@ impl SshConnection {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // WS4/WS5 seam: identify a pane's shared connection
|
||||
#[allow(dead_code)]
|
||||
pub fn key(&self) -> &ConnectionKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Whether this connection is still usable for reuse.
|
||||
///
|
||||
/// Two signals: the `alive` flag (cleared by [`mark_dead`](Self::mark_dead) on
|
||||
/// teardown or when a reuse attempt finds the transport dead) **and** the russh
|
||||
/// handle's own liveness — when russh's session task ends (transport dropped),
|
||||
/// its command sender closes, so `handle.is_closed()` flips to true. The flag
|
||||
/// alone is unreliable: `mark_dead` only runs from `Drop`, but a parked
|
||||
/// forward/loopback accept loop holds an `Arc<SshConnection>`, so a dead
|
||||
/// connection's `Drop` never runs and the flag stays true. Consulting
|
||||
/// `is_closed()` (via a non-blocking `try_lock`; a contended lock means an open
|
||||
/// is in flight, so assume alive) catches that case cheaply. The self-healing
|
||||
/// reconnect in `SshManager::run_session` is the belt-and-suspenders backstop.
|
||||
pub fn is_alive(&self) -> bool {
|
||||
if !self.alive.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
@@ -321,23 +197,14 @@ impl SshConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this connection unusable for reuse (teardown, or a reuse attempt that
|
||||
/// found the transport dead). Idempotent.
|
||||
pub(super) fn mark_dead(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Open a new interactive session channel on this connection. Used for shells
|
||||
/// (WS2) and reused by WS5 to open the SFTP subsystem channel on a pane's
|
||||
/// existing connection.
|
||||
pub async fn open_session_channel(&self) -> Result<Channel<Msg>, russh::Error> {
|
||||
self.handle.lock().await.channel_open_session().await
|
||||
}
|
||||
|
||||
/// Open a `direct-tcpip` channel to `host:port` through this connection. This
|
||||
/// is both the jump-host transport primitive (WS2) and the Local/Dynamic
|
||||
/// port-forward primitive WS4 will build on, opened on the pane's shared
|
||||
/// connection rather than a control socket.
|
||||
pub async fn open_direct_tcpip(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -355,18 +222,6 @@ impl SshConnection {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Open a `direct-streamlocal@openssh.com` channel to `socket_path` on the
|
||||
/// remote — the preferred way into a remote `tty7-server`.
|
||||
///
|
||||
/// The remote's sshd connects the channel to that Unix socket itself, so the
|
||||
/// far end sees an ordinary local connection and needs no extra process. The
|
||||
/// extension is OpenSSH's, and `AllowStreamLocalForwarding` defaults to
|
||||
/// `yes`; an administrator who set it to `no` makes this fail at channel
|
||||
/// open, which is the signal [`RemoteEntry`] caches a fallback for.
|
||||
///
|
||||
/// `socket_path` is the remote's path and is sent verbatim — no `~`, no
|
||||
/// variables, no client-side path arithmetic (a Windows client's
|
||||
/// `PathBuf::join` would corrupt it).
|
||||
pub async fn open_direct_streamlocal(
|
||||
&self,
|
||||
socket_path: &str,
|
||||
@@ -378,13 +233,6 @@ impl SshConnection {
|
||||
.await
|
||||
}
|
||||
|
||||
/// This connection's cached [`RemoteEntry`], probing with `init` the first
|
||||
/// time anyone asks.
|
||||
///
|
||||
/// The lock is held across `init` on purpose: the point of the cache is that
|
||||
/// the round trips happen once, and two workspaces opening simultaneously
|
||||
/// against a cold connection is the *normal* case (a window restoring its
|
||||
/// layout), not a rare race.
|
||||
pub async fn remote_entry_or_init<F, Fut>(&self, init: F) -> RemoteEntry
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
@@ -404,22 +252,10 @@ impl SshConnection {
|
||||
entry
|
||||
}
|
||||
|
||||
/// Replace the cached entry after the preferred one failed in use.
|
||||
///
|
||||
/// A `direct-streamlocal` open that the server refuses is not necessarily
|
||||
/// visible at probe time — a daemon can be restarted, a socket removed, an
|
||||
/// administrator's `AllowStreamLocalForwarding no` applied on reload — so
|
||||
/// the first failure demotes the connection for good rather than letting
|
||||
/// every later stream pay the same rejected round trip.
|
||||
pub async fn set_remote_entry(&self, entry: RemoteEntry) {
|
||||
*self.remote_entry.lock().await = Some(entry);
|
||||
}
|
||||
|
||||
/// Request a `tcpip-forward` binding on `bind_host:bind_port`, routing incoming
|
||||
/// `forwarded-tcpip` channels to `target_host:target_port` (WS4 Remote forward).
|
||||
/// Registers the target *before* the request so an eager server channel finds
|
||||
/// it. Returns the resolved bind port (the server assigns one when `bind_port`
|
||||
/// is 0). On failure the registration is rolled back.
|
||||
pub async fn add_remote_forward(
|
||||
&self,
|
||||
bind_host: &str,
|
||||
@@ -460,8 +296,6 @@ impl SshConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a previously requested `tcpip-forward` binding (best effort) and drop
|
||||
/// its target registration.
|
||||
pub async fn cancel_remote_forward(&self, bind_host: &str, bind_port: u16) {
|
||||
self.remote_forwards.unregister(bind_host, bind_port);
|
||||
let _ = self
|
||||
@@ -478,13 +312,9 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
/// The blocking reader delivers pushed chunks in order and, once the driver
|
||||
/// drops its `data_tx`, reports clean EOF (`Ok(0)`) — the liveness signal the
|
||||
/// pane's death path keys off, identical to a PTY hangup.
|
||||
#[test]
|
||||
fn reader_delivers_chunks_then_eofs_on_sender_drop() {
|
||||
let mut bridge = make_bridge();
|
||||
// Push two chunks into the driver→reader channel (buffered; capacity 16).
|
||||
bridge.data_tx.try_send(b"hello ".to_vec()).unwrap();
|
||||
bridge.data_tx.try_send(b"world".to_vec()).unwrap();
|
||||
|
||||
@@ -494,13 +324,10 @@ mod tests {
|
||||
let n = bridge.reader.read(&mut buf).unwrap();
|
||||
assert_eq!(&buf[..n], b"world");
|
||||
|
||||
// Drop the sender: the next read must EOF, not block forever.
|
||||
drop(bridge.data_tx);
|
||||
assert_eq!(bridge.reader.read(&mut buf).unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A partial read keeps the chunk's tail buffered for the next read (the reader
|
||||
/// must never drop bytes when `buf` is smaller than a chunk).
|
||||
#[test]
|
||||
fn reader_preserves_chunk_tail_across_reads() {
|
||||
let mut bridge = make_bridge();
|
||||
@@ -512,10 +339,6 @@ mod tests {
|
||||
assert_eq!(&small[..n], b"ef");
|
||||
}
|
||||
|
||||
/// The bounded data channel applies backpressure: once `DATA_CHANNEL_DEPTH`
|
||||
/// chunks are queued, a further push is refused (`Full`) until the reader
|
||||
/// drains one — this is what makes a slow client (via `OutputGate`) throttle
|
||||
/// the SSH channel window instead of spooling unboundedly.
|
||||
#[test]
|
||||
fn bounded_channel_applies_backpressure_until_drained() {
|
||||
let mut bridge = make_bridge();
|
||||
@@ -525,10 +348,8 @@ mod tests {
|
||||
.try_send(vec![i as u8])
|
||||
.expect("within capacity");
|
||||
}
|
||||
// At capacity: a further push is rejected rather than buffered.
|
||||
assert!(bridge.data_tx.try_send(vec![0xff]).is_err());
|
||||
|
||||
// Drain one chunk; capacity frees, so the next push succeeds.
|
||||
let mut buf = [0u8; 8];
|
||||
let n = bridge.reader.read(&mut buf).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
@@ -538,12 +359,6 @@ mod tests {
|
||||
|
||||
impl Drop for SshConnection {
|
||||
fn drop(&mut self) {
|
||||
// The last holder of the connection is going away. Marking dead keeps a
|
||||
// racing reuse from adopting it. Dropping `self.handle` (which happens
|
||||
// right after this) drops the last sender to russh's session task, which
|
||||
// ends the session and closes the transport — an immediate teardown. A
|
||||
// *clean* protocol disconnect would need an `.await`, which `Drop` can't
|
||||
// do; an abrupt close is the right behavior for teardown anyway.
|
||||
self.mark_dead();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,3 @@
|
||||
//! SFTP engine for native-SSH panes (Workstream 5).
|
||||
//!
|
||||
//! One [`SftpManager`] (a process-wide singleton) rides the same tokio runtime the
|
||||
//! [`SshManager`](super::SshManager) owns. It answers the daemon's SFTP control
|
||||
//! messages (`SftpList` / `SftpOp` / transfer start/cancel/list) by opening an
|
||||
//! SFTP-subsystem channel on a pane's already-authenticated `SshConnection` and
|
||||
//! driving [`russh_sftp`] over it.
|
||||
//!
|
||||
//! ## Session lifecycle
|
||||
//! - **One cached [`SftpSession`] per [`SshConnection`]** (keyed by
|
||||
//! [`ConnectionKey`]), reused across every pane that shares the connection.
|
||||
//! - The cache stores a `Weak<SshConnection>` beside the session; a lookup reuses
|
||||
//! the session only while that weak still upgrades to the *same* live connection
|
||||
//! (`Arc::ptr_eq`). A reconnect (new connection, same key) transparently gets a
|
||||
//! fresh SFTP session.
|
||||
//! - One-shot operations run through [`SftpManager::with_session`], which retries
|
||||
//! once with a freshly re-opened session **only** on a transport/channel failure
|
||||
//! — so a dead subsystem channel (while the connection itself lives) is re-opened
|
||||
//! transparently, while a logical SFTP error (permission denied, no such file)
|
||||
//! returns directly without a pointless retry.
|
||||
//!
|
||||
//! ## Threading
|
||||
//! The server's std connection threads call the **sync** methods here
|
||||
//! ([`list`](SftpManager::list) etc.), which `block_on` the SSH runtime handle.
|
||||
//! Background transfers are `spawn`ed onto that runtime and report progress the
|
||||
//! GUI polls via [`list_jobs`](SftpManager::list_jobs).
|
||||
//!
|
||||
//! ## Notes / limitations
|
||||
//! - **posix-rename:** upload writes a `.tty7-upload-<rand>` temp then renames over
|
||||
//! the target. russh-sftp 2.3.0's high-level API does not expose the
|
||||
//! `posix-rename@openssh.com` extension, so the swap is a plain SFTP `rename`
|
||||
//! with a remove-then-rename fallback when the server refuses an
|
||||
//! overwrite-rename (FR-T2's intent: atomic-ish temp-file finish).
|
||||
//! - Local filesystem access is the daemon process's own (same user) — fine per
|
||||
//! the spec.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -52,19 +16,10 @@ use crate::daemon::protocol::{
|
||||
|
||||
use super::{ConnectionKey, SshConnection, SshManager};
|
||||
|
||||
/// Chunk size for streaming reads/writes (matches the Tabby reference).
|
||||
const CHUNK: usize = 256 * 1024;
|
||||
|
||||
/// How long a finished job's final progress lingers for the GUI to observe before
|
||||
/// it is pruned from the job table.
|
||||
const JOB_RETENTION: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote path helpers (pure) — also used by the GUI panel (`ui::sftp`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Join a remote directory path with a child name, POSIX-style (`/` separator,
|
||||
/// never a backslash — the remote is always POSIX regardless of the daemon's OS).
|
||||
pub fn remote_join(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() || dir == "/" {
|
||||
format!("/{}", name.trim_start_matches('/'))
|
||||
@@ -77,8 +32,6 @@ pub fn remote_join(dir: &str, name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent directory of a remote path. Root's parent is root. Trailing slashes
|
||||
/// are ignored (so `/a/b/` → `/a`).
|
||||
pub fn remote_parent(path: &str) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
@@ -90,7 +43,6 @@ pub fn remote_parent(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The final component (basename) of a remote path (`/a/b` → `b`, `/` → `/`).
|
||||
pub fn remote_basename(path: &str) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
@@ -102,11 +54,7 @@ pub fn remote_basename(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The temp filename an upload writes to before renaming over its target:
|
||||
/// `<remote>.tty7-upload-<rand>`. Kept in the *same directory* as the target so
|
||||
/// the finishing rename is same-filesystem (atomic on the server).
|
||||
pub fn upload_temp_name(remote: &str) -> String {
|
||||
// A cheap, dependency-free random suffix from the system clock + a counter.
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now()
|
||||
@@ -116,22 +64,10 @@ pub fn upload_temp_name(remote: &str) -> String {
|
||||
format!("{remote}.tty7-upload-{:x}{:x}", nanos, n)
|
||||
}
|
||||
|
||||
/// Whether a server-supplied directory-entry `name` is safe to use as a *single*
|
||||
/// local path component when building a download destination.
|
||||
///
|
||||
/// A recursive download turns remote entry names into local path components
|
||||
/// (`lpath.join(name)`). A malicious or compromised server can return names like
|
||||
/// `..`, `../../etc/foo`, or an absolute `/etc/foo`; `Path::join` with an absolute
|
||||
/// component discards the base, and `..` escapes upward — arbitrary local file
|
||||
/// write (CVE-2019-6111-class). Accept only a name that is exactly one *normal*
|
||||
/// path component: reject empty, `.`, `..`, anything containing a `/` or `\\`
|
||||
/// separator, and anything that doesn't resolve to a single `Component::Normal`.
|
||||
pub fn safe_local_name(name: &str) -> bool {
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
return false;
|
||||
}
|
||||
// Reject either separator on every platform: a POSIX server name must never
|
||||
// introduce a Windows path separator either.
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return false;
|
||||
}
|
||||
@@ -142,9 +78,6 @@ pub fn safe_local_name(name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The temp path a download writes to before renaming over its target:
|
||||
/// `<local>.tty7-download-<rand>`, a sibling in the *same directory* so the
|
||||
/// finishing rename is same-filesystem (atomic). Mirrors [`upload_temp_name`].
|
||||
fn download_temp_path(lpath: &Path) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -152,20 +85,11 @@ fn download_temp_path(lpath: &Path) -> PathBuf {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
// Append to the full path (a sibling with a suffix) so the temp stays in the
|
||||
// destination directory regardless of the file name's own extension.
|
||||
let mut os = lpath.as_os_str().to_os_string();
|
||||
os.push(format!(".tty7-download-{:x}{:x}", nanos, n));
|
||||
PathBuf::from(os)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry classification (pure).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Classify a remote entry from its attributes. Symlink is checked first because
|
||||
/// the SFTP type bits let a symlink also satisfy `is_regular` (S_IFLNK contains
|
||||
/// the S_IFREG bit), so order matters.
|
||||
fn classify(attrs: &FileAttributes) -> SftpEntryKind {
|
||||
if attrs.is_symlink() {
|
||||
SftpEntryKind::Symlink
|
||||
@@ -187,13 +111,6 @@ fn entry_from_attrs(name: &str, attrs: &FileAttributes) -> SftpEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfer job state machine (pure) — tested without any SFTP/window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The mutable progress of one transfer job. Terminal states (`Done`/`Error`/
|
||||
/// `Cancelled`) latch: once reached, further transitions are ignored, so a late
|
||||
/// `add_bytes` after cancellation can't resurrect a job or corrupt its status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JobProgress {
|
||||
pub state: SftpJobState,
|
||||
@@ -262,8 +179,6 @@ impl Default for JobProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// A live/finished transfer job. Progress lives behind a `Mutex` so the transfer
|
||||
/// task updates it while the GUI polls it.
|
||||
struct Job {
|
||||
id: u64,
|
||||
pane_id: u64,
|
||||
@@ -323,7 +238,6 @@ impl Job {
|
||||
}
|
||||
}
|
||||
|
||||
/// True once terminal and past the retention window (safe to prune).
|
||||
fn is_expired(&self) -> bool {
|
||||
matches!(
|
||||
*self.done_at.lock().unwrap(),
|
||||
@@ -332,13 +246,6 @@ impl Job {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The manager.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A per-connection SFTP-session cache slot. The inner `tokio::Mutex` serializes
|
||||
/// opening (so two panes racing to first-use a connection open one session, not
|
||||
/// two) without serializing *different* connections.
|
||||
struct SessionSlot {
|
||||
inner: tokio::sync::Mutex<Option<CachedSession>>,
|
||||
}
|
||||
@@ -355,7 +262,6 @@ pub struct SftpManager {
|
||||
}
|
||||
|
||||
impl SftpManager {
|
||||
/// The process-wide SFTP engine.
|
||||
pub fn global() -> &'static SftpManager {
|
||||
static MANAGER: OnceLock<SftpManager> = OnceLock::new();
|
||||
MANAGER.get_or_init(|| SftpManager {
|
||||
@@ -365,9 +271,6 @@ impl SftpManager {
|
||||
})
|
||||
}
|
||||
|
||||
// --- sync entry points (called from the server's std threads) ----------
|
||||
|
||||
/// List a remote directory. Blocks the calling thread on the SSH runtime.
|
||||
pub fn list(&self, conn: &Arc<SshConnection>, path: &str) -> Result<Vec<SftpEntry>, String> {
|
||||
SshManager::global().handle().block_on(async {
|
||||
self.with_session(conn, |sftp| async move { list_dir(&sftp, path).await })
|
||||
@@ -375,27 +278,6 @@ impl SftpManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, creating or truncating it. Blocks the calling
|
||||
/// thread on the SSH runtime.
|
||||
///
|
||||
/// For callers that have the bytes in memory and no local file to stream
|
||||
/// from — the remote-server installer, which downloads a binary and pushes
|
||||
/// it — so they get the cached session and its retry-once-on-transport-
|
||||
/// failure behaviour instead of opening a channel of their own per write.
|
||||
///
|
||||
/// Chunked rather than one giant write so a ~6 MB binary is not a single
|
||||
/// SFTP message, and flushed *and* shut down before returning `Ok`: a
|
||||
/// server that runs out of disk reports it on the write or the close, and
|
||||
/// swallowing that would leave a truncated file for the caller to chmod and
|
||||
/// rename into place as though it were whole.
|
||||
/// `on_progress` is called with the running total after each chunk lands.
|
||||
/// It runs on the SSH runtime between writes, so it must not block — the
|
||||
/// installer's sink just stores the number.
|
||||
///
|
||||
/// Counted after `write_all` rather than before, so the figure is bytes the
|
||||
/// transport has accepted rather than bytes we intend to send. It still
|
||||
/// reaches `len` before `flush`/`shutdown` have confirmed anything, which is
|
||||
/// why a full bar is not the installer's success signal — the `Ok` is.
|
||||
pub fn put_bytes(
|
||||
&self,
|
||||
conn: &Arc<SshConnection>,
|
||||
@@ -424,7 +306,6 @@ impl SftpManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a one-shot filesystem operation.
|
||||
pub fn op(&self, conn: &Arc<SshConnection>, op: &SftpOp) -> SftpOpResult {
|
||||
let result = SshManager::global().handle().block_on(async {
|
||||
self.with_session(conn, |sftp| async move { run_op(&sftp, op).await })
|
||||
@@ -436,15 +317,11 @@ impl SftpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background transfer. Returns the new job id immediately; the
|
||||
/// transfer runs on the SSH runtime and reports progress via `list_jobs`.
|
||||
pub fn start_transfer(
|
||||
&'static self,
|
||||
conn: &Arc<SshConnection>,
|
||||
spec: SftpTransferSpec,
|
||||
) -> Result<u64, String> {
|
||||
// Establish the session up-front so an immediate failure (no SFTP) is
|
||||
// reported synchronously rather than as a phantom job.
|
||||
let sftp = SshManager::global()
|
||||
.handle()
|
||||
.block_on(async { self.session_for(conn).await })?;
|
||||
@@ -468,8 +345,6 @@ impl SftpManager {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Cancel a running job (idempotent). Returns the current progress list for
|
||||
/// the job's pane so the caller can refresh the tray in one round-trip.
|
||||
pub fn cancel(&self, job_id: u64) -> Vec<SftpJobProgress> {
|
||||
let pane = {
|
||||
let jobs = self.jobs.lock().unwrap();
|
||||
@@ -486,8 +361,6 @@ impl SftpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the transfer jobs for a pane, pruning expired (long-finished)
|
||||
/// ones as a side effect so the table stays bounded.
|
||||
pub fn list_jobs(&self, pane_id: u64) -> Vec<SftpJobProgress> {
|
||||
let mut jobs = self.jobs.lock().unwrap();
|
||||
jobs.retain(|_, job| !job.is_expired());
|
||||
@@ -500,15 +373,6 @@ impl SftpManager {
|
||||
out
|
||||
}
|
||||
|
||||
// --- session cache -----------------------------------------------------
|
||||
|
||||
/// Run `f` against the pane's cached SFTP session, retrying once with a
|
||||
/// freshly re-opened session **only** when the first attempt failed for a
|
||||
/// transport/channel reason (the cached subsystem channel died while the
|
||||
/// connection lives). A logical SFTP failure — a server status like permission
|
||||
/// denied or no-such-file — returns directly, never re-opening the session (a
|
||||
/// retry would just fail identically and waste a round-trip). See
|
||||
/// [`is_transport_failure`].
|
||||
async fn with_session<T, F, Fut>(&self, conn: &Arc<SshConnection>, f: F) -> Result<T, String>
|
||||
where
|
||||
F: Fn(Arc<SftpSession>) -> Fut,
|
||||
@@ -526,7 +390,6 @@ impl SftpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached session for `conn`, opening one if absent or stale.
|
||||
async fn session_for(&self, conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String> {
|
||||
let slot = {
|
||||
let mut map = self.sessions.lock().unwrap();
|
||||
@@ -558,27 +421,18 @@ impl SftpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stringified SFTP op error looks like a *transport/channel* failure
|
||||
/// (the subsystem channel died) rather than a logical server status (permission
|
||||
/// denied, no such file, …). Only the former is worth re-opening the session for.
|
||||
///
|
||||
/// `russh_sftp` renders channel/IO failures with these markers; a server status
|
||||
/// code renders as `<code>: <message>` and matches none of them — so an unmatched
|
||||
/// (logical) error is not retried. Conservative by design: an unrecognized error
|
||||
/// is treated as logical and returned directly.
|
||||
fn is_transport_failure(msg: &str) -> bool {
|
||||
const MARKERS: &[&str] = &[
|
||||
"I/O:", // russh_sftp `Error::IO` — the channel stream failed
|
||||
"Unexpected EOF", // the stream closed mid-message
|
||||
"Timeout", // no response — the subsystem/channel is wedged
|
||||
"I/O:",
|
||||
"Unexpected EOF",
|
||||
"Timeout",
|
||||
"Unexpected packet",
|
||||
"SendError", // the channel task's receiver is gone
|
||||
"RecvError", // the channel task ended before replying
|
||||
"SendError",
|
||||
"RecvError",
|
||||
];
|
||||
MARKERS.iter().any(|m| msg.contains(m))
|
||||
}
|
||||
|
||||
/// Open a fresh SFTP subsystem channel on `conn` and hand back a session.
|
||||
async fn open_sftp(conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String> {
|
||||
let channel = conn
|
||||
.open_session_channel()
|
||||
@@ -594,10 +448,6 @@ async fn open_sftp(conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String
|
||||
Ok(Arc::new(sftp))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn list_dir(sftp: &SftpSession, path: &str) -> Result<Vec<SftpEntry>, String> {
|
||||
let read_dir = sftp.read_dir(path).await.map_err(|e| format!("{e}"))?;
|
||||
let mut out = Vec::new();
|
||||
@@ -609,7 +459,6 @@ async fn list_dir(sftp: &SftpSession, path: &str) -> Result<Vec<SftpEntry>, Stri
|
||||
let attrs = entry.metadata();
|
||||
let mut e = entry_from_attrs(&name, &attrs);
|
||||
if e.kind == SftpEntryKind::Symlink {
|
||||
// Follow-stat the target so the GUI knows navigate-vs-download.
|
||||
if let Ok(target) = sftp.metadata(remote_join(path, &name)).await {
|
||||
e.target_is_dir = target.is_dir();
|
||||
}
|
||||
@@ -635,9 +484,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String>
|
||||
SftpOpResult::Done
|
||||
}
|
||||
SftpOp::CreateFile { path } => {
|
||||
// EXCLUDE => fail rather than clobber an existing file. The OPEN
|
||||
// itself creates the (empty) file server-side; flush/shutdown closes
|
||||
// the handle cleanly.
|
||||
let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::EXCLUDE;
|
||||
let mut file = sftp
|
||||
.open_with_flags(path.clone(), flags)
|
||||
@@ -658,9 +504,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String>
|
||||
SftpOpResult::Done
|
||||
}
|
||||
SftpOp::Rename { from, to } => {
|
||||
// Plain rename, no overwrite: a user rename onto an existing name
|
||||
// must fail, not silently delete the target (`rename_over` is for
|
||||
// the upload temp-swap only, where we own both paths).
|
||||
sftp.rename(from.clone(), to.clone())
|
||||
.await
|
||||
.map_err(|e| format!("rename failed: {e}"))?;
|
||||
@@ -691,9 +534,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String>
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename `from` over `to`, tolerating a server that refuses to overwrite an
|
||||
/// existing target: remove the target first, then retry. (See the module note on
|
||||
/// posix-rename.)
|
||||
async fn rename_over(sftp: &SftpSession, from: &str, to: &str) -> Result<(), String> {
|
||||
if sftp.rename(from.to_string(), to.to_string()).await.is_ok() {
|
||||
return Ok(());
|
||||
@@ -704,14 +544,7 @@ async fn rename_over(sftp: &SftpSession, from: &str, to: &str) -> Result<(), Str
|
||||
.map_err(|e| format!("rename failed: {e}"))
|
||||
}
|
||||
|
||||
/// Daemon-side recursive directory delete: remove children (files and links
|
||||
/// directly; subdirectories by recursion) then the directory itself. A
|
||||
/// symlink child is unlinked, never followed.
|
||||
async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), String> {
|
||||
// Explicit worklist to avoid async recursion. Each dir is visited twice:
|
||||
// first to enqueue its children, then (after them) to remove the now-empty
|
||||
// directory. We push a directory's own removal marker before its children so
|
||||
// that, popping LIFO, children are removed first.
|
||||
enum Step {
|
||||
Enter(String),
|
||||
RemoveDir(String),
|
||||
@@ -732,12 +565,9 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), Stri
|
||||
}
|
||||
let child = remote_join(&dir, &name);
|
||||
let attrs = entry.metadata();
|
||||
// Only a real directory recurses; a symlink (even to a dir) is
|
||||
// unlinked as a file so we never delete through it.
|
||||
if attrs.is_dir() && !attrs.is_symlink() {
|
||||
stack.push(Step::Enter(child));
|
||||
} else {
|
||||
// Best-effort: a child already gone is fine.
|
||||
let _ = sftp.remove_file(child).await;
|
||||
}
|
||||
}
|
||||
@@ -750,10 +580,6 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_transfer(sftp: Arc<SftpSession>, spec: SftpTransferSpec, job: Arc<Job>) {
|
||||
let result = match spec.kind {
|
||||
SftpTransferKind::Download => download(&sftp, &spec, &job).await,
|
||||
@@ -766,20 +592,14 @@ async fn run_transfer(sftp: Arc<SftpSession>, spec: SftpTransferSpec, job: Arc<J
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled job surfaces as an `Err` that `run_transfer` maps to `Cancelled`.
|
||||
fn cancelled() -> String {
|
||||
"cancelled".to_string()
|
||||
}
|
||||
|
||||
async fn download(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Result<(), String> {
|
||||
// Size pre-pass (recursive) so the tray has a denominator.
|
||||
let total = remote_size(sftp, &spec.remote, spec.recursive, job).await?;
|
||||
job.set_total(total);
|
||||
|
||||
// The root is stat'ed (following a symlink deliberately — the user picked
|
||||
// it); children carry their lstat-style attrs from the directory listing so
|
||||
// symlinks are recognized and skipped, never followed: following them would
|
||||
// loop forever on a cyclic link and copy whole trees through e.g. `-> /`.
|
||||
let root_attrs = sftp
|
||||
.metadata(spec.remote.clone())
|
||||
.await
|
||||
@@ -805,9 +625,6 @@ async fn download(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Res
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
// Guard against a hostile server returning a traversing name
|
||||
// (`..`, `a/b`, `/abs`): it would become a local path component
|
||||
// via `lpath.join`, escaping the destination. Skip unsafe names.
|
||||
if !safe_local_name(&name) {
|
||||
log::warn!(
|
||||
"sftp download: skipping remote entry with unsafe name {name:?} under {rpath}"
|
||||
@@ -839,9 +656,6 @@ async fn download_file(
|
||||
if let Some(parent) = lpath.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
// Download to a per-file temp in the destination dir, then rename over the
|
||||
// target on success — mirroring the upload temp+rename discipline so a failed
|
||||
// or cancelled download never truncates a pre-existing local file in place.
|
||||
let temp = download_temp_path(lpath);
|
||||
let result: Result<(), String> = async {
|
||||
let mut remote = sftp
|
||||
@@ -866,8 +680,6 @@ async fn download_file(
|
||||
.map_err(|e| format!("write local: {e}"))?;
|
||||
job.add_bytes(n as u64);
|
||||
}
|
||||
// A failed flush means the temp is incomplete (e.g. disk full) — it must
|
||||
// abort here, before the rename commits the temp over a good target.
|
||||
local
|
||||
.flush()
|
||||
.await
|
||||
@@ -877,16 +689,13 @@ async fn download_file(
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
// Best effort: drop the partial temp, leaving any pre-existing target intact.
|
||||
let _ = tokio::fs::remove_file(&temp).await;
|
||||
return Err(e);
|
||||
}
|
||||
// Swap the completed temp over the target.
|
||||
if let Err(e) = tokio::fs::rename(&temp, lpath).await {
|
||||
let _ = tokio::fs::remove_file(&temp).await;
|
||||
return Err(format!("rename into {}: {e}", lpath.display()));
|
||||
}
|
||||
// Preserve the executable/permission bits where sane (unix only, low 12 bits).
|
||||
preserve_mode(lpath, mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -895,9 +704,6 @@ async fn upload(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Resul
|
||||
let total = local_size(&spec.local, spec.recursive, job).await?;
|
||||
job.set_total(total);
|
||||
|
||||
// Mirrors the download walker's symlink policy: the root is stat'ed
|
||||
// (following a symlink deliberately), children are classified by their
|
||||
// lstat-style file type and symlinks are skipped, never followed.
|
||||
let root_is_dir = tokio::fs::metadata(&spec.local)
|
||||
.await
|
||||
.map_err(|e| format!("stat {}: {e}", spec.local.display()))?
|
||||
@@ -911,7 +717,6 @@ async fn upload(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Resul
|
||||
if !spec.recursive {
|
||||
return Err("local path is a directory (enable recursive)".to_string());
|
||||
}
|
||||
// Create the remote dir (ignore "already exists").
|
||||
let _ = sftp.create_dir(rpath.clone()).await;
|
||||
let mut read_dir = tokio::fs::read_dir(&lpath)
|
||||
.await
|
||||
@@ -971,8 +776,6 @@ async fn upload_file(
|
||||
.map_err(|e| format!("write remote: {e}"))?;
|
||||
job.add_bytes(n as u64);
|
||||
}
|
||||
// Surface late write errors before the rename commits the temp over the
|
||||
// target; a truncated temp must fail the transfer, not replace the file.
|
||||
remote
|
||||
.flush()
|
||||
.await
|
||||
@@ -986,11 +789,9 @@ async fn upload_file(
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
// Clean up the partial temp file, best effort.
|
||||
let _ = sftp.remove_file(temp.clone()).await;
|
||||
return Err(e);
|
||||
}
|
||||
// Swap the temp over the target.
|
||||
if let Err(e) = rename_over(sftp, &temp, rpath).await {
|
||||
let _ = sftp.remove_file(temp).await;
|
||||
return Err(e);
|
||||
@@ -998,7 +799,6 @@ async fn upload_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively sum remote file sizes (files only). Cancellation short-circuits.
|
||||
async fn remote_size(
|
||||
sftp: &SftpSession,
|
||||
root: &str,
|
||||
@@ -1025,8 +825,6 @@ async fn remote_size(
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
// Skip the same unsafe names and symlinks the download walker
|
||||
// skips so the size denominator matches what is transferred.
|
||||
if !safe_local_name(&name) {
|
||||
continue;
|
||||
}
|
||||
@@ -1044,7 +842,6 @@ async fn remote_size(
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Recursively sum local file sizes (files only).
|
||||
async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, String> {
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
@@ -1052,9 +849,6 @@ async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, Stri
|
||||
if job.is_cancelled() {
|
||||
return Err(cancelled());
|
||||
}
|
||||
// Root uses stat (a root symlink is followed deliberately); children
|
||||
// below use their lstat file type, so links are counted at zero and
|
||||
// never followed — matching the upload walker.
|
||||
let meta = match tokio::fs::metadata(&path).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
@@ -1079,7 +873,6 @@ async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, Stri
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Apply the sane low permission bits of a downloaded file locally (unix only).
|
||||
#[cfg(unix)]
|
||||
fn preserve_mode(path: &Path, mode: Option<u32>) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -1103,9 +896,7 @@ mod tests {
|
||||
assert_eq!(remote_join("/", "file"), "/file");
|
||||
assert_eq!(remote_join("", "file"), "/file");
|
||||
assert_eq!(remote_join("/home/deploy", "src"), "/home/deploy/src");
|
||||
// Trailing/leading slashes are normalized to a single separator.
|
||||
assert_eq!(remote_join("/home/deploy/", "/src"), "/home/deploy/src");
|
||||
// Unicode names survive intact.
|
||||
assert_eq!(remote_join("/家", "文件"), "/家/文件");
|
||||
}
|
||||
|
||||
@@ -1115,7 +906,6 @@ mod tests {
|
||||
assert_eq!(remote_parent("/home"), "/");
|
||||
assert_eq!(remote_parent("/"), "/");
|
||||
assert_eq!(remote_parent(""), "/");
|
||||
// Trailing slash ignored.
|
||||
assert_eq!(remote_parent("/a/b/"), "/a");
|
||||
assert_eq!(remote_parent("/项目/子"), "/项目");
|
||||
}
|
||||
@@ -1134,13 +924,11 @@ mod tests {
|
||||
let b = upload_temp_name("/dir/file.txt");
|
||||
assert!(a.starts_with("/dir/file.txt.tty7-upload-"));
|
||||
assert!(b.starts_with("/dir/file.txt.tty7-upload-"));
|
||||
// Two temp names for the same target must differ (counter component).
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_local_name_rejects_traversal_and_accepts_plain_names() {
|
||||
// Rejected: empty, dot, dotdot, embedded/leading separators, absolute.
|
||||
assert!(!safe_local_name(""));
|
||||
assert!(!safe_local_name("."));
|
||||
assert!(!safe_local_name(".."));
|
||||
@@ -1148,7 +936,6 @@ mod tests {
|
||||
assert!(!safe_local_name("/abs"));
|
||||
assert!(!safe_local_name("../../.ssh/authorized_keys"));
|
||||
assert!(!safe_local_name("a\\b"));
|
||||
// Accepted: ordinary single components, including Unicode and dotted names.
|
||||
assert!(safe_local_name("file.txt"));
|
||||
assert!(safe_local_name("项目"));
|
||||
assert!(safe_local_name("a.tar.gz"));
|
||||
@@ -1160,7 +947,6 @@ mod tests {
|
||||
let target = Path::new("/dest/dir/file.bin");
|
||||
let a = download_temp_path(target);
|
||||
let b = download_temp_path(target);
|
||||
// Same directory as the target (so the finishing rename is same-filesystem).
|
||||
assert_eq!(a.parent(), target.parent());
|
||||
assert!(
|
||||
a.file_name()
|
||||
@@ -1168,19 +954,16 @@ mod tests {
|
||||
.to_string_lossy()
|
||||
.starts_with("file.bin.tty7-download-")
|
||||
);
|
||||
// Two temps for the same target differ (counter component).
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_transport_failure_distinguishes_channel_from_logical_errors() {
|
||||
// Transport/channel failures → retry.
|
||||
assert!(is_transport_failure("I/O: broken pipe"));
|
||||
assert!(is_transport_failure("rename failed: I/O: connection reset"));
|
||||
assert!(is_transport_failure("Unexpected EOF on stream"));
|
||||
assert!(is_transport_failure("Timeout"));
|
||||
assert!(is_transport_failure("SendError: channel closed"));
|
||||
// Logical server statuses → no retry.
|
||||
assert!(!is_transport_failure("3: Permission denied"));
|
||||
assert!(!is_transport_failure("2: No such file or directory"));
|
||||
assert!(!is_transport_failure(
|
||||
@@ -1190,7 +973,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classify_prefers_symlink_over_regular_bit() {
|
||||
// S_IFLNK carries the S_IFREG bit too; symlink must win.
|
||||
let mut link = FileAttributes::empty();
|
||||
link.permissions = Some(0o120777);
|
||||
assert_eq!(classify(&link), SftpEntryKind::Symlink);
|
||||
@@ -1203,7 +985,6 @@ mod tests {
|
||||
file.permissions = Some(0o100644);
|
||||
assert_eq!(classify(&file), SftpEntryKind::File);
|
||||
|
||||
// Unknown permissions default to file.
|
||||
assert_eq!(classify(&FileAttributes::empty()), SftpEntryKind::File);
|
||||
}
|
||||
|
||||
@@ -1238,7 +1019,6 @@ mod tests {
|
||||
p.finish();
|
||||
assert_eq!(p.state, SftpJobState::Done);
|
||||
|
||||
// Terminal state latches: later transitions are ignored.
|
||||
p.add_bytes(999);
|
||||
p.fail("late error");
|
||||
p.cancel();
|
||||
|
||||
@@ -1,56 +1,16 @@
|
||||
//! Workspace-scoped control requests (M7).
|
||||
//!
|
||||
//! A *remote workspace* has no pane on this daemon: its
|
||||
//! panes live on the remote `tty7-server` and reach it through a routed byte
|
||||
//! pipe. What this side owns is the [`SshConnection`] that pipe rides — the same
|
||||
//! connection an SSH pane to that host would have used, deduplicated by
|
||||
//! [`ConnectionKey`].
|
||||
//!
|
||||
//! Everything a user wants from that connection — a port forward behind a
|
||||
//! ⌘-clicked `localhost:3000`, an SFTP download dragged out to Finder — is
|
||||
//! therefore addressable, just not by `pane_id`. This module is the one place
|
||||
//! that translates "which workspace" into "which connection", and it is
|
||||
//! deliberately the *only* new entry point: [`handle`] answers a whole
|
||||
//! [`WorkspaceRequest`] with a ready-to-send [`DaemonMsg`], so the daemon's
|
||||
//! dispatch grows one arm rather than nine.
|
||||
//!
|
||||
//! **It never connects.** [`SshManager::existing_connection`] is a lookup: a
|
||||
//! workspace request arrives on a short-lived control connection with nowhere to
|
||||
//! put an auth prompt, so "no connection" is reported as an error the GUI can
|
||||
//! show rather than a silent connect attempt that would hang on a passphrase.
|
||||
|
||||
use crate::core::session::WorkspaceId;
|
||||
use crate::daemon::protocol::{DaemonMsg, WorkspaceOp, WorkspaceRequest};
|
||||
|
||||
use super::SshManager;
|
||||
use super::sftp::SftpManager;
|
||||
|
||||
/// The bucket a workspace's SFTP transfer jobs are filed under.
|
||||
///
|
||||
/// [`SftpManager`] keys jobs by `pane_id`, and a remote workspace has no pane
|
||||
/// here to lend it one. Deriving the key from the workspace instead of using the
|
||||
/// requesting pane keeps a running download visible after the user switches the
|
||||
/// Files panel to another pane — the transfer belongs to the machine, not to
|
||||
/// whichever tab happened to start it.
|
||||
///
|
||||
/// The top bit is set so the synthetic key cannot collide with a real pane id:
|
||||
/// pane ids come from a counter that starts at 1, so a collision would need 2^63
|
||||
/// panes in one daemon.
|
||||
pub fn job_key(workspace: WorkspaceId) -> u64 {
|
||||
workspace.element_key() | (1 << 63)
|
||||
}
|
||||
|
||||
/// Answer one [`WorkspaceRequest`].
|
||||
///
|
||||
/// Every failure — an unknown/disconnected workspace, a refused bind, an SFTP
|
||||
/// error — comes back as [`DaemonMsg::Error`] with a sentence the GUI can show
|
||||
/// verbatim, because the caller has no other channel to explain itself on.
|
||||
pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
|
||||
let mgr = SshManager::global();
|
||||
let Some(conn) = mgr.existing_connection(&req.spec) else {
|
||||
// The workspace is not connected (or is mid-reconnect). Naming the host
|
||||
// matters: with several windows open the user needs to know *which* one
|
||||
// went away.
|
||||
return DaemonMsg::Error(format!(
|
||||
"workspace is not connected to {}@{}:{} — reconnect the window and try again",
|
||||
req.spec.user, req.spec.host, req.spec.port
|
||||
@@ -84,9 +44,6 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
|
||||
},
|
||||
WorkspaceOp::SftpOp { op } => DaemonMsg::SftpOpResult(SftpManager::global().op(&conn, op)),
|
||||
WorkspaceOp::SftpTransferStart { spec } => {
|
||||
// The caller's `pane_id` is overridden rather than trusted: a
|
||||
// workspace's jobs must land in the workspace's bucket, or
|
||||
// `SftpTransferList` below would not find them again.
|
||||
let mut spec = spec.clone();
|
||||
spec.pane_id = job_key(ws);
|
||||
match SftpManager::global().start_transfer(&conn, spec) {
|
||||
@@ -104,27 +61,18 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The synthetic job bucket is stable for a workspace, distinct between
|
||||
/// workspaces, and out of reach of any real pane id.
|
||||
#[test]
|
||||
fn job_key_is_stable_distinct_and_out_of_pane_range() {
|
||||
let a = WorkspaceId::new();
|
||||
let b = WorkspaceId::new();
|
||||
assert_eq!(job_key(a), job_key(a), "stable across calls");
|
||||
assert_ne!(job_key(a), job_key(b));
|
||||
// Real pane ids come from a counter starting at 1; none of them has the
|
||||
// top bit set, so the two spaces cannot overlap.
|
||||
assert!(job_key(a) >= 1 << 63);
|
||||
assert!(job_key(b) >= 1 << 63);
|
||||
}
|
||||
|
||||
/// A request naming a host this daemon has no connection to is refused with a
|
||||
/// message that names the host — never by silently connecting (which would
|
||||
/// need credentials this path cannot prompt for).
|
||||
#[test]
|
||||
fn request_without_a_live_connection_is_refused_by_name() {
|
||||
// Built through serde so the test states only the three fields it cares
|
||||
// about; every other field of `NativeSshSpec` has a serde default.
|
||||
let spec: crate::daemon::protocol::NativeSshSpec = serde_json::from_str(
|
||||
r#"{"host":"nowhere.invalid","port":2222,"user":"someone","auth_mode":"auto"}"#,
|
||||
)
|
||||
|
||||
@@ -1,37 +1,3 @@
|
||||
//! Cross-platform IPC transport for the GUI ⇄ daemon connection.
|
||||
//!
|
||||
//! The daemon and the GUI talk over a local, machine-private byte stream. Which
|
||||
//! kind of stream depends on the platform, but both sides only ever see a type
|
||||
//! that is `Read + Write + try_clone` — so `server`, `spawn`, and
|
||||
//! `terminal::remote` share one code path and never mention the concrete type.
|
||||
//!
|
||||
//! - **Unix**: a Unix-domain socket at `<config>/daemon.sock`. This is the
|
||||
//! original design, kept verbatim — the socket file's presence on disk doubles
|
||||
//! as the "is a daemon here?" marker, and `bind` recreates it.
|
||||
//! - **Windows**: a loopback `TcpListener` on `127.0.0.1:<port>` (an OS-assigned
|
||||
//! ephemeral port). Windows has no first-class Unix sockets, and the
|
||||
//! `interprocess` named-pipe route can't cleanly `try_clone` a blocking duplex
|
||||
//! handle, which our thread-per-connection model needs. Loopback TCP has the
|
||||
//! exact `try_clone` + blocking semantics of `UnixStream`, so the rest of the
|
||||
//! daemon is unchanged. The chosen port is written to `<config>/daemon.port`
|
||||
//! so the GUI can find a daemon it didn't spawn; that file is the Windows
|
||||
//! analogue of the socket file (its presence is the "endpoint exists" marker).
|
||||
//! Loopback is reachable by *any* local process, not just the same user — so,
|
||||
//! unlike a Unix socket, the port alone isn't an access boundary. The daemon
|
||||
//! closes that gap with a token: `bind` writes a random 256-bit token into the
|
||||
//! (user-private) port file, `connect` presents it as a preamble, and
|
||||
//! `authenticate` rejects any connection that doesn't match — so only a process
|
||||
//! that could read the user-private file gets in. See [`imp_windows`].
|
||||
//!
|
||||
//! One daemon serves two dialects on two listeners — panes and control — which
|
||||
//! on Unix are two socket files and here are two port files, each with its own
|
||||
//! ephemeral port and its own token (`bind_endpoint`). The control listener's is
|
||||
//! `control.port`; [`crate::host::server`] owns it, since that is where the
|
||||
//! dialect lives.
|
||||
//!
|
||||
//! All endpoint state lives under the (config-dir-aware) config directory, so
|
||||
//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::core::config;
|
||||
@@ -47,20 +13,11 @@ mod imp_unix {
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The connection stream both sides read/write framed messages over.
|
||||
pub type Stream = UnixStream;
|
||||
/// The daemon's accept side.
|
||||
pub type Listener = UnixListener;
|
||||
|
||||
/// `sockaddr_un.sun_path` caps socket paths at 104 bytes on macOS (108 on
|
||||
/// Linux), NUL included — `bind`/`connect` reject anything longer, so stay
|
||||
/// safely below the smaller limit.
|
||||
pub(super) const MAX_SOCKET_PATH_BYTES: usize = 100;
|
||||
|
||||
/// Deterministic 64-bit FNV-1a. Not `DefaultHasher`: the GUI and the daemon
|
||||
/// can be different builds of tty7 (daemon survives app upgrades), so the
|
||||
/// fallback socket path must hash identically across compiler/std versions
|
||||
/// or an upgraded GUI would lose a live daemon.
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in bytes {
|
||||
@@ -70,13 +27,6 @@ mod imp_unix {
|
||||
h
|
||||
}
|
||||
|
||||
/// The socket path serving `config_dir`: `<config_dir>/daemon.sock` whenever
|
||||
/// that fits in `sun_path`, else a short per-user path keyed by a stable
|
||||
/// hash of the config dir. Without the fallback, a long `--config-dir` made
|
||||
/// bind/connect fail with "path must be shorter than SUN_LEN" and the GUI
|
||||
/// died at startup. Distinct config dirs still get distinct daemons (the
|
||||
/// hash keys the endpoint), and both processes derive the same path because
|
||||
/// the GUI forwards its *resolved* config dir to the daemon it spawns.
|
||||
pub(super) fn socket_path_for(config_dir: &Path) -> PathBuf {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let inline = config_dir.join("daemon.sock");
|
||||
@@ -91,22 +41,6 @@ mod imp_unix {
|
||||
pick_fallback_socket(xdg.as_deref(), &std::env::temp_dir(), &name)
|
||||
}
|
||||
|
||||
/// The fallback path, given the two candidate bases. Split out from
|
||||
/// [`socket_path_for`] so it is testable without mutating the environment
|
||||
/// (which is `unsafe` in edition 2024 and races every other test).
|
||||
///
|
||||
/// Preference order is unchanged — `$XDG_RUNTIME_DIR` (user-private, 0700,
|
||||
/// the norm on Linux) before the OS temp dir (per-user on macOS) — so every
|
||||
/// path that works today is returned byte-for-byte as before and a live
|
||||
/// daemon is never orphaned. What is new is the length check: the "short"
|
||||
/// hashed name is only short *relative to the config dir*, and a deep
|
||||
/// `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Without this,
|
||||
/// `bind` failed with "path must be shorter than SUN_LEN" and the daemon
|
||||
/// died at startup with no hint that the runtime dir was the cause.
|
||||
///
|
||||
/// If neither base fits, return the preferred one anyway: `bind` then
|
||||
/// reports the real path it rejected, which is a far better diagnostic than
|
||||
/// silently landing somewhere the peer will not look.
|
||||
pub(super) fn pick_fallback_socket(xdg: Option<&Path>, temp: &Path, name: &str) -> PathBuf {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let fits = |p: &PathBuf| p.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES;
|
||||
@@ -121,14 +55,10 @@ mod imp_unix {
|
||||
preferred
|
||||
}
|
||||
|
||||
/// Path of the Unix-domain socket for this process's config dir. `None` only
|
||||
/// when the config dir can't be resolved (no `$HOME`).
|
||||
fn socket_path() -> Option<PathBuf> {
|
||||
Some(socket_path_for(&config::config_dir_path()?))
|
||||
}
|
||||
|
||||
/// Try to connect to the daemon. `Err` means "nobody home" (the caller treats
|
||||
/// any error as "not running").
|
||||
pub fn connect() -> io::Result<Stream> {
|
||||
let path = socket_path().ok_or_else(|| {
|
||||
io::Error::other("could not resolve daemon socket path (no config dir)")
|
||||
@@ -138,17 +68,10 @@ mod imp_unix {
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Grow the kernel socket buffers to match the daemon writer's 256 KiB
|
||||
/// coalesced Output frames. macOS defaults Unix-socket buffers to 8 KiB,
|
||||
/// which chops a full-drain stream (100+ MB/s) into ~8 KiB reads — tens of
|
||||
/// thousands of extra syscalls and cross-process wakeups per second, and a
|
||||
/// stall point the PTY reader's backpressure gate then amplifies. Best
|
||||
/// effort: a refused size just keeps the platform default.
|
||||
pub fn tune(stream: &Stream) {
|
||||
use std::os::unix::io::AsRawFd as _;
|
||||
let size: libc::c_int = 256 * 1024;
|
||||
for opt in [libc::SO_SNDBUF, libc::SO_RCVBUF] {
|
||||
// SAFETY: plain setsockopt on a valid owned fd with a c_int payload.
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
stream.as_raw_fd(),
|
||||
@@ -161,30 +84,21 @@ mod imp_unix {
|
||||
}
|
||||
}
|
||||
|
||||
/// Daemon-side connection authentication — a no-op on Unix. The socket lives in
|
||||
/// the user-private config dir (or `$XDG_RUNTIME_DIR`, 0700), so filesystem
|
||||
/// permissions already restrict `connect` to the same user; there's nothing to
|
||||
/// verify. Mirrors the Windows signature so `server` calls it unconditionally.
|
||||
#[inline]
|
||||
pub fn authenticate(_stream: &mut Stream) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the endpoint marker exists on disk (a live *or* stale socket file).
|
||||
pub fn endpoint_exists() -> bool {
|
||||
socket_path().is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
/// Remove a stale endpoint marker so a fresh `bind` can recreate it. Best
|
||||
/// effort: a missing file is fine.
|
||||
pub fn remove_stale_endpoint() {
|
||||
if let Some(path) = socket_path() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the listener (daemon side). Ensures the config dir exists first; the
|
||||
/// caller is responsible for having cleared any stale endpoint.
|
||||
pub fn bind() -> anyhow::Result<Listener> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let path = socket_path().ok_or_else(|| {
|
||||
@@ -192,12 +106,6 @@ mod imp_unix {
|
||||
})?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
// The socket now carries `NativeSshSpec` secrets, so it must be reachable
|
||||
// only by this user. Tighten the config dir to 0700 — but only when the
|
||||
// socket lives *in* the config dir (tty7 owns it). The overlong-path
|
||||
// fallback puts the socket directly under a shared base ($XDG_RUNTIME_DIR
|
||||
// or the OS temp dir), which we must never chmod; the 0600 socket file
|
||||
// below is the boundary there. Best effort: log and continue on failure.
|
||||
let owns_parent = config::config_dir_path().is_some_and(|c| c.as_path() == parent);
|
||||
if owns_parent {
|
||||
if let Err(e) =
|
||||
@@ -212,16 +120,12 @@ mod imp_unix {
|
||||
}
|
||||
let listener = UnixListener::bind(&path)
|
||||
.map_err(|e| anyhow::anyhow!("bind {} failed: {}", path.display(), e))?;
|
||||
// Restrict the socket file to the owner: on Unix, connecting requires write
|
||||
// permission on the socket node, so 0600 keeps a co-local user out — the
|
||||
// access boundary now that the socket conveys cleartext SSH secrets.
|
||||
if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
|
||||
log::warn!("could not chmod 0600 daemon socket {}: {e}", path.display());
|
||||
}
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// A human-readable description of the endpoint, for log messages.
|
||||
pub fn endpoint_display() -> String {
|
||||
socket_path()
|
||||
.map(|p| p.display().to_string())
|
||||
@@ -234,20 +138,15 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Pin the process config dir so the socket lives under a temp dir, never the
|
||||
/// real `~/.config`. First-call-wins; every IO test computes the same path.
|
||||
fn pin_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
config::set_config_dir(dir);
|
||||
}
|
||||
|
||||
/// One test drives the whole endpoint lifecycle so the shared `daemon.sock`
|
||||
/// file isn't raced by parallel tests: clean → bind → exists/connect → remove.
|
||||
#[test]
|
||||
fn endpoint_lifecycle_bind_connect_and_clear() {
|
||||
pin_config_dir();
|
||||
// Start from a clean slate (a prior run may have left a stale socket).
|
||||
remove_stale_endpoint();
|
||||
assert!(!endpoint_exists(), "no endpoint before bind");
|
||||
|
||||
@@ -258,26 +157,19 @@ mod tests {
|
||||
"display names the socket file"
|
||||
);
|
||||
|
||||
// A client can connect while the listener is alive.
|
||||
let _client = connect().expect("connect to the live listener");
|
||||
|
||||
drop(listener);
|
||||
// The socket file lingers after the listener drops; clearing it makes the
|
||||
// endpoint look absent again (the stale-takeover path in `run`).
|
||||
remove_stale_endpoint();
|
||||
assert!(!endpoint_exists(), "endpoint cleared after removal");
|
||||
}
|
||||
|
||||
/// A short config dir keeps the original `<config>/daemon.sock` layout —
|
||||
/// existing daemons must stay reachable across this change.
|
||||
#[test]
|
||||
fn socket_path_stays_in_config_dir_when_it_fits() {
|
||||
let dir = std::path::PathBuf::from("/tmp/tty7-short");
|
||||
assert_eq!(imp_unix::socket_path_for(&dir), dir.join("daemon.sock"));
|
||||
}
|
||||
|
||||
/// An overlong config dir (the SUN_LEN panic regression) falls back to a
|
||||
/// short path that is deterministic and still keyed to the config dir.
|
||||
#[test]
|
||||
fn socket_path_falls_back_when_config_dir_is_too_long() {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
@@ -302,12 +194,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end on the OS: the fallback path actually binds and accepts a
|
||||
/// connection (this is exactly what failed with SUN_LEN before).
|
||||
#[test]
|
||||
fn fallback_socket_binds_and_connects() {
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
// Pid-keyed so concurrent `cargo test` processes don't share a path.
|
||||
let long_dir =
|
||||
std::env::temp_dir().join(format!("{}-{}", "x".repeat(120), std::process::id()));
|
||||
let path = imp_unix::socket_path_for(&long_dir);
|
||||
@@ -324,10 +213,6 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A long `$XDG_RUNTIME_DIR` must not produce an over-long fallback. The
|
||||
/// hashed name is short relative to the *config dir*, not in absolute
|
||||
/// terms, so preferring the runtime dir unconditionally overran `sun_path`
|
||||
/// and killed the daemon at `bind` with no hint at the cause.
|
||||
#[test]
|
||||
fn a_long_runtime_dir_falls_through_to_the_temp_dir() {
|
||||
let name = "tty7-0123456789abcdef.sock";
|
||||
@@ -337,8 +222,6 @@ mod tests {
|
||||
let picked = imp_unix::pick_fallback_socket(Some(&long_xdg), &temp, name);
|
||||
assert_eq!(picked, temp.join(name), "falls through to the temp dir");
|
||||
|
||||
// The preference itself is untouched when the runtime dir does fit —
|
||||
// changing that would orphan every live daemon on a normal machine.
|
||||
let short_xdg = PathBuf::from("/run/user/1000");
|
||||
assert_eq!(
|
||||
imp_unix::pick_fallback_socket(Some(&short_xdg), &temp, name),
|
||||
@@ -352,8 +235,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Neither base fits: return the preferred one so `bind` names the path it
|
||||
/// actually rejected, rather than silently landing where no peer looks.
|
||||
#[test]
|
||||
fn an_unusable_pair_of_bases_still_reports_the_preferred_path() {
|
||||
let name = "tty7-0123456789abcdef.sock";
|
||||
@@ -374,41 +255,22 @@ mod imp_windows {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The connection stream both sides read/write framed messages over.
|
||||
pub type Stream = TcpStream;
|
||||
/// The daemon's accept side.
|
||||
pub type Listener = TcpListener;
|
||||
|
||||
/// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG:
|
||||
/// unguessable without reading the (user-private) port file, so possessing it
|
||||
/// proves the connecting process runs as the same user.
|
||||
pub const TOKEN_LEN: usize = 32;
|
||||
pub type Token = [u8; TOKEN_LEN];
|
||||
|
||||
/// The pane dialect's endpoint marker.
|
||||
///
|
||||
/// Named, because one daemon serves two dialects on two listeners — the
|
||||
/// same shape it has on Unix, where they are two socket files — and each
|
||||
/// records its own port and mints its own token. See
|
||||
/// [`bind_endpoint`].
|
||||
const PANE_PORT_FILE: &str = "daemon.port";
|
||||
|
||||
/// This daemon's auth token, minted once at [`bind`] and checked by
|
||||
/// [`authenticate`] on every accepted connection. A process global because the
|
||||
/// listener and the per-connection auth check live in the same daemon process
|
||||
/// but don't share a handle; the client learns the token from the port file
|
||||
/// instead. Set exactly once per daemon lifetime.
|
||||
static DAEMON_TOKEN: OnceLock<Token> = OnceLock::new();
|
||||
|
||||
/// Mint a fresh 256-bit token from the OS CSPRNG. Panics only if the OS RNG is
|
||||
/// unavailable, which on Windows means the system is too broken to run.
|
||||
fn make_token() -> Token {
|
||||
let mut token = [0u8; TOKEN_LEN];
|
||||
getrandom::fill(&mut token).expect("OS RNG (BCryptGenRandom) unavailable");
|
||||
token
|
||||
}
|
||||
|
||||
/// Lowercase-hex encode a token for the (text) port file.
|
||||
fn encode_token(token: &Token) -> String {
|
||||
let mut s = String::with_capacity(TOKEN_LEN * 2);
|
||||
for b in token {
|
||||
@@ -418,7 +280,6 @@ mod imp_windows {
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode a hex token; `None` unless it's exactly `TOKEN_LEN` bytes of valid hex.
|
||||
fn decode_token(s: &str) -> Option<Token> {
|
||||
let s = s.trim();
|
||||
if s.len() != TOKEN_LEN * 2 {
|
||||
@@ -434,9 +295,6 @@ mod imp_windows {
|
||||
Some(token)
|
||||
}
|
||||
|
||||
/// The port file records `<port>\n<token-hex>`: the loopback port the GUI
|
||||
/// connects to, plus the token it must present. Parse both back; `None` if the
|
||||
/// file is malformed (a truncated write, or an old single-line file).
|
||||
fn parse_port_file(contents: &str) -> Option<(u16, Token)> {
|
||||
let mut lines = contents.lines();
|
||||
let port = lines.next()?.trim().parse::<u16>().ok()?;
|
||||
@@ -444,9 +302,6 @@ mod imp_windows {
|
||||
Some((port, token))
|
||||
}
|
||||
|
||||
/// Constant-time token comparison: fold every byte's difference into one
|
||||
/// accumulator so the check can't leak how many leading bytes matched. A local
|
||||
/// timing side-channel is far-fetched over loopback, but the guard is free.
|
||||
fn tokens_match(a: &Token, b: &Token) -> bool {
|
||||
let mut diff = 0u8;
|
||||
for i in 0..TOKEN_LEN {
|
||||
@@ -455,20 +310,14 @@ mod imp_windows {
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Path of the port file recording the daemon's chosen loopback port + token.
|
||||
/// This is the Windows analogue of the Unix socket file: its presence is the
|
||||
/// "endpoint exists" marker, and — being under the user-private config dir —
|
||||
/// its contents (the token) are readable only by the same user.
|
||||
fn port_path() -> Option<PathBuf> {
|
||||
port_path_named(PANE_PORT_FILE)
|
||||
}
|
||||
|
||||
/// [`port_path`] for any of this daemon's endpoints.
|
||||
pub fn port_path_named(file: &str) -> Option<PathBuf> {
|
||||
config::config_path(file)
|
||||
}
|
||||
|
||||
/// Read the recorded loopback port + token, if the port file exists and parses.
|
||||
fn read_port_file() -> Option<(u16, Token)> {
|
||||
read_port_file_named(PANE_PORT_FILE)
|
||||
}
|
||||
@@ -483,30 +332,16 @@ mod imp_windows {
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, port))
|
||||
}
|
||||
|
||||
/// Try to connect to the daemon. `Err` (including a missing/zero port or a
|
||||
/// malformed file) means "nobody home" — the caller treats any error as "not
|
||||
/// running". On success we send the auth token as the connection preamble,
|
||||
/// before any `ClientMsg`, so the daemon accepts us.
|
||||
pub fn connect() -> io::Result<Stream> {
|
||||
let (port, token) = read_port_file()
|
||||
.filter(|(p, _)| *p != 0)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no daemon port file"))?;
|
||||
let mut stream = TcpStream::connect(loopback(port))?;
|
||||
tune(&stream);
|
||||
// Present the token first thing; the daemon reads exactly these bytes in
|
||||
// `authenticate` before it looks for a `ClientMsg`.
|
||||
stream.write_all(&token)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Daemon side: read and verify the connection preamble against this daemon's
|
||||
/// token before any message is processed. Any process on the machine can open
|
||||
/// a loopback TCP connection, but only one that read the user-private port file
|
||||
/// knows the token — so this is what makes the loopback endpoint per-user
|
||||
/// private, the property a Unix socket gets for free from filesystem perms.
|
||||
///
|
||||
/// A short read (peer hung up), a mismatch, or an uninitialized token all fail
|
||||
/// the connection; the caller drops it.
|
||||
pub fn authenticate(stream: &mut Stream) -> io::Result<()> {
|
||||
let expected = DAEMON_TOKEN
|
||||
.get()
|
||||
@@ -514,16 +349,10 @@ mod imp_windows {
|
||||
authenticate_with(stream, expected)
|
||||
}
|
||||
|
||||
/// [`authenticate`] for a connection on one of this daemon's *other*
|
||||
/// endpoints, whose token its listener holds rather than reading from the
|
||||
/// process global.
|
||||
pub fn check_endpoint_token(stream: &mut Stream, expected: &Token) -> io::Result<()> {
|
||||
authenticate_with(stream, expected)
|
||||
}
|
||||
|
||||
/// Pure core of [`authenticate`]: read a token off `reader` and compare it to
|
||||
/// `expected`. Split out so the handshake is testable without a live daemon or
|
||||
/// the process-global token.
|
||||
fn authenticate_with(reader: &mut impl Read, expected: &Token) -> io::Result<()> {
|
||||
let mut got = [0u8; TOKEN_LEN];
|
||||
reader.read_exact(&mut got)?;
|
||||
@@ -537,44 +366,26 @@ mod imp_windows {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loopback-TCP analogue of the Unix `tune`: disable Nagle so small framed
|
||||
/// messages (keystrokes, resizes) aren't held back waiting for an ACK.
|
||||
/// Buffer sizes are left at the Windows defaults (already 64 KiB). Best
|
||||
/// effort.
|
||||
pub fn tune(stream: &Stream) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
}
|
||||
|
||||
/// Whether the endpoint marker (port file) exists on disk.
|
||||
pub fn endpoint_exists() -> bool {
|
||||
port_path().is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
/// Remove a stale endpoint marker (the port file). Best effort.
|
||||
pub fn remove_stale_endpoint() {
|
||||
if let Some(path) = port_path() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a loopback listener on an OS-assigned port and record that port — plus
|
||||
/// this daemon's freshly-minted auth token — in the port file so the GUI can
|
||||
/// find *and* authenticate to it. Ensures the config dir exists first.
|
||||
pub fn bind() -> anyhow::Result<Listener> {
|
||||
// Mint the pane dialect's token once for this daemon's lifetime;
|
||||
// `authenticate` checks against the same value.
|
||||
let token = *DAEMON_TOKEN.get_or_init(make_token);
|
||||
let (listener, _) = bind_named(PANE_PORT_FILE, token)?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// [`bind`] for a second dialect in this same daemon: its own ephemeral
|
||||
/// port, its own token, its own marker file beside `daemon.port`.
|
||||
///
|
||||
/// Answers the token as well as the listener, because a second endpoint has
|
||||
/// nowhere process-global to keep it — its accept loop holds it and checks
|
||||
/// each connection with [`check_endpoint_token`]. One token per endpoint, so
|
||||
/// a client that learned one cannot present it to the other.
|
||||
pub fn bind_endpoint(file: &str) -> anyhow::Result<(Listener, Token)> {
|
||||
bind_named(file, make_token())
|
||||
}
|
||||
@@ -585,25 +396,18 @@ mod imp_windows {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// Port 0 lets the OS pick a free ephemeral port; we read it back so the
|
||||
// GUI connects to the actual bound port.
|
||||
let listener = TcpListener::bind(loopback(0))
|
||||
.map_err(|e| anyhow::anyhow!("bind 127.0.0.1:0 failed: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))?
|
||||
.port();
|
||||
// Written to the marker file so a client that can read it (same user)
|
||||
// can present it back.
|
||||
let contents = format!("{port}\n{}", encode_token(&token));
|
||||
std::fs::write(&path, contents)
|
||||
.map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?;
|
||||
Ok((listener, token))
|
||||
}
|
||||
|
||||
/// [`connect`] to one of the daemon's other endpoints, presenting the token
|
||||
/// its marker file records. `NotFound` means nothing is listening there — the
|
||||
/// same "nobody home" every caller treats as "not running".
|
||||
pub fn connect_endpoint(file: &str) -> io::Result<Stream> {
|
||||
let (port, token) = read_port_file_named(file)
|
||||
.filter(|(p, _)| *p != 0)
|
||||
@@ -614,20 +418,16 @@ mod imp_windows {
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Remove another endpoint's marker file. Best effort, like
|
||||
/// [`remove_stale_endpoint`].
|
||||
pub fn remove_endpoint(file: &str) {
|
||||
if let Some(path) = port_path_named(file) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// A human-readable description of the endpoint, for log messages.
|
||||
pub fn endpoint_display() -> String {
|
||||
endpoint_display_named(PANE_PORT_FILE)
|
||||
}
|
||||
|
||||
/// [`endpoint_display`] for another of this daemon's endpoints.
|
||||
pub fn endpoint_display_named(file: &str) -> String {
|
||||
match read_port_file_named(file) {
|
||||
Some((port, _)) => format!("127.0.0.1:{port}"),
|
||||
@@ -640,26 +440,8 @@ mod imp_windows {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a loopback client gets to show up. Generous on purpose — the
|
||||
/// client is a thread in this same process dialling 127.0.0.1 — so a trip
|
||||
/// means the client is never coming, not that the runner is slow.
|
||||
const CLIENT_WITHIN: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `accept()` with a deadline, and a read timeout on what it returns.
|
||||
///
|
||||
/// Both halves matter, and neither is available on the blocking calls
|
||||
/// these tests would otherwise make. Every client below is a thread that
|
||||
/// `unwrap()`s its `connect`: when one of those panics — a transient
|
||||
/// loopback refusal on a loaded runner is enough — a plain
|
||||
/// `listener.accept()` has nothing left to wake it, and the handshake read
|
||||
/// after it has nothing left to feed it. The test does not fail. The whole
|
||||
/// test binary stops, `cargo test` never returns, and CI bills six hours
|
||||
/// for a step that takes seventy-five seconds.
|
||||
///
|
||||
/// That is not hypothetical: it happened three times in one day, and
|
||||
/// because libtest only names a test once it *finishes*, no log ever said
|
||||
/// which one. These tests are `cfg(windows)`, so a developer's macOS
|
||||
/// `cargo test` never runs them and CI is the only place they execute.
|
||||
fn accept_within(listener: &TcpListener) -> TcpStream {
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
@@ -683,9 +465,6 @@ mod imp_windows {
|
||||
listener
|
||||
.set_nonblocking(false)
|
||||
.expect("restore the listener to blocking");
|
||||
// Winsock gives an accepted socket the listening socket's blocking
|
||||
// mode, so this is a real change rather than a no-op: the handshake
|
||||
// read must block, but only for a bounded time.
|
||||
accepted
|
||||
.set_nonblocking(false)
|
||||
.expect("the accepted socket must block");
|
||||
@@ -695,25 +474,22 @@ mod imp_windows {
|
||||
accepted
|
||||
}
|
||||
|
||||
/// A token round-trips through hex encode → decode unchanged.
|
||||
#[test]
|
||||
fn token_hex_round_trips() {
|
||||
let token = make_token();
|
||||
assert_eq!(decode_token(&encode_token(&token)), Some(token));
|
||||
}
|
||||
|
||||
/// `decode_token` rejects anything that isn't exactly 32 bytes of hex.
|
||||
#[test]
|
||||
fn decode_token_rejects_malformed() {
|
||||
assert!(decode_token("").is_none());
|
||||
assert!(decode_token("zz").is_none());
|
||||
assert!(decode_token(&"a".repeat(63)).is_none()); // odd/short
|
||||
assert!(decode_token(&"a".repeat(66)).is_none()); // too long
|
||||
assert!(decode_token(&"g".repeat(64)).is_none()); // non-hex digit
|
||||
assert!(decode_token(&"ab".repeat(32)).is_some()); // exactly right
|
||||
assert!(decode_token(&"a".repeat(63)).is_none());
|
||||
assert!(decode_token(&"a".repeat(66)).is_none());
|
||||
assert!(decode_token(&"g".repeat(64)).is_none());
|
||||
assert!(decode_token(&"ab".repeat(32)).is_some());
|
||||
}
|
||||
|
||||
/// The port file format is `<port>\n<token-hex>`, and parsing recovers both.
|
||||
#[test]
|
||||
fn parse_port_file_recovers_port_and_token() {
|
||||
let token = make_token();
|
||||
@@ -721,8 +497,6 @@ mod imp_windows {
|
||||
assert_eq!(parse_port_file(&contents), Some((54321, token)));
|
||||
}
|
||||
|
||||
/// A single-line (legacy / truncated) file has no token, so it must not
|
||||
/// parse — a client can't authenticate without one.
|
||||
#[test]
|
||||
fn parse_port_file_rejects_missing_token() {
|
||||
assert!(parse_port_file("54321").is_none());
|
||||
@@ -731,48 +505,38 @@ mod imp_windows {
|
||||
assert!(parse_port_file("notaport\ndeadbeef").is_none());
|
||||
}
|
||||
|
||||
/// `tokens_match` is true only for identical tokens.
|
||||
#[test]
|
||||
fn tokens_match_is_exact() {
|
||||
let a = make_token();
|
||||
let mut b = a;
|
||||
assert!(tokens_match(&a, &b));
|
||||
b[TOKEN_LEN - 1] ^= 1; // flip the last bit
|
||||
b[TOKEN_LEN - 1] ^= 1;
|
||||
assert!(!tokens_match(&a, &b));
|
||||
}
|
||||
|
||||
/// The handshake core accepts the matching token and rejects a wrong one
|
||||
/// (and a short read), driven over an in-memory reader — no live daemon.
|
||||
#[test]
|
||||
fn authenticate_with_accepts_only_the_matching_token() {
|
||||
let token = make_token();
|
||||
|
||||
// Correct token → Ok.
|
||||
let mut good = std::io::Cursor::new(token.to_vec());
|
||||
assert!(authenticate_with(&mut good, &token).is_ok());
|
||||
|
||||
// Wrong token → PermissionDenied.
|
||||
let mut wrong_bytes = token;
|
||||
wrong_bytes[0] ^= 0xff;
|
||||
let mut wrong = std::io::Cursor::new(wrong_bytes.to_vec());
|
||||
let err = authenticate_with(&mut wrong, &token).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
|
||||
// Short preamble (peer hung up mid-token) → error, never a false accept.
|
||||
let mut short = std::io::Cursor::new(vec![0u8; TOKEN_LEN - 1]);
|
||||
assert!(authenticate_with(&mut short, &token).is_err());
|
||||
}
|
||||
|
||||
/// End-to-end over a real loopback socket: a client that presents the
|
||||
/// token authenticates; one that presents garbage is rejected. This is the
|
||||
/// exact property the whole change exists to enforce.
|
||||
#[test]
|
||||
fn loopback_handshake_authenticates_real_connection() {
|
||||
let token = make_token();
|
||||
let listener = TcpListener::bind(loopback(0)).expect("bind loopback");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
// Good client: connect and present the correct token.
|
||||
let good = std::thread::spawn(move || {
|
||||
let mut s = TcpStream::connect(loopback(port)).unwrap();
|
||||
s.write_all(&token).unwrap();
|
||||
@@ -782,7 +546,6 @@ mod imp_windows {
|
||||
assert!(authenticate_with(&mut server_side, &token).is_ok());
|
||||
let _keep = good.join().unwrap();
|
||||
|
||||
// Bad client: connect and present a wrong token.
|
||||
let mut bad_token = token;
|
||||
bad_token[5] ^= 0xff;
|
||||
let bad = std::thread::spawn(move || {
|
||||
@@ -794,15 +557,8 @@ mod imp_windows {
|
||||
bad.join().unwrap();
|
||||
}
|
||||
|
||||
/// The daemon's *second* endpoint — the control dialect's, bound by
|
||||
/// [`crate::host::server`] — is a separate port with a separate token,
|
||||
/// recorded in a separate file. Two listeners, two boundaries: a client
|
||||
/// that learned the pane endpoint's token has not thereby been given the
|
||||
/// one behind which the whole workspace tree lives.
|
||||
#[test]
|
||||
fn a_second_endpoint_gets_its_own_port_and_token() {
|
||||
// The name `host::server` uses; spelled out rather than imported so
|
||||
// the transport does not depend on the dialect above it.
|
||||
const CONTROL: &str = "control.port";
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id()));
|
||||
@@ -821,16 +577,11 @@ mod imp_windows {
|
||||
"and the token the listener will check for"
|
||||
);
|
||||
|
||||
// A client that could read the file gets in — that read is the whole
|
||||
// proof of same-user, which is what filesystem permissions give the
|
||||
// Unix socket for free.
|
||||
let good = std::thread::spawn(move || connect_endpoint(CONTROL).unwrap());
|
||||
let mut server_side = accept_within(&listener);
|
||||
assert!(check_endpoint_token(&mut server_side, &token).is_ok());
|
||||
let _keep = good.join().unwrap();
|
||||
|
||||
// Anything else is refused before a frame is parsed — including the
|
||||
// other endpoint's token, which is why they are minted separately.
|
||||
let mut foreign = token;
|
||||
foreign[0] ^= 0xff;
|
||||
let bad = std::thread::spawn(move || {
|
||||
@@ -849,15 +600,8 @@ mod imp_windows {
|
||||
remove_endpoint(CONTROL);
|
||||
}
|
||||
|
||||
/// Full wiring over the real config-dir path: `bind` writes a parseable
|
||||
/// `<port>\n<token>` file and seeds the process token, and the public
|
||||
/// `authenticate` (which reads that process token) then accepts a client
|
||||
/// that presents the file's token. Exercises the `bind`→`connect`→
|
||||
/// `authenticate` seam the daemon actually runs, not just the pure core.
|
||||
#[test]
|
||||
fn bind_seeds_token_and_public_authenticate_accepts_a_file_token_client() {
|
||||
// Pin the config dir under a temp dir so the port file never touches the
|
||||
// real `%APPDATA%`. First-call-wins, matching the Unix IO tests.
|
||||
let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
config::set_config_dir(dir);
|
||||
@@ -866,13 +610,10 @@ mod imp_windows {
|
||||
let listener = bind().expect("bind under temp config dir");
|
||||
let bound_port = listener.local_addr().unwrap().port();
|
||||
|
||||
// The port file parses and matches the bound port.
|
||||
let contents = std::fs::read_to_string(port_path().unwrap()).unwrap();
|
||||
let (port, token) = parse_port_file(&contents).expect("port file parses");
|
||||
assert_eq!(port, bound_port, "file records the actually-bound port");
|
||||
|
||||
// A client that read the file (has the token) authenticates via the
|
||||
// public path, which checks against the token `bind` seeded.
|
||||
let good = std::thread::spawn(move || {
|
||||
let mut s = TcpStream::connect(loopback(port)).unwrap();
|
||||
s.write_all(&token).unwrap();
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
//! Windows-only process-table helpers.
|
||||
//!
|
||||
//! Windows has no ConPTY analogue of a Unix "foreground process group", so the
|
||||
//! daemon can't ask the pty who's in front (that's why `pane`'s macOS/Linux
|
||||
//! foreground queries have no Windows counterpart). What it *can* do is walk the
|
||||
//! process table from the shell's own pid. Two pane operations need that:
|
||||
//!
|
||||
//! - **titling** a pane by the command running under the shell
|
||||
//! ([`foreground_name`]), so Windows tabs show `git` / `node` / … instead of
|
||||
//! staying blank; and
|
||||
//! - **hangup** ([`descendants`]), because `portable-pty`'s Windows `kill`
|
||||
//! terminates only the shell process — its children would otherwise be
|
||||
//! reparented and linger, some still attached to the ConPTY, which keeps the
|
||||
//! pane reader's blocking read from ever hitting EOF.
|
||||
//!
|
||||
//! The Win32 surface is a thin [`snapshot`]/[`terminate`] pair; all the tree
|
||||
//! logic is pure over a plain [`Proc`] list and unit-tested without a live
|
||||
//! process. Note that reading another process's *cwd* is deliberately not here:
|
||||
//! it needs PEB traversal via `ReadProcessMemory`, which is undocumented and
|
||||
//! fragile across bitness/elevation — so cwd on Windows stays sourced from OSC 7
|
||||
//! (see `pane::foreground_cwd`).
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
/// One process-table row: a pid, its parent's pid, and the executable basename.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Proc {
|
||||
pub pid: u32,
|
||||
@@ -30,11 +7,6 @@ pub(crate) struct Proc {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// BFS the table from `root` by parent link, returning `(depth, pid, name)` for
|
||||
/// every reachable descendant (root excluded), shallowest-first. A `seen` set
|
||||
/// makes the walk robust to Windows pid reuse: a stale parent link that points
|
||||
/// back into the tree (or a process that lists itself as its own parent) can't
|
||||
/// create a cycle, because each pid is expanded at most once.
|
||||
fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> {
|
||||
let mut seen = HashSet::new();
|
||||
seen.insert(root);
|
||||
@@ -52,22 +24,12 @@ fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Descendants of `root` (children, grandchildren, …), each listed once and
|
||||
/// ordered deepest-first — so a caller terminating them tears down leaf commands
|
||||
/// before the shells that spawned them. `root` itself is never included.
|
||||
pub(crate) fn descendants(procs: &[Proc], root: u32) -> Vec<u32> {
|
||||
let mut walked = walk(procs, root);
|
||||
// Deepest depth first; stable within a depth, so ordering is deterministic.
|
||||
walked.sort_by_key(|&(depth, ..)| std::cmp::Reverse(depth));
|
||||
walked.into_iter().map(|(_, pid, _)| pid).collect()
|
||||
}
|
||||
|
||||
/// The foreground command's exe name for a shell rooted at `shell_pid`: the
|
||||
/// deepest descendant (the thing actually running under the shell), or `None`
|
||||
/// when the shell has no descendants at all — i.e. it's idle at its prompt, in
|
||||
/// which case the caller keeps the pane's existing title. Ties at equal depth
|
||||
/// break toward the largest pid (roughly the most recently created) so the pick
|
||||
/// is stable frame to frame.
|
||||
pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option<String> {
|
||||
walk(procs, shell_pid)
|
||||
.into_iter()
|
||||
@@ -75,9 +37,6 @@ pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option<String>
|
||||
.map(|(_, _, name)| name.to_string())
|
||||
}
|
||||
|
||||
/// Snapshot every process on the system as a [`Proc`] list, via a Toolhelp
|
||||
/// snapshot. Best effort: any failure yields an empty list (the callers then
|
||||
/// simply do nothing — no title, no extra kills).
|
||||
pub(crate) fn snapshot() -> Vec<Proc> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
@@ -86,10 +45,6 @@ pub(crate) fn snapshot() -> Vec<Proc> {
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a textbook Toolhelp enumeration. The snapshot handle is closed on
|
||||
// every exit path; `PROCESSENTRY32W` is zeroed and its `dwSize` set before the
|
||||
// first call, exactly as the API requires; each `szExeFile` is a NUL-terminated
|
||||
// UTF-16 buffer we read within its fixed length.
|
||||
unsafe {
|
||||
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snap == INVALID_HANDLE_VALUE {
|
||||
@@ -114,13 +69,9 @@ pub(crate) fn snapshot() -> Vec<Proc> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Force-terminate `pid`. Best effort: a process we can't open (already gone, or
|
||||
/// access denied) is simply skipped.
|
||||
pub(crate) fn terminate(pid: u32) {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
|
||||
// SAFETY: open → terminate → close on a single pid. A null handle (the process
|
||||
// exited or we lack rights) is checked before use; the handle is always closed.
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
|
||||
if !handle.is_null() {
|
||||
@@ -130,7 +81,6 @@ pub(crate) fn terminate(pid: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable basename from a NUL-terminated UTF-16 `szExeFile` field.
|
||||
fn exe_name(raw: &[u16]) -> String {
|
||||
let len = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
|
||||
String::from_utf16_lossy(&raw[..len])
|
||||
@@ -148,59 +98,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A realistic tree: only the shell's own descendants come back, unrelated
|
||||
/// processes (and the shell's ancestors) are excluded.
|
||||
#[test]
|
||||
fn descendants_collects_only_the_shell_subtree() {
|
||||
let procs = vec![
|
||||
p(1, 0, "System"),
|
||||
p(100, 1, "powershell.exe"), // the shell
|
||||
p(200, 100, "git.exe"), // child
|
||||
p(300, 200, "less.exe"), // grandchild
|
||||
p(201, 100, "node.exe"), // another child
|
||||
p(999, 1, "explorer.exe"), // unrelated
|
||||
p(100, 1, "powershell.exe"),
|
||||
p(200, 100, "git.exe"),
|
||||
p(300, 200, "less.exe"),
|
||||
p(201, 100, "node.exe"),
|
||||
p(999, 1, "explorer.exe"),
|
||||
];
|
||||
let mut got = descendants(&procs, 100);
|
||||
got.sort();
|
||||
assert_eq!(got, vec![200, 201, 300]);
|
||||
}
|
||||
|
||||
/// Descendants come back deepest-first, so a terminator hits leaves before
|
||||
/// the parents that spawned them.
|
||||
#[test]
|
||||
fn descendants_are_ordered_deepest_first() {
|
||||
let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(300, 200, "b")];
|
||||
assert_eq!(descendants(&procs, 100), vec![300, 200]);
|
||||
}
|
||||
|
||||
/// Pid reuse can make a parent link point back into the tree; the walk must
|
||||
/// not loop forever on that.
|
||||
#[test]
|
||||
fn descendants_survive_a_pid_reuse_cycle() {
|
||||
// 200's parent is 100 (real child); 100 *also* claims 200 as its parent
|
||||
// (a reused pid). 100 is the root, so it's never re-expanded.
|
||||
let procs = vec![p(100, 200, "a"), p(200, 100, "b")];
|
||||
assert_eq!(descendants(&procs, 100), vec![200]);
|
||||
}
|
||||
|
||||
/// A self-parenting row (pid == parent, as some system pids report) can't
|
||||
/// wedge the walk either.
|
||||
#[test]
|
||||
fn descendants_survive_self_parenting() {
|
||||
let procs = vec![p(100, 1, "sh"), p(100, 100, "self")];
|
||||
// The only row whose parent is 100 is the self-referential one, which is
|
||||
// rejected (pid == parent), so nothing descends.
|
||||
assert!(descendants(&procs, 100).is_empty());
|
||||
}
|
||||
|
||||
/// A shell sitting idle at its prompt (no children) has no descendants.
|
||||
#[test]
|
||||
fn descendants_empty_without_children() {
|
||||
let procs = vec![p(100, 1, "sh"), p(999, 1, "other")];
|
||||
assert!(descendants(&procs, 100).is_empty());
|
||||
}
|
||||
|
||||
/// The pane title is the deepest running command, not the shell.
|
||||
#[test]
|
||||
fn foreground_name_is_the_deepest_command() {
|
||||
let procs = vec![
|
||||
@@ -211,23 +147,18 @@ mod tests {
|
||||
assert_eq!(foreground_name(&procs, 100).as_deref(), Some("less.exe"));
|
||||
}
|
||||
|
||||
/// Idle at the prompt → no foreground command, so the caller keeps the
|
||||
/// existing title rather than blanking it.
|
||||
#[test]
|
||||
fn foreground_name_is_none_at_idle_prompt() {
|
||||
let procs = vec![p(100, 1, "powershell.exe"), p(999, 1, "explorer.exe")];
|
||||
assert_eq!(foreground_name(&procs, 100), None);
|
||||
}
|
||||
|
||||
/// Two equally-deep children resolve deterministically (largest pid wins) so
|
||||
/// the title doesn't flicker between them.
|
||||
#[test]
|
||||
fn foreground_name_breaks_depth_ties_by_pid() {
|
||||
let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(201, 100, "b")];
|
||||
assert_eq!(foreground_name(&procs, 100).as_deref(), Some("b"));
|
||||
}
|
||||
|
||||
/// UTF-16 `szExeFile` decoding stops at the NUL terminator.
|
||||
#[test]
|
||||
fn exe_name_reads_up_to_the_nul() {
|
||||
let mut raw = [0u16; 260];
|
||||
|
||||
@@ -1,37 +1,3 @@
|
||||
//! The suite every [`Host`] implementation has to pass, unchanged.
|
||||
//!
|
||||
//! A remote workspace is only worth having if "the files are over there" is
|
||||
//! invisible. That invisibility is not something a design document can enforce
|
||||
//! — it is a property of two implementations agreeing on several dozen small
|
||||
//! behaviours: what a listing is sorted by, whether a non-zero `git` exit is an
|
||||
//! error, what happens when you rename onto an existing file, how long a
|
||||
//! watcher batches for. So the behaviours live here, once, as functions over
|
||||
//! `&dyn Host`, and [`LocalHost`](super::local::LocalHost), `RemoteHost` and the
|
||||
//! `--stdio` server all run the same list.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Each case is a `pub fn(&dyn Host, &dyn Sandbox)`. `&dyn Host` rather than a generic
|
||||
//! is deliberate twice over: it keeps the suite from monomorphizing per
|
||||
//! implementation, and it makes the suite itself the proof that the trait stayed
|
||||
//! object-safe — which the whole tree depends on, since a workspace holds
|
||||
//! `Arc<dyn Host>`.
|
||||
//!
|
||||
//! [`for_each_host_case!`](crate::for_each_host_case) lists every case;
|
||||
//! [`host_conformance_suite!`](crate::host_conformance_suite) expands that list
|
||||
//! into one `#[test]` per case for a given host factory, so a failure names the
|
||||
//! behaviour that broke instead of arriving as one opaque red suite.
|
||||
//!
|
||||
//! Adding a case means writing the `pub fn` *and* adding a line to the macro.
|
||||
//! `every_case_is_registered` fails if you do only the first.
|
||||
//!
|
||||
//! # What a case may assume
|
||||
//!
|
||||
//! Only the sandbox and the `Host`. Cases build their fixtures through the host
|
||||
//! being tested — `h.write_file`, `h.create_dir` — never through `std::fs`,
|
||||
//! because for a remote host the sandbox is a directory on *another machine*
|
||||
//! and `std::fs` would quietly test the wrong computer.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -39,42 +5,23 @@ use std::time::{Duration, Instant};
|
||||
use super::{Host, MTime, SearchHit};
|
||||
use crate::daemon::control::WATCH_COALESCE_WINDOW;
|
||||
|
||||
/// One conformance case.
|
||||
pub type Case = fn(h: &dyn Host, sandbox: &dyn Sandbox);
|
||||
|
||||
/// An empty, disposable directory in the host's own namespace.
|
||||
///
|
||||
/// The factory that produces one has to guarantee: it is empty; it is cleaned up
|
||||
/// when dropped; its path is meaningful *to the host under test* (for a remote
|
||||
/// host that means a path on the server, not on the client); and `git` can run
|
||||
/// inside it.
|
||||
pub trait Sandbox {
|
||||
/// The directory's path, in the host's own vocabulary.
|
||||
fn path(&self) -> &Path;
|
||||
|
||||
/// Create a symbolic link at `link` pointing to `target`, or `None` if this
|
||||
/// sandbox cannot make symlinks (unprivileged Windows). Cases that need one
|
||||
/// skip rather than fail when this is `None`, because "this platform has no
|
||||
/// symlinks" is not a host bug.
|
||||
fn symlink(&self, target: &Path, link: &Path) -> Option<io::Result<()>> {
|
||||
let _ = (target, link);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Every case, one per line. The single source of truth for what the suite is.
|
||||
///
|
||||
/// The whole list goes to `$cb` in one brace-delimited invocation, plus whatever
|
||||
/// extra token trees the caller passes ahead of the `@cases` marker. Two
|
||||
/// callbacks use it — one builds [`CASES`], the other builds a run of `#[test]`s
|
||||
/// — and neither can drift from the other, because there is only one list.
|
||||
#[macro_export]
|
||||
macro_rules! for_each_host_case {
|
||||
($cb:ident $(, $extra:tt)*) => {
|
||||
$cb! {
|
||||
$($extra)*
|
||||
@cases
|
||||
// fs: reading
|
||||
read_dir_lists_and_sorts,
|
||||
read_dir_includes_hidden,
|
||||
read_dir_missing_is_not_found,
|
||||
@@ -90,7 +37,6 @@ macro_rules! for_each_host_case {
|
||||
read_file_roundtrips_bytes,
|
||||
read_file_over_max_bytes_errors,
|
||||
read_file_on_a_dir_errors,
|
||||
// fs: writing
|
||||
write_file_creates_and_overwrites,
|
||||
write_file_reports_its_own_metadata,
|
||||
write_file_to_missing_parent_errors,
|
||||
@@ -102,7 +48,6 @@ macro_rules! for_each_host_case {
|
||||
remove_file_then_missing,
|
||||
remove_dir_non_recursive_needs_empty,
|
||||
remove_dir_recursive_clears_tree,
|
||||
// git
|
||||
repo_root_finds_nearest_git,
|
||||
repo_root_handles_worktree_file,
|
||||
git_status_porcelain_reflects_changes,
|
||||
@@ -110,23 +55,18 @@ macro_rules! for_each_host_case {
|
||||
git_that_cannot_run_is_err,
|
||||
git_optional_locks_env_is_set,
|
||||
git_stdin_is_null,
|
||||
// path arithmetic
|
||||
join_uses_host_separator,
|
||||
is_absolute_matches_host_semantics,
|
||||
// search
|
||||
search_is_breadth_first,
|
||||
search_skips_ignored_dirs,
|
||||
search_respects_limit,
|
||||
search_respects_max_dirs,
|
||||
// machine inventory
|
||||
shells_are_named_and_have_a_default,
|
||||
// watch
|
||||
watch_reports_create_and_delete,
|
||||
watch_is_non_recursive,
|
||||
watch_set_dirs_adds_and_drops,
|
||||
watch_coalesces_within_window,
|
||||
watch_drop_unsubscribes,
|
||||
// connection semantics
|
||||
is_connected_is_true_when_healthy,
|
||||
id_is_stable_across_calls,
|
||||
separator_matches_hello,
|
||||
@@ -134,15 +74,11 @@ macro_rules! for_each_host_case {
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds [`CASES`]. Internal to [`for_each_host_case!`].
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __host_case_table {
|
||||
(@cases $($name:ident),* $(,)?) => {
|
||||
/// Every case as `(name, fn)`, for a runner that cannot use the
|
||||
/// `#[test]` expansion — an integration test in another crate driving a
|
||||
/// real `tty7-server --stdio`, say.
|
||||
pub const CASES: &[(&str, $crate::host::conformance::Case)] = &[
|
||||
pub const CASES: &[(&str, $crate::host::conformance::Case)] = &[
|
||||
$((stringify!($name), $name as $crate::host::conformance::Case)),*
|
||||
];
|
||||
};
|
||||
@@ -150,7 +86,6 @@ macro_rules! __host_case_table {
|
||||
|
||||
crate::for_each_host_case!(__host_case_table);
|
||||
|
||||
/// Builds one `#[test]` per case. Internal to [`host_conformance_suite!`].
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __host_case_tests {
|
||||
@@ -165,15 +100,6 @@ macro_rules! __host_case_tests {
|
||||
};
|
||||
}
|
||||
|
||||
/// Expand the whole suite into `#[test]`s for one host factory.
|
||||
///
|
||||
/// `$factory` is any expression callable with no arguments returning
|
||||
/// `(SharedHost, impl Sandbox)`. Each case gets a *fresh* host and sandbox, so
|
||||
/// one case's leftovers can never explain another's failure.
|
||||
///
|
||||
/// ```ignore
|
||||
/// tty7_core::host_conformance_suite!(local, || (LocalHost::new(), TempSandbox::new()));
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! host_conformance_suite {
|
||||
($modname:ident, $factory:expr) => {
|
||||
@@ -187,16 +113,8 @@ macro_rules! host_conformance_suite {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How long a case waits for a watcher event before calling it absent. Four
|
||||
/// coalescing windows plus slack — long enough that a loaded CI box does not
|
||||
/// flake, short enough that a genuinely dead watcher fails the run promptly.
|
||||
const WATCH_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long a case waits to be sure an event is *not* coming.
|
||||
const WATCH_QUIET: Duration = Duration::from_millis(1200);
|
||||
|
||||
fn write(h: &dyn Host, p: &Path, body: &str) {
|
||||
@@ -204,7 +122,6 @@ fn write(h: &dyn Host, p: &Path, body: &str) {
|
||||
.unwrap_or_else(|e| panic!("write {}: {e}", p.display()));
|
||||
}
|
||||
|
||||
/// [`write`] for a body that is not a `&str`.
|
||||
fn put(h: &dyn Host, p: &Path, bytes: &[u8]) {
|
||||
h.write_file(p, bytes)
|
||||
.unwrap_or_else(|e| panic!("write {}: {e}", p.display()));
|
||||
@@ -223,22 +140,15 @@ fn hit_names(hits: &[SearchHit]) -> Vec<&str> {
|
||||
hits.iter().map(|h| h.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// A git repository in `dir`, or `None` when this host has no usable git — in
|
||||
/// which case the git cases skip rather than fail, because "no git installed"
|
||||
/// is an environment fact and not a conformance violation.
|
||||
fn git_repo(h: &dyn Host, dir: &Path) -> Option<()> {
|
||||
let out = h.git(dir, &["init", "--quiet"]).ok()?;
|
||||
out.success().then_some(())
|
||||
}
|
||||
|
||||
/// Drain whatever the watcher already queued, so a case's assertions are about
|
||||
/// the change it just made and not about the fixture it built.
|
||||
fn drain(sub: &super::WatchSub) {
|
||||
while sub.events().try_recv().is_ok() {}
|
||||
}
|
||||
|
||||
/// The next batch containing a path whose file name is `name`, or `None` if
|
||||
/// none arrives within [`WATCH_TIMEOUT`].
|
||||
fn await_event(sub: &super::WatchSub, name: &str) -> Option<Vec<PathBuf>> {
|
||||
let deadline = Instant::now() + WATCH_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
@@ -260,7 +170,6 @@ fn await_event(sub: &super::WatchSub, name: &str) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect every batch that arrives over `window`.
|
||||
fn collect_batches(sub: &super::WatchSub, window: Duration) -> Vec<Vec<PathBuf>> {
|
||||
let deadline = Instant::now() + window;
|
||||
let mut out = Vec::new();
|
||||
@@ -276,13 +185,6 @@ fn collect_batches(sub: &super::WatchSub, window: Duration) -> Vec<Vec<PathBuf>>
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fs: reading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Directories first, then case-insensitively by name — the order the file tree
|
||||
/// renders, computed by the host so a remote listing needs no client-side sort
|
||||
/// (and so the two can never drift).
|
||||
pub fn read_dir_lists_and_sorts(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
mkdir(h, &h.join(sandbox, "src"));
|
||||
@@ -299,9 +201,6 @@ pub fn read_dir_lists_and_sorts(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hidden files come back. Whether to *show* them is a UI preference, and a host
|
||||
/// that filtered them would make that preference unimplementable for the tree
|
||||
/// while still costing a listing.
|
||||
pub fn read_dir_includes_hidden(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(h, &h.join(sandbox, ".hidden"), "");
|
||||
@@ -310,17 +209,12 @@ pub fn read_dir_includes_hidden(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(names(&listed).contains(&".hidden"), "{:?}", names(&listed));
|
||||
}
|
||||
|
||||
/// A directory that isn't there is `NotFound`, so the tree can tell "gone" from
|
||||
/// "unreadable" and drop the row instead of showing an error.
|
||||
pub fn read_dir_missing_is_not_found(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let err = h.read_dir(&h.join(sandbox, "nope"), None).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}");
|
||||
}
|
||||
|
||||
/// Listing a file is an error. Which error varies by platform (`NotADirectory`
|
||||
/// where it exists), so the assertion is only that it fails rather than
|
||||
/// pretending to be an empty directory.
|
||||
pub fn read_dir_on_a_file_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "file.txt");
|
||||
@@ -328,8 +222,6 @@ pub fn read_dir_on_a_file_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(h.read_dir(&f, None).is_err());
|
||||
}
|
||||
|
||||
/// `.git` is ignored unconditionally — no `.gitignore` mentions it, and the tree
|
||||
/// has always dimmed it.
|
||||
pub fn read_dir_marks_dotgit_ignored(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
mkdir(h, &h.join(sandbox, ".git"));
|
||||
@@ -344,10 +236,6 @@ pub fn read_dir_marks_dotgit_ignored(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!a.ignored);
|
||||
}
|
||||
|
||||
/// The gitignore chain, scored the way git scores it: walk from the root down,
|
||||
/// deepest match wins, a nested `!pattern` un-ignores what an ancestor ignored.
|
||||
/// The fixture is the file tree's own, so a regression here is a visible change
|
||||
/// in what the sidebar dims.
|
||||
pub fn read_dir_applies_gitignore_chain(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
mkdir(h, &h.join(sandbox, "src"));
|
||||
@@ -378,8 +266,6 @@ pub fn read_dir_applies_gitignore_chain(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!ignored(&nested, "main.rs"));
|
||||
}
|
||||
|
||||
/// Without a root there is no chain to score against, so nothing is ignored —
|
||||
/// except `.git`, which is not a pattern match.
|
||||
pub fn read_dir_without_root_ignores_nothing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
mkdir(h, &h.join(sandbox, ".git"));
|
||||
@@ -400,13 +286,8 @@ pub fn read_dir_without_root_ignores_nothing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A symlink to a directory reads as a directory *and* as a link: the tree
|
||||
/// expands it like a directory, and the sort puts it with the directories, but
|
||||
/// callers that care (a delete, say) can still tell.
|
||||
pub fn read_dir_symlink_to_dir_is_dir(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
// The sandbox owns symlink creation because the host trait has no method
|
||||
// for it — and on unprivileged Windows there is nothing to test.
|
||||
let target = h.join(sandbox, "real");
|
||||
let link = h.join(sandbox, "link");
|
||||
mkdir(h, &target);
|
||||
@@ -426,8 +307,6 @@ pub fn read_dir_symlink_to_dir_is_dir(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(l.is_symlink, "and still reports as a link");
|
||||
}
|
||||
|
||||
/// Size is exact and a modification time is present — the two fields the editor
|
||||
/// builds its external-change detection on.
|
||||
pub fn stat_reports_len_and_mtime(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "sized.txt");
|
||||
@@ -444,16 +323,12 @@ pub fn stat_reports_len_and_mtime(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(h.stat(&d).unwrap().is_dir);
|
||||
}
|
||||
|
||||
/// A missing path is `NotFound`, not some generic failure — call sites branch on
|
||||
/// it to tell "deleted" from "broken".
|
||||
pub fn stat_missing_is_not_found(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let err = h.stat(&h.join(sandbox, "ghost")).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}");
|
||||
}
|
||||
|
||||
/// `exists` is allowed to be a cheaper path than `stat`, but it must never be a
|
||||
/// *different* answer.
|
||||
pub fn exists_matches_stat(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "there.txt");
|
||||
@@ -465,14 +340,10 @@ pub fn exists_matches_stat(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(h.exists(&missing), h.stat(&missing).is_ok());
|
||||
assert!(!h.exists(&missing));
|
||||
|
||||
// A path *under* a file is neither a file nor a directory.
|
||||
let nested = h.join(&f, "child");
|
||||
assert_eq!(h.exists(&nested), h.stat(&nested).is_ok());
|
||||
}
|
||||
|
||||
/// `..` is resolved by the host, not by the client's `std::path` — which on a
|
||||
/// Windows client would resolve a remote POSIX path against the wrong
|
||||
/// filesystem entirely.
|
||||
pub fn canonicalize_resolves_dotdot(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let a = h.join(sandbox, "a");
|
||||
@@ -486,9 +357,6 @@ pub fn canonicalize_resolves_dotdot(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(canon, direct, "a/../b is b");
|
||||
}
|
||||
|
||||
/// Bytes are bytes: NULs, invalid UTF-8 and a multi-megabyte body all come back
|
||||
/// exactly as written. The editor reads files this way and would corrupt a
|
||||
/// binary it merely *opened* if any of it were lossy.
|
||||
pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "bytes.bin");
|
||||
@@ -496,7 +364,6 @@ pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
put(h, &f, &body);
|
||||
assert_eq!(h.read_file(&f, 1024).unwrap(), body);
|
||||
|
||||
// Big enough to cross any chunking a transport might do.
|
||||
let big = h.join(sandbox, "big.bin");
|
||||
body = (0..10 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||
put(h, &big, &body);
|
||||
@@ -505,20 +372,15 @@ pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(back == body, "10MB body round-tripped byte for byte");
|
||||
}
|
||||
|
||||
/// The limit is the *host's*: an oversized file fails without its contents
|
||||
/// being read or transferred, which is the difference between an instant "too
|
||||
/// big" and a minute of transatlantic transfer thrown away on arrival.
|
||||
pub fn read_file_over_max_bytes_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "fat.bin");
|
||||
put(h, &f, &vec![b'x'; 4096]);
|
||||
let err = h.read_file(&f, 1024).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::FileTooLarge, "{err}");
|
||||
// Exactly at the limit is fine — the check is `>`, not `>=`.
|
||||
assert_eq!(h.read_file(&f, 4096).unwrap().len(), 4096);
|
||||
}
|
||||
|
||||
/// Reading a directory fails rather than returning something.
|
||||
pub fn read_file_on_a_dir_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let d = h.join(sandbox, "adir");
|
||||
@@ -526,26 +388,15 @@ pub fn read_file_on_a_dir_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(h.read_file(&d, 1024 * 1024).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fs: writing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Creating and overwriting both work, and the file the host reports after the
|
||||
/// write is the file that is actually there.
|
||||
pub fn write_file_creates_and_overwrites(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "doc.txt");
|
||||
let wrote = h.write_file(&f, b"first").unwrap();
|
||||
assert_eq!(h.read_file(&f, 1024).unwrap(), b"first");
|
||||
// The metadata visible straight after the write describes the bytes that
|
||||
// were written — the editor's whole external-change detection rests on
|
||||
// being able to record "this mtime is mine" the moment a save lands.
|
||||
let first = h.stat(&f).unwrap();
|
||||
assert_eq!(first.len, 5);
|
||||
assert!(first.mtime.is_some());
|
||||
assert!(!first.is_dir);
|
||||
// …and the write reports that same file itself, so the caller never has to
|
||||
// ask again. This is the guard on the round trip `write_file -> Meta` saves.
|
||||
assert_eq!(
|
||||
wrote, first,
|
||||
"the write answers with the file it just wrote"
|
||||
@@ -560,13 +411,6 @@ pub fn write_file_creates_and_overwrites(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(wrote, second);
|
||||
}
|
||||
|
||||
/// The mtime a save records must come from the write itself.
|
||||
///
|
||||
/// The editor tells its own save apart from someone else's edit by comparing
|
||||
/// against a `disk_mtime` baseline. If that baseline came from a `stat` issued
|
||||
/// *after* the write, an edit landing in the gap would be stamped as ours and
|
||||
/// the editor would never report it — a silent lost-update, and the user never
|
||||
/// gets the conflict prompt. So the write has to answer for itself.
|
||||
pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let f = h.join(sb.path(), "baseline.txt");
|
||||
let wrote = h.write_file(&f, b"mine").unwrap();
|
||||
@@ -578,9 +422,6 @@ pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!wrote.is_dir);
|
||||
assert!(!wrote.is_symlink);
|
||||
|
||||
// A later external write moves the mtime forward; the value we recorded is
|
||||
// still the one describing *our* bytes, which is what makes the comparison
|
||||
// meaningful.
|
||||
let after = h.write_file(&f, b"theirs, longer").unwrap();
|
||||
assert_eq!(after.len, 14);
|
||||
assert_ne!(
|
||||
@@ -589,8 +430,6 @@ pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing parent is an error and stays missing. Silently creating it would
|
||||
/// turn a typo in a save dialog into a directory tree nobody asked for.
|
||||
pub fn write_file_to_missing_parent_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let parent = h.join(sandbox, "no-such-dir");
|
||||
@@ -600,8 +439,6 @@ pub fn write_file_to_missing_parent_errors(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!h.exists(&parent), "the parent must not have been created");
|
||||
}
|
||||
|
||||
/// Exclusive creation: the file tree's "new file" row must not silently
|
||||
/// truncate a file that is already there.
|
||||
pub fn create_file_new_rejects_existing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "fresh.txt");
|
||||
@@ -618,8 +455,6 @@ pub fn create_file_new_rejects_existing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Without `recursive`, a missing parent is an error rather than an implicit
|
||||
/// `mkdir -p`.
|
||||
pub fn create_dir_non_recursive_needs_parent(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let deep = h.join(&h.join(sandbox, "a"), "b");
|
||||
@@ -631,13 +466,10 @@ pub fn create_dir_non_recursive_needs_parent(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
h.create_dir(&deep, false).unwrap();
|
||||
assert!(h.stat(&deep).unwrap().is_dir);
|
||||
|
||||
// And a second non-recursive create of the same directory is a conflict.
|
||||
let err = h.create_dir(&a, false).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists, "{err}");
|
||||
}
|
||||
|
||||
/// With `recursive`, the whole chain appears at once and an existing directory
|
||||
/// is success — the `mkdir -p` semantics the worktree setup depends on.
|
||||
pub fn create_dir_recursive_makes_chain(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let deep = h.join(&h.join(&h.join(sandbox, "x"), "y"), "z");
|
||||
@@ -647,11 +479,6 @@ pub fn create_dir_recursive_makes_chain(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(h.stat(&deep).unwrap().is_dir);
|
||||
}
|
||||
|
||||
/// An occupied destination is `AlreadyExists`, guaranteed by the host.
|
||||
///
|
||||
/// This is the case that keeps the file tree's inline rename from needing an
|
||||
/// `exists` probe first: the probe would be an extra round trip *and* racy, and
|
||||
/// on Unix a bare `rename(2)` would have silently destroyed the other file.
|
||||
pub fn rename_moves_and_rejects_existing_target(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let a = h.join(sandbox, "a.txt");
|
||||
@@ -669,8 +496,6 @@ pub fn rename_moves_and_rejects_existing_target(h: &dyn Host, sb: &dyn Sandbox)
|
||||
assert!(h.exists(&c), "source untouched");
|
||||
}
|
||||
|
||||
/// Moving between directories on the same host works — a drag in the tree is
|
||||
/// this call.
|
||||
pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let from_dir = h.join(sandbox, "from");
|
||||
@@ -685,7 +510,6 @@ pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!h.exists(&src));
|
||||
assert_eq!(h.read_file(&dst, 64).unwrap(), b"moved");
|
||||
|
||||
// Directories move too.
|
||||
let sub = h.join(&from_dir, "sub");
|
||||
mkdir(h, &sub);
|
||||
let sub_dst = h.join(&to_dir, "sub");
|
||||
@@ -693,8 +517,6 @@ pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(h.stat(&sub_dst).unwrap().is_dir);
|
||||
}
|
||||
|
||||
/// Deleting twice is `NotFound` the second time, so the tree's optimistic row
|
||||
/// removal can tell "already gone" from "could not delete".
|
||||
pub fn remove_file_then_missing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let f = h.join(sandbox, "doomed.txt");
|
||||
@@ -705,8 +527,6 @@ pub fn remove_file_then_missing(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}");
|
||||
}
|
||||
|
||||
/// A non-empty directory needs `recursive`. Without it the host refuses, which
|
||||
/// is what lets a delete confirm before it destroys a subtree.
|
||||
pub fn remove_dir_non_recursive_needs_empty(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let d = h.join(sandbox, "full");
|
||||
@@ -716,15 +536,12 @@ pub fn remove_dir_non_recursive_needs_empty(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(err.kind(), io::ErrorKind::DirectoryNotEmpty, "{err}");
|
||||
assert!(h.exists(&d));
|
||||
|
||||
// Empty, it goes.
|
||||
let empty = h.join(sandbox, "empty");
|
||||
mkdir(h, &empty);
|
||||
h.remove(&empty, false).unwrap();
|
||||
assert!(!h.exists(&empty));
|
||||
}
|
||||
|
||||
/// With `recursive`, a whole tree goes in one call rather than one round trip
|
||||
/// per file.
|
||||
pub fn remove_dir_recursive_clears_tree(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let root = h.join(sandbox, "tree");
|
||||
@@ -737,19 +554,10 @@ pub fn remove_dir_recursive_clears_tree(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!h.exists(&root));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// git
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The nearest ancestor with a `.git`, found in one call rather than one round
|
||||
/// trip per level — and `Ok(None)`, not an error, outside any repository.
|
||||
pub fn repo_root_finds_nearest_git(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let outside = h.join(sandbox, "outside");
|
||||
mkdir(h, &outside);
|
||||
// The sandbox itself may sit inside somebody's repository (a checkout under
|
||||
// a repo-shaped temp dir), so the "outside" assertion is only meaningful
|
||||
// when the sandbox is genuinely outside one.
|
||||
let sandbox_root = h.repo_root(sandbox).unwrap();
|
||||
if sandbox_root.is_none() {
|
||||
assert_eq!(h.repo_root(&outside).unwrap(), None, "no repo, no root");
|
||||
@@ -763,8 +571,6 @@ pub fn repo_root_finds_nearest_git(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(h.repo_root(&repo).unwrap(), Some(repo));
|
||||
}
|
||||
|
||||
/// A linked worktree's `.git` is a *file*, not a directory. A root probe that
|
||||
/// only looked for directories would treat every worktree as "not a repo".
|
||||
pub fn repo_root_handles_worktree_file(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let wt = h.join(sandbox, "worktree");
|
||||
@@ -779,8 +585,6 @@ pub fn repo_root_handles_worktree_file(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(h.repo_root(&deep).unwrap(), Some(wt));
|
||||
}
|
||||
|
||||
/// A real `git` invocation against a real repository: the sidebar's status line
|
||||
/// is this call, and it has to see a change the host just made.
|
||||
pub fn git_status_porcelain_reflects_changes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let repo = h.join(sandbox, "repo");
|
||||
@@ -797,24 +601,14 @@ pub fn git_status_porcelain_reflects_changes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The load-bearing one.** A non-zero exit is `Ok`, with the code in
|
||||
/// `Output::status`.
|
||||
///
|
||||
/// Everything downstream is built on this split: `Err` means git never ran, so a
|
||||
/// caller can keep the previous status instead of blanking it, while an exit
|
||||
/// 128 is just git's answer to a question about a directory that isn't a repo.
|
||||
/// Collapse the two and the sidebar starts showing errors for ordinary states.
|
||||
pub fn git_nonzero_exit_is_ok_not_err(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let plain = h.join(sandbox, "not-a-repo");
|
||||
mkdir(h, &plain);
|
||||
let out = match h.git(&plain, &["rev-parse", "--show-toplevel"]) {
|
||||
Ok(out) => out,
|
||||
// No git on this host at all: nothing to assert about exit codes.
|
||||
Err(_) => return,
|
||||
};
|
||||
// If the sandbox happens to live inside a repository, git succeeds — then
|
||||
// the case has nothing to say, and saying it anyway would be a false red.
|
||||
if out.success() {
|
||||
return;
|
||||
}
|
||||
@@ -828,12 +622,6 @@ pub fn git_nonzero_exit_is_ok_not_err(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Err` is reserved for "it could not run". A `cwd` that does not exist is
|
||||
/// exactly that: the question was never about the repository.
|
||||
///
|
||||
/// (The other way to reach `Err` — no `git` on `PATH` — cannot be provoked
|
||||
/// in-process without mutating the environment out from under every other test
|
||||
/// in the binary, so this is the deterministic half of that contract.)
|
||||
pub fn git_that_cannot_run_is_err(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let gone = h.join(sandbox, "no-such-directory");
|
||||
@@ -841,12 +629,6 @@ pub fn git_that_cannot_run_is_err(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}");
|
||||
}
|
||||
|
||||
/// `GIT_OPTIONAL_LOCKS=0` reaches the git process.
|
||||
///
|
||||
/// Probed through a `!`-alias, which git runs in a shell that inherits git's own
|
||||
/// environment — the only way to observe the variable without mutating this
|
||||
/// process's `PATH`. Without it, every background status probe can take
|
||||
/// `index.lock` and lose a race against a git command the user is running.
|
||||
pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let repo = h.join(sandbox, "repo");
|
||||
@@ -868,8 +650,6 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let Ok(out) = h.git(&repo, &["tty7probe"]) else {
|
||||
return;
|
||||
};
|
||||
// A host without a shell for `!`-aliases (some Windows layouts) cannot run
|
||||
// the probe; that is an environment limit, not a conformance failure.
|
||||
if !out.success() {
|
||||
return;
|
||||
}
|
||||
@@ -880,13 +660,6 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// git's stdin is closed, so a subcommand that reads it gets EOF immediately
|
||||
/// instead of blocking a background thread forever on a terminal nobody is
|
||||
/// attached to.
|
||||
///
|
||||
/// Hard-bounded: if the invariant is broken the call hangs, and a hung test that
|
||||
/// eventually times out the whole suite is a far worse failure report than a
|
||||
/// named assertion.
|
||||
pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let repo = h.join(sandbox, "repo");
|
||||
@@ -895,27 +668,16 @@ pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
// `stripspace` reads stdin to EOF and writes it out. With stdin
|
||||
// nulled it returns instantly and empty; with stdin inherited from
|
||||
// an interactive terminal it never returns at all.
|
||||
let _ = tx.send(h.git(&repo, &["stripspace"]).map(|o| o.stdout));
|
||||
});
|
||||
match rx.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(Ok(stdout)) => assert!(stdout.is_empty(), "stripspace read something from stdin"),
|
||||
// No git here: nothing to assert.
|
||||
Ok(Err(_)) => {}
|
||||
Err(_) => panic!("git blocked on stdin — it must be nulled"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path arithmetic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `join` uses the *host's* separator, not the client's. On a Windows client
|
||||
/// talking to Linux, `PathBuf::join` would produce `/home/me\src`, which the
|
||||
/// remote has never heard of.
|
||||
pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sep = h.separator();
|
||||
@@ -927,7 +689,6 @@ pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
"{text} should extend {}",
|
||||
sandbox.display()
|
||||
);
|
||||
// Joining twice is joining a path, not concatenating two roots.
|
||||
let deep = h.join(&joined, "grand");
|
||||
assert!(
|
||||
deep.to_string_lossy()
|
||||
@@ -935,9 +696,6 @@ pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Absoluteness is the host's judgement. A Windows client asked about
|
||||
/// `/home/me` would say "relative" — which would send every remote path down
|
||||
/// the wrong branch of every call site that checks.
|
||||
pub fn is_absolute_matches_host_semantics(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
assert!(
|
||||
@@ -950,11 +708,6 @@ pub fn is_absolute_matches_host_semantics(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(!h.is_absolute(Path::new("child.txt")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Breadth-first: the shallow hit is the one you meant, so it comes first.
|
||||
pub fn search_is_breadth_first(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(h, &h.join(sandbox, "target-top.txt"), "");
|
||||
@@ -972,9 +725,6 @@ pub fn search_is_breadth_first(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert!(top < deep_pos, "shallow before deep: {names:?}");
|
||||
}
|
||||
|
||||
/// Ignored directories are not walked at all. `node_modules` and `target` are
|
||||
/// where the file count explodes and never where anyone is searching — walking
|
||||
/// them would burn the whole directory budget before reaching real code.
|
||||
pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(h, &h.join(sandbox, ".gitignore"), "node_modules/\n");
|
||||
@@ -993,7 +743,6 @@ pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
hit_names(&hits)
|
||||
);
|
||||
|
||||
// With hidden/ignored shown, the walk does go in — the flag is the switch.
|
||||
let hits = h
|
||||
.search(&[sandbox.to_path_buf()], "needle", 100, 2000, true)
|
||||
.unwrap();
|
||||
@@ -1002,7 +751,6 @@ pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(names, vec!["needle.js", "needle.rs"]);
|
||||
}
|
||||
|
||||
/// `limit` stops the walk, so a query like "e" cannot crawl a monorepo.
|
||||
pub fn search_respects_limit(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
for i in 0..10 {
|
||||
@@ -1014,12 +762,8 @@ pub fn search_respects_limit(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(hits.len(), 3, "{:?}", hit_names(&hits));
|
||||
}
|
||||
|
||||
/// `max_dirs` bounds the walk even when nothing matches, so a typo cannot turn
|
||||
/// into a full-disk crawl.
|
||||
pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
// A chain deep enough that visiting it all would be obvious, with the only
|
||||
// match at the bottom.
|
||||
let mut dir = sandbox.to_path_buf();
|
||||
for i in 0..12 {
|
||||
dir = h.join(&dir, &format!("d{i}"));
|
||||
@@ -1036,25 +780,12 @@ pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
hit_names(&hits)
|
||||
);
|
||||
|
||||
// With room, it is found — proving the fixture, not just the bound.
|
||||
let hits = h
|
||||
.search(&[sandbox.to_path_buf()], "needle", 100, 2000, false)
|
||||
.unwrap();
|
||||
assert_eq!(hit_names(&hits), vec!["needle.txt"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// machine inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every row of the new-tab dropdown is launchable and labelled, and the menu
|
||||
/// knows which one is the default.
|
||||
///
|
||||
/// Deliberately not "the list is non-empty": a host with no shell registered
|
||||
/// anywhere is a strange machine, not a broken `Host` implementation. What the
|
||||
/// dropdown cannot survive is a blank row, a row with nothing to spawn, or two
|
||||
/// rows with the same name — the dedupe the local probe does is part of the
|
||||
/// contract, not an implementation detail of `/etc/shells` parsing.
|
||||
pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) {
|
||||
let inv = h.shells().expect("a host can list its shells");
|
||||
assert!(
|
||||
@@ -1077,11 +808,6 @@ pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// watch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Creating and deleting a file in a watched directory both surface.
|
||||
pub fn watch_reports_create_and_delete(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sub = h.watch(&[sandbox.to_path_buf()]).unwrap();
|
||||
@@ -1102,9 +828,6 @@ pub fn watch_reports_create_and_delete(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-recursive, always. The tree watches the directories it has expanded; a
|
||||
/// recursive watch on a repository root would report every file a build touches
|
||||
/// and repaint the sidebar continuously.
|
||||
pub fn watch_is_non_recursive(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sub_dir = h.join(sandbox, "child");
|
||||
@@ -1127,9 +850,6 @@ pub fn watch_is_non_recursive(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The watched set is replaceable in place — the file tree changes it on every
|
||||
/// expand, and rebuilding the subscription each time would cost a round trip
|
||||
/// and a fresh server-side watcher per disclosure triangle.
|
||||
pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let a = h.join(sandbox, "a");
|
||||
@@ -1143,14 +863,12 @@ pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
sub.set_dirs(&[b.clone()]).unwrap();
|
||||
drain(&sub);
|
||||
|
||||
// The newly watched directory reports.
|
||||
write(h, &h.join(&b, "in-b.txt"), "x");
|
||||
assert!(
|
||||
await_event(&sub, "in-b.txt").is_some(),
|
||||
"the added directory should report"
|
||||
);
|
||||
|
||||
// The dropped one does not.
|
||||
drain(&sub);
|
||||
write(h, &h.join(&a, "in-a.txt"), "x");
|
||||
std::thread::sleep(WATCH_QUIET);
|
||||
@@ -1166,9 +884,6 @@ pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
);
|
||||
}
|
||||
|
||||
/// A burst becomes a batch. Fifty writes arrive as a handful of deduplicated
|
||||
/// batches, not fifty repaints — and identically on every host, so where the
|
||||
/// files live cannot change how busy the UI looks.
|
||||
pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sub = h.watch(&[sandbox.to_path_buf()]).unwrap();
|
||||
@@ -1181,7 +896,6 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
}
|
||||
let burst = started.elapsed();
|
||||
|
||||
// Give the window time to close, plus slack for a loaded machine.
|
||||
let batches = collect_batches(&sub, Duration::from_secs(2));
|
||||
let with_file: Vec<&Vec<PathBuf>> = batches
|
||||
.iter()
|
||||
@@ -1191,11 +905,6 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
})
|
||||
.collect();
|
||||
assert!(!with_file.is_empty(), "the burst produced no events at all");
|
||||
// The guarantee is one batch per window, not a fixed batch count. A loaded
|
||||
// runner can spend well over a window just issuing the writes, and a burst
|
||||
// spread over N windows is *allowed* to arrive as N batches — bounding by a
|
||||
// constant would be testing how fast the machine writes files, not whether
|
||||
// the coalescer coalesces.
|
||||
let windows = burst
|
||||
.as_millis()
|
||||
.div_ceil(WATCH_COALESCE_WINDOW.as_millis())
|
||||
@@ -1216,13 +925,10 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping the subscription unsubscribes — the watcher goes away rather than
|
||||
/// living on and (remotely) leaking a server-side watch per expanded directory.
|
||||
pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sub = h.watch(&[sandbox.to_path_buf()]).unwrap();
|
||||
drain(&sub);
|
||||
// Keep the receiving end so we can prove nothing arrives after the drop.
|
||||
let rx = sub.events().clone();
|
||||
drop(sub);
|
||||
|
||||
@@ -1231,8 +937,6 @@ pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
// A batch already in flight when the drop happened is fine; a batch
|
||||
// describing the change made *after* it is not.
|
||||
Ok(batch) => assert!(
|
||||
!batch
|
||||
.iter()
|
||||
@@ -1245,23 +949,13 @@ pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connection semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A host handed to a test is a working host. (Trivially true locally; the
|
||||
/// point is that a remote one has to agree, so call sites can trust the flag to
|
||||
/// mean "showing stale data is the right move".)
|
||||
pub fn is_connected_is_true_when_healthy(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
assert!(h.is_connected());
|
||||
// And it stays true across real work.
|
||||
let _ = h.read_dir(sandbox, None).unwrap();
|
||||
assert!(h.is_connected());
|
||||
}
|
||||
|
||||
/// The id never changes under a live host. Caches key on it; a shifting id would
|
||||
/// silently orphan every entry they hold.
|
||||
pub fn id_is_stable_across_calls(h: &dyn Host, _sb: &dyn Sandbox) {
|
||||
let first = h.id();
|
||||
for _ in 0..8 {
|
||||
@@ -1269,9 +963,6 @@ pub fn id_is_stable_across_calls(h: &dyn Host, _sb: &dyn Sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The separator is stable and is the one `join` actually uses — for a remote
|
||||
/// host it comes from the handshake, so a mismatch here means every path the
|
||||
/// client builds is wrong.
|
||||
pub fn separator_matches_hello(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let sep = h.separator();
|
||||
@@ -1286,11 +977,6 @@ pub fn separator_matches_hello(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
mod tests {
|
||||
use super::CASES;
|
||||
|
||||
/// Every `pub fn` case in this file appears in the registry.
|
||||
///
|
||||
/// The failure mode this guards is silent: write a case, forget the list
|
||||
/// entry, and it simply never runs — a green suite that tests one behaviour
|
||||
/// less than it claims to.
|
||||
#[test]
|
||||
fn every_case_is_registered() {
|
||||
let src = include_str!("conformance.rs");
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
//! [`LocalHost`] — the [`Host`] that answers with `std::fs` and a `git`
|
||||
//! subprocess.
|
||||
//!
|
||||
//! This is the 99% path: every workspace whose files are on this machine holds
|
||||
//! one, and so does the `tty7-server` process serving a *remote* workspace to
|
||||
//! someone else's client. That second role is why it is written the way it is —
|
||||
//! blocking, allocation-frugal, and with every semantic decision (sort order,
|
||||
//! gitignore scoring, the search walk's bounds) made *here* rather than by the
|
||||
//! caller, so that a remote workspace gets byte-identical answers to a local
|
||||
//! one without the client and the server having to agree on anything but the
|
||||
//! wire.
|
||||
//!
|
||||
//! Two pieces of state, both about not repeating work:
|
||||
//!
|
||||
//! - the compiled `.gitignore` matchers, shared across every listing rather
|
||||
//! than shuttled to a worker and back the way the file tree used to before a
|
||||
//! host existed to own them;
|
||||
//! - nothing else. A host is otherwise a pure function of the filesystem.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -33,27 +14,13 @@ use crate::host::{
|
||||
WatchSub, guard_off_ui,
|
||||
};
|
||||
|
||||
/// How long changes are collected before a batch is delivered. Matched exactly
|
||||
/// by the remote implementation — see [`WatchSub::events`].
|
||||
const COALESCE_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// This machine's filesystem and git.
|
||||
pub struct LocalHost {
|
||||
/// Compiled `.gitignore` matchers, keyed by the directory each came from.
|
||||
///
|
||||
/// Behind an `Arc` as well as a `Mutex` so the watcher's coalescing thread
|
||||
/// can hold the same cache and clear it when a `.gitignore` is edited —
|
||||
/// which is the only thing that can invalidate a compiled matcher, and the
|
||||
/// watcher is the only place that finds out.
|
||||
gitignore: Arc<Mutex<GitignoreChain>>,
|
||||
}
|
||||
|
||||
impl LocalHost {
|
||||
/// A new local host.
|
||||
///
|
||||
/// Returns the trait object directly: nothing in the tree wants a concrete
|
||||
/// `LocalHost`, and handing one out would invite a call site to depend on
|
||||
/// something a remote host cannot do.
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new() -> SharedHost {
|
||||
Arc::new(LocalHost {
|
||||
@@ -61,36 +28,19 @@ impl LocalHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// The process-wide local host.
|
||||
///
|
||||
/// One instance, so the gitignore cache is shared by every local workspace
|
||||
/// instead of being recompiled per tab. Workspaces take their host from
|
||||
/// here rather than constructing their own.
|
||||
pub fn shared() -> SharedHost {
|
||||
static LOCAL: OnceLock<SharedHost> = OnceLock::new();
|
||||
LOCAL.get_or_init(LocalHost::new).clone()
|
||||
}
|
||||
|
||||
/// List `dir`, keeping each entry's full path — the shape `search` needs
|
||||
/// and `read_dir` throws away.
|
||||
fn list(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<(Entry, PathBuf)>> {
|
||||
// Two passes, because the second needs a lock the first must not hold.
|
||||
// The file tree asks for every root and every expanded directory in one
|
||||
// frame, so a dozen of these run at once; holding the shared matcher
|
||||
// cache across the `readdir` syscalls would serialize work that has no
|
||||
// reason to be serial.
|
||||
let mut out: Vec<(Entry, PathBuf)> = Vec::new();
|
||||
for e in fs::read_dir(dir)?.flatten() {
|
||||
let path = e.path();
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
// `DirEntry::file_type` is free on Unix (it comes out of `readdir`)
|
||||
// but does *not* follow links, and a link to a directory has to
|
||||
// read as a directory — that is what the tree expands and what the
|
||||
// sort puts first. So pay for the follow only on links.
|
||||
let ft = e.file_type().ok();
|
||||
let is_symlink = ft.is_some_and(|t| t.is_symlink());
|
||||
let is_dir = if is_symlink {
|
||||
// A broken link resolves to nothing: not a directory.
|
||||
fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false)
|
||||
} else {
|
||||
ft.is_some_and(|t| t.is_dir())
|
||||
@@ -106,8 +56,6 @@ impl LocalHost {
|
||||
));
|
||||
}
|
||||
|
||||
// `.git` is ignored whatever the patterns say; everything else is scored
|
||||
// against the chain, and only when there is a root to bound it.
|
||||
let mut chain = self.gitignore.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (entry, path) in &mut out {
|
||||
entry.ignored = entry.name == ".git"
|
||||
@@ -120,11 +68,6 @@ impl LocalHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories first, then case-insensitive by name.
|
||||
///
|
||||
/// Dotfiles keep their leading dot in that ordering, so they sort before
|
||||
/// letters — which is where users expect them, and what the file tree has
|
||||
/// always done.
|
||||
fn sort_entries(entries: &mut [(Entry, PathBuf)]) {
|
||||
entries.sort_by(|(a, _), (b, _)| {
|
||||
b.is_dir
|
||||
@@ -143,8 +86,6 @@ impl Host for LocalHost {
|
||||
}
|
||||
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
// Native semantics locally: `Path::join` already knows this platform's
|
||||
// rules, including the ones a separator alone doesn't capture.
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
@@ -159,9 +100,6 @@ impl Host for LocalHost {
|
||||
|
||||
fn stat(&self, p: &Path) -> io::Result<Meta> {
|
||||
guard_off_ui();
|
||||
// `symlink_metadata` first: it answers `is_symlink` and, for the
|
||||
// overwhelmingly common non-link, is also the answer — one syscall
|
||||
// instead of two.
|
||||
let lmd = fs::symlink_metadata(p)?;
|
||||
let is_symlink = lmd.file_type().is_symlink();
|
||||
let md = if is_symlink { fs::metadata(p)? } else { lmd };
|
||||
@@ -183,8 +121,6 @@ impl Host for LocalHost {
|
||||
format!("{} is a directory", p.display()),
|
||||
));
|
||||
}
|
||||
// Checked before reading, not after: the whole point of the limit is
|
||||
// that an oversized file is never carried anywhere.
|
||||
if md.len() > max_bytes {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::FileTooLarge,
|
||||
@@ -214,28 +150,18 @@ impl Host for LocalHost {
|
||||
guard_off_ui();
|
||||
let needle = query.to_lowercase();
|
||||
let mut out: Vec<SearchHit> = Vec::new();
|
||||
// Shared across roots: the budget bounds the *search*, not each root,
|
||||
// so a workspace with six roots cannot walk six times as far.
|
||||
let mut visited = 0usize;
|
||||
for root in roots {
|
||||
// A deque, not a `Vec` with `remove(0)`: the frontier of a wide tree
|
||||
// gets long and shifting it down per pop is quadratic.
|
||||
let mut queue: VecDeque<PathBuf> = VecDeque::from([root.clone()]);
|
||||
while let Some(dir) = queue.pop_front() {
|
||||
if out.len() >= limit || visited >= max_dirs {
|
||||
break;
|
||||
}
|
||||
visited += 1;
|
||||
// An unreadable directory is skipped, not fatal — a search that
|
||||
// aborted on the first permission-denied subdirectory would be
|
||||
// useless on any real machine.
|
||||
let Ok(entries) = self.list(&dir, Some(root)) else {
|
||||
continue;
|
||||
};
|
||||
for (e, path) in entries {
|
||||
// `.git`, `target`, `node_modules`: where the file count
|
||||
// explodes and never where anyone is searching. Skipping
|
||||
// them is what keeps the directory budget meaningful.
|
||||
if !show_hidden && (e.ignored || e.name.starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
@@ -262,10 +188,6 @@ impl Host for LocalHost {
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
guard_off_ui();
|
||||
fs::write(p, bytes)?;
|
||||
// Stat immediately after, on the same thread that just wrote: the
|
||||
// remote peer answers from the same place, so both hosts report the
|
||||
// metadata the write itself produced rather than whatever a later
|
||||
// caller happens to observe.
|
||||
self.stat(p)
|
||||
}
|
||||
|
||||
@@ -285,10 +207,6 @@ impl Host for LocalHost {
|
||||
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
// `fs::rename` overwrites silently on Unix, and this API promises it
|
||||
// doesn't. `symlink_metadata` rather than `exists` so a dangling
|
||||
// symlink at the destination still counts as occupied — clobbering one
|
||||
// would destroy a link the user can see in the tree.
|
||||
if fs::symlink_metadata(to).is_ok() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
@@ -300,9 +218,6 @@ impl Host for LocalHost {
|
||||
|
||||
fn remove(&self, p: &Path, recursive: bool) -> io::Result<()> {
|
||||
guard_off_ui();
|
||||
// `symlink_metadata`, so a symlink pointing at a directory is unlinked
|
||||
// rather than recursed into — deleting a link must never delete what it
|
||||
// points at.
|
||||
let md = fs::symlink_metadata(p)?;
|
||||
if md.is_dir() {
|
||||
if recursive {
|
||||
@@ -317,8 +232,6 @@ impl Host for LocalHost {
|
||||
|
||||
fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>> {
|
||||
guard_off_ui();
|
||||
// `.git` is a directory in a normal checkout and a *file* in a linked
|
||||
// worktree, so test for existence rather than for a directory.
|
||||
Ok(p.ancestors()
|
||||
.find(|a| a.join(".git").exists())
|
||||
.map(Path::to_path_buf))
|
||||
@@ -329,8 +242,6 @@ impl Host for LocalHost {
|
||||
git::git_output(cwd, args)
|
||||
}
|
||||
|
||||
/// Straight off the pipe: this machine's git writes into a buffer we drain
|
||||
/// as it fills, so a multi-megabyte diff never exists as one allocation.
|
||||
fn git_lines(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
@@ -358,34 +269,13 @@ impl Host for LocalHost {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The watched set, in both the form the caller gave and the form the platform
|
||||
/// reports events in.
|
||||
///
|
||||
/// macOS' FSEvents canonicalizes: a watch on `/var/folders/…` reports
|
||||
/// `/private/var/folders/…`. Without the second form every event would look
|
||||
/// like it came from somewhere unwatched; without the first, callers would get
|
||||
/// back paths they cannot match against the ones they asked about. So both are
|
||||
/// kept and events are rewritten into the caller's vocabulary on the way out.
|
||||
#[derive(Default)]
|
||||
struct WatchedDirs {
|
||||
/// Canonical form → the form the caller used.
|
||||
by_canonical: HashMap<PathBuf, PathBuf>,
|
||||
/// Exactly what the caller asked for, for `set_dirs` diffing.
|
||||
given: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl WatchedDirs {
|
||||
/// The caller-facing path for an event on `p`, or `None` when `p` is not in
|
||||
/// (or directly under) a watched directory.
|
||||
///
|
||||
/// This filter is what makes the subscription non-recursive regardless of
|
||||
/// backend: FSEvents is inherently recursive and notify only filters on a
|
||||
/// best-effort basis, so the guarantee is enforced here rather than
|
||||
/// assumed.
|
||||
fn translate(&self, p: &Path) -> Option<PathBuf> {
|
||||
if let Some(parent) = p.parent()
|
||||
&& let Some(given) = self.by_canonical.get(parent)
|
||||
@@ -395,28 +285,12 @@ impl WatchedDirs {
|
||||
None => Some(given.clone()),
|
||||
};
|
||||
}
|
||||
// The watched directory itself (created, removed, renamed).
|
||||
self.by_canonical.get(p).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A live local watch: the notify watcher plus the set it is following.
|
||||
struct LocalWatch {
|
||||
inner: Mutex<LocalWatchInner>,
|
||||
/// The delivery end, kept solely so dropping this handle can close it.
|
||||
///
|
||||
/// Tearing the watcher down is not instantaneous — the OS backend has its
|
||||
/// own thread, and on Windows a `ReadDirectoryChangesW` completion can fire
|
||||
/// *during* teardown, reach the event closure while `raw_tx` is still
|
||||
/// alive, and be forwarded by a coalescer that has not noticed the
|
||||
/// disconnect yet. A consumer holding a clone of the receiver would then
|
||||
/// see an event for a change made after it unsubscribed.
|
||||
///
|
||||
/// Closing the channel here makes "dropped" mean "no further batches" at
|
||||
/// the instant of the drop, whatever the backend does afterwards. Batches
|
||||
/// already queued stay readable — `close` stops sends, not receives — which
|
||||
/// is the one thing a consumer racing its own drop may legitimately still
|
||||
/// see.
|
||||
batch_tx: smol::channel::Sender<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
@@ -442,14 +316,8 @@ impl WatchHandle for LocalWatch {
|
||||
let _ = watcher.unwatch(gone);
|
||||
}
|
||||
let added: Vec<PathBuf> = want.difference(&set.given).cloned().collect();
|
||||
// Rebuild rather than patch: `by_canonical` is keyed by a form we do not
|
||||
// hold the inverse of, and the set is at most a few dozen expanded
|
||||
// directories.
|
||||
set.by_canonical.clear();
|
||||
for d in &added {
|
||||
// A directory that has just been deleted is not an error worth
|
||||
// failing the whole re-subscription over — the next listing will
|
||||
// notice it is gone.
|
||||
let _ = watcher.watch(d, RecursiveMode::NonRecursive);
|
||||
}
|
||||
for d in &want {
|
||||
@@ -462,12 +330,6 @@ impl WatchHandle for LocalWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a watch over `dirs`, coalescing events into 100ms batches.
|
||||
///
|
||||
/// `gitignore` is cleared whenever a batch contains a `.gitignore`, which is the
|
||||
/// only event that can invalidate a compiled matcher. Doing it here rather than
|
||||
/// asking callers to remember means a remote client gets the same invalidation
|
||||
/// for free: the server's own host is the one watching.
|
||||
fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::Result<WatchSub> {
|
||||
let (raw_tx, raw_rx) = std::sync::mpsc::channel::<Vec<PathBuf>>();
|
||||
let (batch_tx, batch_rx) = smol::channel::unbounded::<Vec<PathBuf>>();
|
||||
@@ -477,8 +339,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R
|
||||
if let Ok(ev) = res
|
||||
&& !ev.paths.is_empty()
|
||||
{
|
||||
// A closed receiver means the subscription was dropped; the watcher
|
||||
// is on its way out too, so there is nothing to report.
|
||||
let _ = raw_tx.send(ev.paths);
|
||||
}
|
||||
})
|
||||
@@ -493,8 +353,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R
|
||||
};
|
||||
handle.set_dirs(dirs)?;
|
||||
|
||||
// The coalescer. It ends when the watcher is dropped: that drops the event
|
||||
// closure, which drops `raw_tx`, which disconnects this receiver.
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-host-watch".into())
|
||||
.spawn(move || coalesce(raw_rx, batch_tx, watched, gitignore))
|
||||
@@ -503,11 +361,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R
|
||||
Ok(WatchSub::new(batch_rx, Box::new(handle)))
|
||||
}
|
||||
|
||||
/// Collect raw events into deduplicated 100ms batches.
|
||||
///
|
||||
/// The window exists so that a `cargo build` touching ten thousand files is one
|
||||
/// repaint rather than ten thousand, and it is identical on the remote side so
|
||||
/// that where the files live cannot change how the tree behaves.
|
||||
fn coalesce(
|
||||
raw_rx: std::sync::mpsc::Receiver<Vec<PathBuf>>,
|
||||
batch_tx: smol::channel::Sender<Vec<PathBuf>>,
|
||||
@@ -515,7 +368,6 @@ fn coalesce(
|
||||
gitignore: Arc<Mutex<GitignoreChain>>,
|
||||
) {
|
||||
loop {
|
||||
// Block until something happens at all — an idle watch costs nothing.
|
||||
let Ok(first) = raw_rx.recv() else { return };
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
let mut batch: Vec<PathBuf> = Vec::new();
|
||||
@@ -539,7 +391,6 @@ fn coalesce(
|
||||
match raw_rx.recv_timeout(left) {
|
||||
Ok(paths) => take(paths, &mut batch, &mut seen),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
|
||||
// Watcher gone: deliver what we have, then stop.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if !batch.is_empty() {
|
||||
let _ = batch_tx.send_blocking(batch);
|
||||
@@ -552,9 +403,6 @@ fn coalesce(
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A `.gitignore` edit changes the answer for every path under it, so the
|
||||
// compiled matchers all go. Cheap: they recompile lazily, per directory,
|
||||
// on the next listing that needs one.
|
||||
if batch
|
||||
.iter()
|
||||
.any(|p| p.file_name().is_some_and(|n| n == ".gitignore"))
|
||||
@@ -567,8 +415,6 @@ fn coalesce(
|
||||
}
|
||||
}
|
||||
|
||||
/// notify's error type carries an `io::Error` for the cases that have one; the
|
||||
/// rest become `Other` with the message preserved.
|
||||
fn notify_to_io(e: notify::Error) -> io::Error {
|
||||
match e.kind {
|
||||
notify::ErrorKind::Io(io) => io,
|
||||
@@ -581,7 +427,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::host::conformance::Sandbox;
|
||||
|
||||
/// A temp directory that satisfies the conformance sandbox contract.
|
||||
struct TempSandbox(tempfile::TempDir);
|
||||
|
||||
impl Sandbox for TempSandbox {
|
||||
@@ -596,8 +441,6 @@ mod tests {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Windows needs a privilege we cannot assume in CI; the cases
|
||||
// that want a symlink skip instead of failing.
|
||||
let _ = (target, link);
|
||||
None
|
||||
}
|
||||
@@ -611,14 +454,8 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
// Every case in the shared suite, run against `LocalHost`. `RemoteHost` and
|
||||
// the stdio server run the identical list; a divergence between them is
|
||||
// exactly what this exists to catch.
|
||||
crate::host_conformance_suite!(local, sandbox);
|
||||
|
||||
/// The sort is the file tree's, verbatim: directories first, then
|
||||
/// case-insensitively by name, with dotfiles keeping their leading dot (so
|
||||
/// `.gitignore` sorts before `main.rs`).
|
||||
#[test]
|
||||
fn sort_matches_the_file_trees_order() {
|
||||
let mut v: Vec<(Entry, PathBuf)> = ["main.rs", "Cargo.toml", ".gitignore", "src", "Zeta"]
|
||||
@@ -643,17 +480,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The process-wide host is one instance, so every local workspace shares
|
||||
/// the gitignore cache rather than recompiling per tab.
|
||||
#[test]
|
||||
fn shared_is_a_singleton() {
|
||||
assert!(Arc::ptr_eq(&LocalHost::shared(), &LocalHost::shared()));
|
||||
assert!(LocalHost::shared().id().is_local());
|
||||
}
|
||||
|
||||
/// The gitignore chain the file tree used to hand back and forth now lives
|
||||
/// in the host — and scores the same fixture the same way: deepest match
|
||||
/// wins, `!` un-ignores, `.git` is ignored whatever the patterns say.
|
||||
#[test]
|
||||
fn gitignore_chain_scores_the_file_tree_fixture() {
|
||||
let (h, tmp) = sandbox();
|
||||
@@ -684,8 +516,6 @@ mod tests {
|
||||
assert!(!ignored(&nested, "keep.log"), "whitelist un-ignores");
|
||||
assert!(!ignored(&nested, "main.rs"));
|
||||
|
||||
// And the search agrees with the listing: the ignored `.log` stays out,
|
||||
// the whitelisted one comes back.
|
||||
let hits = h
|
||||
.search(&[root.to_path_buf()], "log", 200, 2000, false)
|
||||
.unwrap();
|
||||
@@ -693,8 +523,6 @@ mod tests {
|
||||
assert_eq!(names, vec!["keep.log"]);
|
||||
}
|
||||
|
||||
/// Editing a `.gitignore` has to change the answer, and the only thing that
|
||||
/// finds out is the watcher — so the invalidation rides along with it.
|
||||
#[test]
|
||||
fn a_gitignore_edit_through_the_watcher_clears_the_cache() {
|
||||
let (h, tmp) = sandbox();
|
||||
@@ -706,11 +534,6 @@ mod tests {
|
||||
|
||||
let sub = h.watch(&[root.clone()]).unwrap();
|
||||
|
||||
// Poll rather than block on the channel, and re-write each round.
|
||||
// FSEvents registers asynchronously, so a write landing in the first
|
||||
// few milliseconds after `watch` can simply never be reported — and a
|
||||
// test that blocked waiting for that event would hang forever rather
|
||||
// than fail.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
let mut cleared = false;
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -1,40 +1,3 @@
|
||||
//! [`Host`]: the machine a workspace's files and git live on.
|
||||
//!
|
||||
//! Every filesystem read, every write, every `git` shell-out and every file
|
||||
//! watch tty7 performs on behalf of a workspace goes through this one trait, so
|
||||
//! that "the files are on this laptop" and "the files are on a box in another
|
||||
//! datacentre" differ by which `Arc<dyn Host>` the workspace holds and by
|
||||
//! nothing else. [`local::LocalHost`] is the implementation that answers with
|
||||
//! `std::fs`; `host::remote::RemoteHost` answers over the control connection;
|
||||
//! and both are checked against the same [`conformance`] suite, because a
|
||||
//! difference between them is a bug that only shows up on someone else's
|
||||
//! machine.
|
||||
//!
|
||||
//! # Blocking on purpose
|
||||
//!
|
||||
//! Every method blocks. That is a decision, not an oversight: the trait
|
||||
//! has to be
|
||||
//! object-safe because the whole tree holds `Arc<dyn Host>`, the server side
|
||||
//! serves these same calls from a blocking thread pool, and a GPUI
|
||||
//! `&mut Context<T>` cannot be held across an `.await` anyway — so making the
|
||||
//! trait async would box every `LocalHost::stat` (the 99% path) without saving
|
||||
//! a single call site from being restructured.
|
||||
//!
|
||||
//! The consequence is a rule: **no `Host` method may be called on the UI
|
||||
//! thread.** The GUI reaches a host only through `ui::host_ops::HostOps`, which
|
||||
//! does the `spawn` → `background_spawn` → `update` dance. [`guard_off_ui`]
|
||||
//! turns a violation into a debug-build panic at the call site rather than a
|
||||
//! dropped frame nobody can attribute.
|
||||
//!
|
||||
//! # Paths belong to the host, not to `std::path`
|
||||
//!
|
||||
//! A Windows client talking to a Linux host has to build `/home/me/src`, but
|
||||
//! `PathBuf::join` would give it `/home/me\src` and `Path::is_absolute` would
|
||||
//! call `/home/me` relative. So path arithmetic that crosses the boundary goes
|
||||
//! through [`Host::join`] and [`Host::is_absolute`], which answer with the
|
||||
//! *host's* semantics. `parent`, `file_name`, `starts_with` and friends are
|
||||
//! fine as-is — Windows' `std::path` already treats `/` as a separator.
|
||||
|
||||
pub mod conformance;
|
||||
pub mod local;
|
||||
pub mod remote;
|
||||
@@ -48,52 +11,22 @@ use std::thread::ThreadId;
|
||||
|
||||
pub use crate::core::shells::ShellInventory;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A stable, in-process identifier for one `Arc<dyn Host>`.
|
||||
///
|
||||
/// **Never persisted.** It exists so that structures which cannot hold an
|
||||
/// `Arc<dyn Host>` — the git-status cache's path tables, pane records, the
|
||||
/// in-flight maps — can still say *which* machine a path belongs to, and so
|
||||
/// that two identical paths on two different machines never collide in one map.
|
||||
///
|
||||
/// [`HostId::LOCAL`] is `0` and always means this machine. Remote ids are
|
||||
/// derived from the **connection**, not the workspace: several workspaces on
|
||||
/// one remote box share an id, matching the granularity at which the SSH
|
||||
/// connection itself is shared.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
|
||||
pub struct HostId(pub u64);
|
||||
|
||||
impl HostId {
|
||||
/// This machine. Reserved: no derivation ever produces it.
|
||||
pub const LOCAL: HostId = HostId(0);
|
||||
|
||||
/// Derive an id from a normalized connection key.
|
||||
///
|
||||
/// `key` must be the canonical connection string for the machine
|
||||
/// (`ssh-profile:<uuid>`, `ssh-alias:<alias>`, `ssh-direct:<user>@<host>:<port>`,
|
||||
/// `wsl:<distro>`) so that two references to the same box always hash the
|
||||
/// same. A hash of exactly `0` is bumped to `1`, because `0` is local's.
|
||||
pub fn from_connection_key(key: &str) -> HostId {
|
||||
let h = fnv1a64(key.as_bytes());
|
||||
HostId(if h == 0 { 1 } else { h })
|
||||
}
|
||||
|
||||
/// Whether this is [`HostId::LOCAL`].
|
||||
pub fn is_local(self) -> bool {
|
||||
self == HostId::LOCAL
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic 64-bit FNV-1a — the same one `daemon::transport` keys its
|
||||
/// fallback socket path with.
|
||||
///
|
||||
/// Not `DefaultHasher`: ids derived here are compared against ids derived by a
|
||||
/// *different build* of tty7 (a daemon outlives an app upgrade; a remote server
|
||||
/// is its own binary), so the function has to be stable across compiler and
|
||||
/// std versions, which `DefaultHasher` explicitly is not.
|
||||
pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in bytes {
|
||||
@@ -103,36 +36,18 @@ pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
h
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The UI-thread guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static UI_THREAD: OnceLock<ThreadId> = OnceLock::new();
|
||||
|
||||
/// Record the calling thread as the UI thread, so [`guard_off_ui`] has
|
||||
/// something to compare against. Idempotent; later calls are ignored.
|
||||
///
|
||||
/// `ui::host_ops` calls this on its way through, which is enough: everything it
|
||||
/// runs on runs on the UI thread by construction, and until it is called the
|
||||
/// guard simply never fires (a headless `tty7-server` has no UI thread and
|
||||
/// wants none of this).
|
||||
pub fn register_ui_thread() {
|
||||
let _ = UI_THREAD.set(std::thread::current().id());
|
||||
}
|
||||
|
||||
/// Whether the calling thread is the one [`register_ui_thread`] claimed.
|
||||
pub fn is_ui_thread() -> bool {
|
||||
UI_THREAD
|
||||
.get()
|
||||
.is_some_and(|t| *t == std::thread::current().id())
|
||||
}
|
||||
|
||||
/// Panic (debug builds only) if a blocking `Host` call is happening on the UI
|
||||
/// thread.
|
||||
///
|
||||
/// Deliberately not `#[cfg(debug_assertions)]` on the *function* — call sites
|
||||
/// would then need their own `cfg`, and one forgotten `cfg` is one unguarded
|
||||
/// method. `debug_assert!` already compiles the check away in release.
|
||||
#[inline]
|
||||
pub fn guard_off_ui() {
|
||||
debug_assert!(
|
||||
@@ -141,72 +56,32 @@ pub fn guard_off_ui() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One entry of a directory listing.
|
||||
///
|
||||
/// **No `path` field, on purpose.** The caller rebuilds it with
|
||||
/// [`Host::join`]: a remote entry's path uses the *remote's* separator, and a
|
||||
/// `PathBuf` assembled on a Windows client would use a backslash the remote has
|
||||
/// never heard of.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Entry {
|
||||
/// The file name, lossily decoded when the host's filesystem holds bytes
|
||||
/// that are not valid UTF-8.
|
||||
pub name: String,
|
||||
/// Whether the entry *resolves to* a directory — symlinks followed, so a
|
||||
/// link to a directory is `true` here and `true` in `is_symlink` both.
|
||||
pub is_dir: bool,
|
||||
/// Whether the entry is itself a symbolic link.
|
||||
pub is_symlink: bool,
|
||||
/// The host's own gitignore verdict, computed against the chain of
|
||||
/// `.gitignore` files from the listing's `root` down. `.git` is always
|
||||
/// `true`. Always `false` when the listing had no `root`, or when the host
|
||||
/// has no git.
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
/// What a `stat` answers.
|
||||
///
|
||||
/// Deliberately not `std::fs::Metadata`, which can be neither constructed nor
|
||||
/// serialized — a remote host has to be able to hand one across a wire.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Meta {
|
||||
/// Whether the path resolves to a directory (symlinks followed).
|
||||
pub is_dir: bool,
|
||||
/// Whether the path is itself a symbolic link.
|
||||
pub is_symlink: bool,
|
||||
/// Size in bytes.
|
||||
pub len: u64,
|
||||
/// `None` when the platform or filesystem has no modification time.
|
||||
pub mtime: Option<MTime>,
|
||||
/// Whether the permission bits say read-only.
|
||||
pub readonly: bool,
|
||||
}
|
||||
|
||||
/// A modification time, to the nanosecond.
|
||||
///
|
||||
/// Nanoseconds rather than milliseconds because the code editor detects
|
||||
/// external edits by asking "is the mtime still the one I wrote?" — at
|
||||
/// millisecond granularity a real edit landing in the same millisecond as our
|
||||
/// own write is indistinguishable from our own write, and gets swallowed.
|
||||
/// Two fields rather than a `u128` because JSON cannot carry a `u128` without
|
||||
/// losing precision, and this type crosses a JSON wire.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct MTime {
|
||||
/// Whole seconds since the Unix epoch; negative before 1970.
|
||||
pub secs: i64,
|
||||
/// Nanoseconds within the second, `0..1_000_000_000`.
|
||||
pub nanos: u32,
|
||||
}
|
||||
|
||||
impl MTime {
|
||||
/// Convert from a `SystemTime`, keeping pre-epoch times exact rather than
|
||||
/// clamping them to zero.
|
||||
pub fn from_system_time(t: std::time::SystemTime) -> MTime {
|
||||
match t.duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(d) => MTime {
|
||||
@@ -214,7 +89,6 @@ impl MTime {
|
||||
nanos: d.subsec_nanos(),
|
||||
},
|
||||
Err(e) => {
|
||||
// Before the epoch: `duration_since` hands back how far before.
|
||||
let d = e.duration();
|
||||
let (secs, nanos) = if d.subsec_nanos() == 0 {
|
||||
(-(d.as_secs() as i64), 0)
|
||||
@@ -227,34 +101,15 @@ impl MTime {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one child-process run produced.
|
||||
///
|
||||
/// Deliberately not `std::process::Output`: its `ExitStatus` cannot be
|
||||
/// constructed portably, and a remote host has to synthesize one from a wire
|
||||
/// message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Output {
|
||||
/// The exit code, or `None` when the process was killed by a signal or the
|
||||
/// code could not be obtained.
|
||||
pub status: Option<i32>,
|
||||
/// Raw stdout, base64 on the wire.
|
||||
///
|
||||
/// Not a plain `Vec<u8>`: `serde_json` has no byte type and renders one as
|
||||
/// an array of decimal numbers, so a 1 MB `git diff` would cross the wire as
|
||||
/// roughly 4 MB of JSON. (`serde_bytes` does not help here — it forwards to
|
||||
/// `serialize_bytes`, which `serde_json` implements as exactly that array.)
|
||||
#[serde(with = "b64")]
|
||||
pub stdout: Vec<u8>,
|
||||
/// Raw stderr, same encoding.
|
||||
#[serde(with = "b64")]
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `Vec<u8>` ⇄ base64 string, for the byte fields that cross a JSON wire.
|
||||
///
|
||||
/// Shared with the control dialect ([`crate::daemon::control::ControlEvent::GitChunk`])
|
||||
/// rather than duplicated there: every byte field on that wire has the same
|
||||
/// hazard, and one encoding is one thing to get right.
|
||||
pub(crate) mod b64 {
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
@@ -271,174 +126,73 @@ pub(crate) mod b64 {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
/// Exited zero.
|
||||
pub fn success(&self) -> bool {
|
||||
self.status == Some(0)
|
||||
}
|
||||
|
||||
/// stdout as lossy UTF-8, trimmed — the shape every git call site wants.
|
||||
pub fn stdout_trimmed(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// stderr as lossy UTF-8, trimmed — the shape error messages want.
|
||||
pub fn stderr_trimmed(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stderr).trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One hit from [`Host::search`]. Unlike [`Entry`] this *does* carry a path:
|
||||
/// hits come from directories the caller never listed, so there is nothing to
|
||||
/// join against — the host, which knows its own separator, builds it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SearchHit {
|
||||
/// The file name that matched.
|
||||
pub name: String,
|
||||
/// The absolute path, in the host's own separator.
|
||||
pub path: PathBuf,
|
||||
/// Whether the hit is a directory.
|
||||
pub is_dir: bool,
|
||||
/// Whether the hit is gitignored.
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A live subscription to filesystem changes.
|
||||
///
|
||||
/// Long-lived and *mutable*: the file tree changes which directories it cares
|
||||
/// about every time a row is expanded, and tearing the subscription down and
|
||||
/// rebuilding it would cost a round trip plus a rebuilt server-side watcher for
|
||||
/// every disclosure triangle. So the subscription outlives the set, and
|
||||
/// [`WatchSub::set_dirs`] replaces the set in place.
|
||||
///
|
||||
/// Dropping it unsubscribes.
|
||||
pub struct WatchSub {
|
||||
rx: smol::channel::Receiver<Vec<PathBuf>>,
|
||||
inner: Box<dyn WatchHandle>,
|
||||
}
|
||||
|
||||
impl WatchSub {
|
||||
/// Build a subscription from its two halves. Implementations of
|
||||
/// [`Host::watch`] call this; nothing else needs to.
|
||||
pub fn new(rx: smol::channel::Receiver<Vec<PathBuf>>, inner: Box<dyn WatchHandle>) -> WatchSub {
|
||||
WatchSub { rx, inner }
|
||||
}
|
||||
|
||||
/// The batched event stream.
|
||||
///
|
||||
/// Batches are coalesced over a 100ms window and deduplicated within it —
|
||||
/// **by every implementation, identically**. A local watcher is not allowed
|
||||
/// to be "helpfully" more immediate than a remote one, because then the
|
||||
/// consumer's idea of how often it repaints would depend on where the files
|
||||
/// happen to live.
|
||||
pub fn events(&self) -> &smol::channel::Receiver<Vec<PathBuf>> {
|
||||
&self.rx
|
||||
}
|
||||
|
||||
/// Replace the watched set wholesale. The implementation works out the
|
||||
/// difference; the caller only ever states the full set.
|
||||
///
|
||||
/// Always **non-recursive**: a directory being watched says nothing about
|
||||
/// its subdirectories.
|
||||
pub fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> {
|
||||
self.inner.set_dirs(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
/// The implementation half of a [`WatchSub`]: whatever has to be told when the
|
||||
/// watched set changes, and whatever has to be torn down on drop.
|
||||
pub trait WatchHandle: Send + Sync {
|
||||
/// Replace the watched directory set.
|
||||
fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A machine's filesystem and git, behind one blocking, object-safe interface.
|
||||
///
|
||||
/// See the module docs for why it blocks and why paths go through
|
||||
/// [`join`](Host::join) / [`is_absolute`](Host::is_absolute) rather than
|
||||
/// `std::path`.
|
||||
pub trait Host: Send + Sync + 'static {
|
||||
// ----- identity --------------------------------------------------------
|
||||
|
||||
/// This host's in-process id.
|
||||
fn id(&self) -> HostId;
|
||||
|
||||
/// The path separator this host's filesystem uses: the platform's own for a
|
||||
/// local host, `/` for a remote Linux or WSL one.
|
||||
fn separator(&self) -> char;
|
||||
|
||||
// ----- path arithmetic -------------------------------------------------
|
||||
|
||||
/// `dir` + `name`, using this host's separator.
|
||||
///
|
||||
/// Use this instead of `Path::join` for any path that might belong to a
|
||||
/// remote host — `PathBuf::join` uses the *client's* separator, which on a
|
||||
/// Windows client talking to Linux produces `/home/me\src`.
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
default_join(dir, name, self.separator())
|
||||
}
|
||||
|
||||
/// Whether `p` is absolute *in this host's semantics*.
|
||||
///
|
||||
/// Use this instead of `Path::is_absolute`: on a Windows client
|
||||
/// `Path::new("/home/me").is_absolute()` is `false` (it reads as
|
||||
/// drive-relative), which would silently mis-classify every remote POSIX
|
||||
/// path.
|
||||
fn is_absolute(&self, p: &Path) -> bool;
|
||||
|
||||
// ----- reading ---------------------------------------------------------
|
||||
|
||||
/// List `dir`, **already sorted**: directories first, then case-insensitive
|
||||
/// by name. Sorting is the host's job so that a remote listing arrives
|
||||
/// ready to render and the order can never drift between hosts.
|
||||
///
|
||||
/// `root` bounds the gitignore chain: each entry's `ignored` is scored by
|
||||
/// walking `.gitignore` files from `root` down to `dir`, deepest match
|
||||
/// winning and `!` whitelists un-ignoring. With `root == None` nothing is
|
||||
/// ignored except `.git` itself.
|
||||
///
|
||||
/// Hidden files are **not** filtered — "show hidden" is a UI preference and
|
||||
/// stays on the client.
|
||||
fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<Entry>>;
|
||||
|
||||
/// Metadata for `p`, symlinks followed.
|
||||
fn stat(&self, p: &Path) -> io::Result<Meta>;
|
||||
|
||||
/// Whether `p` exists. Separate from `stat` so an implementation can answer
|
||||
/// in one round trip instead of shipping metadata nobody asked for.
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
self.stat(p).is_ok()
|
||||
}
|
||||
|
||||
/// Read `p` whole.
|
||||
///
|
||||
/// `max_bytes` is enforced **by the host**: a file over the limit fails with
|
||||
/// [`io::ErrorKind::FileTooLarge`] without its contents being transferred,
|
||||
/// rather than being shipped across an ocean and then discarded.
|
||||
fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>>;
|
||||
|
||||
/// Resolve `p` to an absolute path with symlinks and `..` resolved, on the
|
||||
/// host's own filesystem.
|
||||
fn canonicalize(&self, p: &Path) -> io::Result<PathBuf>;
|
||||
|
||||
/// Breadth-first substring search over file names, **executed on the
|
||||
/// host**.
|
||||
///
|
||||
/// Running this client-side would mean up to `max_dirs` separate directory
|
||||
/// listings; at 200ms of round trip each that is a search which takes
|
||||
/// minutes. So the whole walk goes to the host and only the hits come back.
|
||||
///
|
||||
/// The walk starts at each of `roots`, visits at most `max_dirs`
|
||||
/// directories in total, stops at `limit` hits, and — when `show_hidden` is
|
||||
/// false — never descends into an ignored or dot-prefixed directory, which
|
||||
/// is what keeps `node_modules` and `target` from eating the whole budget.
|
||||
fn search(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
@@ -448,86 +202,20 @@ pub trait Host: Send + Sync + 'static {
|
||||
show_hidden: bool,
|
||||
) -> io::Result<Vec<SearchHit>>;
|
||||
|
||||
// ----- writing ---------------------------------------------------------
|
||||
|
||||
/// Write `bytes` to `p`, creating or truncating it, and answer the file's
|
||||
/// post-write [`Meta`]. A missing parent directory is an error, not
|
||||
/// something to create.
|
||||
///
|
||||
/// **Why it returns `Meta` rather than `()`.** The editor keeps a
|
||||
/// `disk_mtime` baseline to tell its own write apart from someone else's
|
||||
/// edit. Taking that baseline from a *separate* `stat` after the write
|
||||
/// leaves a window: a change landing in between is stamped as ours, and the
|
||||
/// editor then never reports it — silent, and it costs the user their
|
||||
/// conflict prompt. The post-write metadata is the write's own answer, so
|
||||
/// it closes the window by construction. It is also one round trip instead
|
||||
/// of two on every remote save; the control reply already carried it
|
||||
///, so nothing on the wire moved.
|
||||
///
|
||||
/// Callers that genuinely don't want it write `.map(|_| ())`.
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>;
|
||||
|
||||
/// Create `p` as an empty file, failing with
|
||||
/// [`io::ErrorKind::AlreadyExists`] if anything is already there.
|
||||
fn create_file_new(&self, p: &Path) -> io::Result<()>;
|
||||
|
||||
/// Create directory `p`. With `recursive`, create missing parents too and
|
||||
/// treat an existing directory as success.
|
||||
fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()>;
|
||||
|
||||
/// Move `from` to `to`.
|
||||
///
|
||||
/// An existing `to` is [`io::ErrorKind::AlreadyExists`], **guaranteed by
|
||||
/// the implementation** — a caller that probed first would be paying an
|
||||
/// extra round trip for a check that is racy anyway.
|
||||
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
|
||||
|
||||
/// Remove `p`. `recursive` only means anything for a directory; a
|
||||
/// non-empty directory without it is
|
||||
/// [`io::ErrorKind::DirectoryNotEmpty`].
|
||||
fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>;
|
||||
|
||||
// ----- git -------------------------------------------------------------
|
||||
|
||||
/// The work-tree root `p` belongs to: the nearest ancestor holding a `.git`
|
||||
/// (a directory, or the file a linked worktree gets). `Ok(None)` — not an
|
||||
/// error — when `p` is outside any repository.
|
||||
///
|
||||
/// The whole ancestor walk happens on the host; a remote implementation
|
||||
/// must not climb one level per round trip.
|
||||
fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>>;
|
||||
|
||||
/// Run `git -C <cwd> <args>` on the host.
|
||||
///
|
||||
/// `Ok` means git *ran*; its exit code is in [`Output::status`], and a
|
||||
/// non-zero one is a perfectly ordinary answer (`rev-parse` outside a repo
|
||||
/// exits 128). `Err` means it could not be run at all — no git, missing
|
||||
/// `cwd`, connection gone.
|
||||
///
|
||||
/// Every implementation runs it under the same invariants: `-C` rather than
|
||||
/// a current directory, `GIT_OPTIONAL_LOCKS=0`, null stdin, `GIT_DIR` and
|
||||
/// `GIT_WORK_TREE` cleared, and both output streams captured.
|
||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
|
||||
|
||||
/// [`git`](Self::git), delivered a line at a time.
|
||||
///
|
||||
/// Same invocation, same invariants; the difference is that neither side
|
||||
/// has to hold the whole output. `git diff HEAD` on a large work tree is
|
||||
/// tens of megabytes and the caller keeps a small fraction of it, so
|
||||
/// buffering it first is pure cost — see [`crate::core::git::git_stream`].
|
||||
///
|
||||
/// Lines arrive with their trailing `\n`/`\r` stripped and invalid UTF-8
|
||||
/// replaced. `Ok` means git ran, carrying its exit code (`None` when a
|
||||
/// signal killed it); `Err` means it could not be run at all, exactly as
|
||||
/// for [`git`](Self::git).
|
||||
///
|
||||
/// **The default implementation buffers**, so this is never a second way to
|
||||
/// reach git — every implementation still funnels through the same
|
||||
/// invocation, and a host with no incremental transport simply pays the
|
||||
/// memory it would have paid anyway. Both hosts that ship override it;
|
||||
/// the default is what keeps a future one from having to. Overriding is an
|
||||
/// optimisation, not a behaviour change: the lines a caller sees must be
|
||||
/// identical either way.
|
||||
fn git_lines(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
@@ -541,38 +229,15 @@ pub trait Host: Send + Sync + 'static {
|
||||
Ok(out.status)
|
||||
}
|
||||
|
||||
// ----- machine inventory -----------------------------------------------
|
||||
|
||||
/// The shells this host can launch, plus which one a plain new tab lands
|
||||
/// on — the new-tab dropdown's menu.
|
||||
///
|
||||
/// On the trait rather than beside `detect_shells` because a window bound to
|
||||
/// a remote workspace opens its tabs *over there*: a picker built from this
|
||||
/// computer's `/etc/shells` offers paths that don't exist on the machine the
|
||||
/// spawn actually reaches. Probing is not free (Windows enumerates WSL by
|
||||
/// spawning `wsl.exe`), so callers ask once per machine, not per menu open.
|
||||
fn shells(&self) -> io::Result<ShellInventory>;
|
||||
|
||||
// ----- watching --------------------------------------------------------
|
||||
|
||||
/// Open a long-lived, non-recursive watch over `dirs` (which may be empty —
|
||||
/// the set can be filled in later with [`WatchSub::set_dirs`]).
|
||||
fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub>;
|
||||
|
||||
// ----- liveness --------------------------------------------------------
|
||||
|
||||
/// Whether the host is reachable right now.
|
||||
///
|
||||
/// Always true locally. A remote host reports false while reconnecting or
|
||||
/// after being taken over, and call sites use that to keep showing the last
|
||||
/// good listing instead of flashing an error.
|
||||
fn is_connected(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Join `name` onto `dir` with an explicit separator — the default
|
||||
/// [`Host::join`], and the one a remote host uses.
|
||||
pub fn default_join(dir: &Path, name: &str, sep: char) -> PathBuf {
|
||||
let mut s = dir.to_string_lossy().into_owned();
|
||||
if !s.is_empty() && !s.ends_with(sep) && !s.ends_with('/') {
|
||||
@@ -582,18 +247,12 @@ pub fn default_join(dir: &Path, name: &str, sep: char) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// The alias the rest of the tree uses. A workspace holds one of these; nothing
|
||||
/// holds a concrete host type.
|
||||
pub type SharedHost = Arc<dyn Host>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The hash has to stay bit-for-bit what `daemon::transport` computes, or an
|
||||
/// upgraded client would derive different ids than the daemon it is talking
|
||||
/// to. Pinned against the published FNV-1a-64 vectors rather than against
|
||||
/// our own output, so a "refactor" that changes the algorithm fails here.
|
||||
#[test]
|
||||
fn fnv1a64_matches_the_published_vectors() {
|
||||
assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
|
||||
@@ -601,10 +260,6 @@ mod tests {
|
||||
assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
|
||||
}
|
||||
|
||||
/// `HostId(0)` means local and nothing derived may claim it. Sweeping a few
|
||||
/// thousand plausible keys is not a proof, but it is the part of the
|
||||
/// reservation that could plausibly regress (someone dropping the `h == 0`
|
||||
/// bump as dead code).
|
||||
#[test]
|
||||
fn zero_is_reserved_for_local() {
|
||||
assert!(HostId::LOCAL.is_local());
|
||||
@@ -615,8 +270,6 @@ mod tests {
|
||||
assert!(!HostId::from_connection_key("").is_local());
|
||||
}
|
||||
|
||||
/// Same machine, same id; different machines, different ids. This is what
|
||||
/// keeps two workspaces on one remote box sharing a git-status cache.
|
||||
#[test]
|
||||
fn connection_keys_map_to_stable_ids() {
|
||||
let a = HostId::from_connection_key("ssh-direct:me@box:22");
|
||||
@@ -625,15 +278,12 @@ mod tests {
|
||||
assert_ne!(a, HostId::from_connection_key("wsl:Ubuntu"));
|
||||
}
|
||||
|
||||
/// The remote separator wins, whatever the client's `std::path` thinks —
|
||||
/// the whole point of not using `PathBuf::join`.
|
||||
#[test]
|
||||
fn default_join_uses_the_given_separator() {
|
||||
assert_eq!(
|
||||
default_join(Path::new("/home/me"), "src", '/'),
|
||||
PathBuf::from("/home/me/src")
|
||||
);
|
||||
// Already separated: no doubling.
|
||||
assert_eq!(
|
||||
default_join(Path::new("/"), "etc", '/'),
|
||||
PathBuf::from("/etc")
|
||||
@@ -648,13 +298,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-epoch times round-trip exactly rather than clamping to zero, because
|
||||
/// the editor compares mtimes for equality.
|
||||
///
|
||||
/// Every nanosecond figure here is a multiple of 100: a Windows
|
||||
/// `SystemTime` is a FILETIME, whose tick *is* 100ns, so a finer value
|
||||
/// would be rounded on the way in and the assertion would be about
|
||||
/// `SystemTime`'s resolution rather than about this conversion.
|
||||
#[test]
|
||||
fn mtime_handles_both_sides_of_the_epoch() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
@@ -680,8 +323,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Err` is "it did not run"; a non-zero exit is an ordinary `Ok`. Every
|
||||
/// git call site's error handling is built on that split.
|
||||
#[test]
|
||||
fn output_success_is_exit_zero_only() {
|
||||
let ok = Output {
|
||||
@@ -706,8 +347,6 @@ mod tests {
|
||||
assert!(!signalled.success());
|
||||
}
|
||||
|
||||
/// Non-UTF-8 output does not lose the run: it comes back lossy rather than
|
||||
/// turning the call into an error.
|
||||
#[test]
|
||||
fn output_text_is_lossy_not_fallible() {
|
||||
let o = Output {
|
||||
@@ -718,9 +357,6 @@ mod tests {
|
||||
assert!(o.stdout_trimmed().contains('a'));
|
||||
}
|
||||
|
||||
/// Arbitrary bytes survive a JSON round trip, and they do it as base64
|
||||
/// rather than as an array of numbers — the difference between 1.33× and 4×
|
||||
/// on a `git diff` big enough to matter.
|
||||
#[test]
|
||||
fn output_bytes_cross_json_as_base64() {
|
||||
let o = Output {
|
||||
@@ -737,8 +373,6 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<Output>(&json).unwrap(), o);
|
||||
}
|
||||
|
||||
/// The listing/metadata types have to survive the same trip, since they are
|
||||
/// the payload of every read RPC.
|
||||
#[test]
|
||||
fn value_types_round_trip_through_json() {
|
||||
let e = Entry {
|
||||
@@ -773,17 +407,11 @@ mod tests {
|
||||
assert_eq!(back, h);
|
||||
}
|
||||
|
||||
/// The guard is inert until a UI thread claims itself, which is what lets
|
||||
/// `tty7-server` — and every test — call hosts from any thread.
|
||||
#[test]
|
||||
fn the_ui_guard_is_inert_without_registration() {
|
||||
// No `register_ui_thread` in the server or in tests, so this is a no-op
|
||||
// rather than a panic.
|
||||
guard_off_ui();
|
||||
}
|
||||
|
||||
/// Object safety is not decoration: the whole tree stores `Arc<dyn Host>`,
|
||||
/// and the conformance suite takes `&dyn Host` precisely to keep this true.
|
||||
#[test]
|
||||
fn host_is_object_safe() {
|
||||
fn takes_dyn(_h: &dyn Host) {}
|
||||
|
||||
@@ -1,37 +1,3 @@
|
||||
//! [`RemoteHost`] — a [`Host`] whose filesystem is on another machine.
|
||||
//!
|
||||
//! Every method here is one control round trip: the `Host` call blocks its own
|
||||
//! caller (which is always a background thread — see the module docs on why the
|
||||
//! trait stays blocking), the request goes out with a fresh id, and
|
||||
//! [`ControlClient`] wakes exactly that caller when the matching reply arrives.
|
||||
//! Nothing is batched, nothing is cached, and nothing shares a queue: a
|
||||
//! twenty-second `git` and a five-millisecond `read_dir` overlap freely.
|
||||
//!
|
||||
//! ## What this file is and isn't
|
||||
//!
|
||||
//! It is a **translation layer**, deliberately thin. The interesting machinery —
|
||||
//! request ids, out-of-order reply matching, per-method deadlines,
|
||||
//! cancellation, tearing every waiter down when the link dies — lives in
|
||||
//! [`crate::daemon::control`], because the server needs the same wire and the
|
||||
//! test suite needs to exercise the multiplexer without a `Host` in the
|
||||
//! picture. What is left here is the mapping from a `Host` method to a
|
||||
//! [`ControlRequest`] and back, plus the watch bookkeeping that has no wire
|
||||
//! equivalent.
|
||||
//!
|
||||
//! ## Round trips are the budget
|
||||
//!
|
||||
//! On a transcontinental link a round trip is 150-250ms, so the count is the
|
||||
//! only performance number that matters and every method is written to cost
|
||||
//! exactly one:
|
||||
//!
|
||||
//! | Temptation | Why it is refused |
|
||||
//! |---|---|
|
||||
//! | `exists` as `stat().is_ok()` | The default would work, but `Exists` answers a bool without shipping metadata nobody asked for |
|
||||
//! | `rename` probing `to` first | Two round trips *and* a TOCTOU. The server guarantees `AlreadyExists` |
|
||||
//! | `repo_root` climbing one level per call | A twelve-deep path would cost twelve round trips; the server walks it |
|
||||
//! | `search` listing directories one at a time | Up to `max_dirs` round trips — minutes. The whole walk runs on the server |
|
||||
//! | `read_file` fetching then checking the size | `max_bytes` is enforced *before* the bytes move |
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -48,28 +14,16 @@ use crate::host::{
|
||||
Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub,
|
||||
};
|
||||
|
||||
/// A [`Host`] backed by a control connection to another machine.
|
||||
pub struct RemoteHost {
|
||||
id: HostId,
|
||||
client: Arc<ControlClient>,
|
||||
separator: char,
|
||||
watches: Arc<WatchRegistry>,
|
||||
streams: Arc<GitStreamRegistry>,
|
||||
/// Ids for [`ControlRequest::GitStream`]. Client-assigned so the receiver
|
||||
/// can be registered before the request is sent; unique per connection is
|
||||
/// all they need to be.
|
||||
next_stream: AtomicU64,
|
||||
}
|
||||
|
||||
impl RemoteHost {
|
||||
/// Handshake over an already-connected duplex link and build the host.
|
||||
///
|
||||
/// `r` and `w` are the two halves of one stream — a `try_clone`d socket, or
|
||||
/// a child process's stdout and stdin. `connection_key` is the normalized
|
||||
/// connection string the [`HostId`] is derived from (`ssh-alias:box`,
|
||||
/// `wsl:Ubuntu`, …); it deliberately excludes the workspace, so several
|
||||
/// workspaces on one machine share one id and therefore one git-status
|
||||
/// cache.
|
||||
pub fn connect<R, W>(
|
||||
r: R,
|
||||
w: W,
|
||||
@@ -83,11 +37,6 @@ impl RemoteHost {
|
||||
Self::connect_with(r, w, None, connection_key, hello)
|
||||
}
|
||||
|
||||
/// [`RemoteHost::connect`] over a TCP socket, with link shutdown wired up.
|
||||
///
|
||||
/// Prefer this wherever the transport has a shutdown. Without one, dropping
|
||||
/// the host cannot wake its reader thread, so the drop costs a grace period
|
||||
/// and leaves the thread behind — see [`LinkShutdown`].
|
||||
pub fn over_tcp(
|
||||
sock: std::net::TcpStream,
|
||||
connection_key: &str,
|
||||
@@ -98,8 +47,6 @@ impl RemoteHost {
|
||||
Self::connect_with(r, sock, Some(closer), connection_key, hello)
|
||||
}
|
||||
|
||||
/// [`RemoteHost::connect`] over a Unix-domain socket, with link shutdown
|
||||
/// wired up.
|
||||
#[cfg(unix)]
|
||||
pub fn over_unix(
|
||||
sock: std::os::unix::net::UnixStream,
|
||||
@@ -111,8 +58,6 @@ impl RemoteHost {
|
||||
Self::connect_with(r, sock, Some(closer), connection_key, hello)
|
||||
}
|
||||
|
||||
/// The full form. `shutdown` is what lets dropping this host actually close
|
||||
/// the link rather than orphan its reader.
|
||||
pub fn connect_with<R, W>(
|
||||
r: R,
|
||||
w: W,
|
||||
@@ -124,35 +69,23 @@ impl RemoteHost {
|
||||
R: Read + Send + 'static,
|
||||
W: Write + Send + 'static,
|
||||
{
|
||||
// The event sink has to exist before the client, and the watch table it
|
||||
// feeds has to outlive both, so the table is built first and shared
|
||||
// rather than reached back into.
|
||||
let watches = Arc::new(WatchRegistry::default());
|
||||
let sink_watches = Arc::clone(&watches);
|
||||
let streams: Arc<GitStreamRegistry> = Arc::new(GitStreamRegistry::default());
|
||||
let sink_streams = Arc::clone(&streams);
|
||||
// The id is derived here rather than read back off the host because the
|
||||
// sink has to exist before the host does — and because an event that
|
||||
// could not say *which machine* it came from would be useless to the
|
||||
// window layer, which has one connection per machine.
|
||||
let id = HostId::from_connection_key(connection_key);
|
||||
let sink: EventSink = Box::new(move |event| match event {
|
||||
// Watch pushes belong to whoever is still holding the `WatchSub`.
|
||||
ControlEvent::Watch { .. } | ControlEvent::WatchOverflow { .. } => {
|
||||
sink_watches.dispatch(event);
|
||||
}
|
||||
// Git chunks belong to whichever thread is draining that stream.
|
||||
ControlEvent::GitChunk { .. } | ControlEvent::GitEnd { .. } => {
|
||||
sink_streams.dispatch(event);
|
||||
}
|
||||
// Everything else is about a *window*, and this layer has none.
|
||||
other => crate::daemon::control::observe_event(id, other),
|
||||
});
|
||||
|
||||
let client = Arc::new(ControlClient::connect_with(r, w, shutdown, hello, sink)?);
|
||||
let separator = client.hello().separator;
|
||||
// A stream is answered by pushes, not by a reply, so `fail_all` cannot
|
||||
// see anyone waiting on one — see [`GitStreamRegistry::close_all`].
|
||||
let down_streams = Arc::clone(&streams);
|
||||
client.on_link_down(move || down_streams.close_all());
|
||||
|
||||
@@ -168,24 +101,18 @@ impl RemoteHost {
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// What the peer said about itself at handshake time.
|
||||
pub fn peer(&self) -> &ControlHelloOk {
|
||||
self.client.hello()
|
||||
}
|
||||
|
||||
/// The server's `$HOME`, for "new workspace defaults to `~`" — which has to
|
||||
/// mean the *remote's* home, not the client's.
|
||||
pub fn home(&self) -> PathBuf {
|
||||
PathBuf::from(&self.client.hello().home)
|
||||
}
|
||||
|
||||
/// The underlying connection, for callers that need to speak control
|
||||
/// directly (the machine-tree verbs).
|
||||
pub fn client(&self) -> &Arc<ControlClient> {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Erase to the shared trait object the rest of the tree holds.
|
||||
pub fn into_shared(self: Arc<Self>) -> SharedHost {
|
||||
self
|
||||
}
|
||||
@@ -195,12 +122,6 @@ impl RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a path for the wire.
|
||||
///
|
||||
/// Lossy rather than an error: a remote path is UTF-8 by construction, and the
|
||||
/// only way a non-UTF-8 one reaches here is if it came *from* the server's own
|
||||
/// lossy listing — in which case failing would turn a cosmetically odd filename
|
||||
/// into an unusable one.
|
||||
fn wire_path(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -209,8 +130,6 @@ fn wire_paths(paths: &[PathBuf]) -> Vec<String> {
|
||||
paths.iter().map(|p| wire_path(p)).collect()
|
||||
}
|
||||
|
||||
/// A reply of the wrong shape is a server bug, and it is worth saying so
|
||||
/// plainly rather than letting it surface as a confusing empty result.
|
||||
fn wrong_shape(expected: &str, got: &ReplyOk) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -227,14 +146,9 @@ impl Host for RemoteHost {
|
||||
self.separator
|
||||
}
|
||||
|
||||
/// Absolute *in the peer's* terms, which is the whole reason this is a
|
||||
/// trait method: a Windows client asking `Path::is_absolute` about
|
||||
/// `/home/me` is told `false`, and would then treat every remote path as
|
||||
/// relative.
|
||||
fn is_absolute(&self, p: &Path) -> bool {
|
||||
let s = p.to_string_lossy();
|
||||
if self.separator == '\\' {
|
||||
// A remote Windows host: `C:\…`, or a UNC/rooted path.
|
||||
let mut c = s.chars();
|
||||
let drive = matches!(
|
||||
(c.next(), c.next(), c.next()),
|
||||
@@ -263,8 +177,6 @@ impl Host for RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// One round trip that ships a bool, rather than the default's `stat` that
|
||||
/// ships metadata to throw away.
|
||||
fn exists(&self, p: &Path) -> bool {
|
||||
matches!(
|
||||
self.call(ControlRequest::Exists { path: wire_path(p) }),
|
||||
@@ -273,8 +185,6 @@ impl Host for RemoteHost {
|
||||
}
|
||||
|
||||
fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>> {
|
||||
// The content rides the frame's blob; the JSON head carries only the
|
||||
// metadata, so nothing has to be re-fetched afterwards.
|
||||
let got = self.client.call_full(
|
||||
ControlRequest::ReadFile {
|
||||
path: wire_path(p),
|
||||
@@ -316,10 +226,6 @@ impl Host for RemoteHost {
|
||||
}
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
// One round trip, not two: the reply already carries the post-write
|
||||
// metadata, so the editor's mtime baseline comes from the write itself
|
||||
// rather than from a follow-up `stat` an external edit could slip in
|
||||
// front of.
|
||||
match self
|
||||
.client
|
||||
.call_with_blob(ControlRequest::WriteFile { path: wire_path(p) }, bytes)?
|
||||
@@ -361,10 +267,6 @@ impl Host for RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ok` means git *ran* on the server. A non-zero exit is in
|
||||
/// [`Output::status`], not in the `Err` — which is what keeps
|
||||
/// `git_status`'s `Option<String>` semantics identical whether the repo is
|
||||
/// local or six thousand miles away.
|
||||
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
match self.call(ControlRequest::Git {
|
||||
cwd: wire_path(cwd),
|
||||
@@ -375,19 +277,12 @@ impl Host for RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental, always: [`ControlRequest::GitStream`] is part of the
|
||||
/// protocol, not an extension a peer may lack. Remote workspaces have never
|
||||
/// shipped a release, so there is no older server to negotiate with — and a
|
||||
/// capability check with a buffered fallback would be dead code pretending
|
||||
/// otherwise.
|
||||
fn git_lines(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
on_line: &mut dyn FnMut(&str),
|
||||
) -> io::Result<Option<i32>> {
|
||||
// Registered *before* the request goes out, so a chunk cannot arrive
|
||||
// with nowhere to go — see `ControlRequest::GitStream`.
|
||||
let id = self.next_stream.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let queued = Arc::new(AtomicUsize::new(0));
|
||||
@@ -398,9 +293,6 @@ impl Host for RemoteHost {
|
||||
queued: Arc::clone(&queued),
|
||||
},
|
||||
);
|
||||
// The receiver comes off the registry however this returns: an early
|
||||
// `?` below would otherwise leak the entry for the life of the
|
||||
// connection.
|
||||
let _guard = StreamGuard {
|
||||
streams: &self.streams,
|
||||
id,
|
||||
@@ -418,10 +310,6 @@ impl Host for RemoteHost {
|
||||
drain_git_stream(&rx, &queued, GIT_STREAM_IDLE_TIMEOUT, on_line)
|
||||
}
|
||||
|
||||
/// Safe to send unguarded: the request landed in control v2, and the
|
||||
/// handshake already refused any peer on another dialect. A server too old
|
||||
/// to know the variant is never on the other end of a live connection — it
|
||||
/// was replaced at install time, or the connection never opened.
|
||||
fn shells(&self) -> io::Result<ShellInventory> {
|
||||
match self.call(ControlRequest::Shells)? {
|
||||
ReplyOk::Shells(inv) => Ok(inv),
|
||||
@@ -437,9 +325,6 @@ impl Host for RemoteHost {
|
||||
other => return Err(wrong_shape("a watch id", &other)),
|
||||
};
|
||||
|
||||
// Unbounded so the reader thread never blocks delivering a batch: the
|
||||
// server has already coalesced within its window, and the consumer is a
|
||||
// UI that may be a frame or two behind.
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
self.watches.insert(id, tx, dirs.to_vec());
|
||||
|
||||
@@ -477,46 +362,8 @@ impl std::fmt::Debug for RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bytes one stream may have sitting between the reader thread and the thread
|
||||
/// draining it.
|
||||
///
|
||||
/// The queue below is unbounded and its `send` never waits, deliberately: the
|
||||
/// reader thread serves the *whole* connection, so parking it there would stall
|
||||
/// every other reply, every watch event and the keepalive pongs — a peer that
|
||||
/// out-runs one diff reader would take the link down with it. The cost of not
|
||||
/// waiting is that nothing throttles the sender, and "streaming" would bound
|
||||
/// what each end reads at once while letting the queue between them grow to the
|
||||
/// size of the whole diff — the exact peak this path exists to remove, one
|
||||
/// container further along.
|
||||
///
|
||||
/// So the queue is *bounded* instead of back-pressured: past this the stream is
|
||||
/// failed with [`GitStreamMsg::Overrun`] rather than served, which turns an
|
||||
/// unbounded allocation into a read that says what happened. Real back-pressure
|
||||
/// would need credit-based flow control in the dialect — the client telling the
|
||||
/// server how much more it may push — which is a protocol change, not a
|
||||
/// buffering policy, and is not what this is.
|
||||
///
|
||||
/// Set far above any healthy gap. The drainer only splits lines and parses, at
|
||||
/// roughly 8 MB per 12 ms, so it stays within a chunk or two of a link that is
|
||||
/// merely fast; reaching 32 MiB of arrears means the consumer is wedged, not
|
||||
/// busy.
|
||||
const GIT_STREAM_QUEUE_BUDGET: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Reassemble one git stream's pushes into lines, ending on `GitEnd`, on a link
|
||||
/// that died, on the queue budget blowing, or on `idle` elapsing between chunks.
|
||||
///
|
||||
/// Split out from [`RemoteHost::git_lines`] so the ways a stream ends are
|
||||
/// reachable from a test without a socket — the timeout in particular, which
|
||||
/// otherwise could only be exercised by waiting out
|
||||
/// [`GIT_STREAM_IDLE_TIMEOUT`].
|
||||
///
|
||||
/// `queued` is the arrears this stream has accrued, in bytes; every chunk taken
|
||||
/// off the channel is subtracted from it, which is what lets the reader thread
|
||||
/// see a consumer falling behind. See [`GIT_STREAM_QUEUE_BUDGET`].
|
||||
fn drain_git_stream(
|
||||
rx: &mpsc::Receiver<GitStreamMsg>,
|
||||
queued: &AtomicUsize,
|
||||
@@ -525,30 +372,13 @@ fn drain_git_stream(
|
||||
) -> io::Result<Option<i32>> {
|
||||
let mut split = crate::core::git::LineSplitter::default();
|
||||
loop {
|
||||
// The wait is per *chunk*, not for the stream as a whole — a slow link
|
||||
// is allowed to take as long as it takes, a silent one is not. See
|
||||
// `GIT_STREAM_IDLE_TIMEOUT` for what this catches that neither the
|
||||
// request deadline nor keepalive can.
|
||||
match rx.recv_timeout(idle) {
|
||||
Ok(GitStreamMsg::Chunk(bytes)) => {
|
||||
// Before parsing, not after: the arrears the reader thread reads
|
||||
// must fall as soon as the bytes are ours, or a slow parse of one
|
||||
// chunk would count against the budget twice.
|
||||
//
|
||||
// Saturating, because the two sides of this counter are updated
|
||||
// by different threads and only the *sum* is ever meaningful: a
|
||||
// chunk that reached the channel before its charge landed would
|
||||
// otherwise wrap the counter to `usize::MAX` and kill the next
|
||||
// healthy stream for being over budget.
|
||||
let _ = queued.fetch_update(Ordering::AcqRel, Ordering::Acquire, |q| {
|
||||
Some(q.saturating_sub(bytes.len()))
|
||||
});
|
||||
split.push(&bytes, &mut *on_line);
|
||||
}
|
||||
// The queue outgrew its budget, so the reader thread stopped filling
|
||||
// it. Everything after the last delivered chunk is missing, which
|
||||
// makes this a failed read rather than a short one — the same rule
|
||||
// the timeout arm follows.
|
||||
Ok(GitStreamMsg::Overrun) => {
|
||||
return Err(io::Error::other(format!(
|
||||
"the git stream outran this client by more than \
|
||||
@@ -563,18 +393,12 @@ fn drain_git_stream(
|
||||
Ok(code)
|
||||
};
|
||||
}
|
||||
// Nothing is coming and nothing said so. The lines already handed
|
||||
// out are *not* retracted, but the result is an error: half a diff
|
||||
// reported as a successful read is how a stale overlay becomes a
|
||||
// wrong one.
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!("the git stream went silent for {idle:?} while the link stayed up"),
|
||||
));
|
||||
}
|
||||
// The connection died mid-stream. Distinguishable from a non-zero
|
||||
// exit, same as everywhere else in this file.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(io::Error::other("the control connection closed mid-stream"));
|
||||
}
|
||||
@@ -582,9 +406,6 @@ fn drain_git_stream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a stream's registry entry however its reader leaves — an early
|
||||
/// return on a wire error would otherwise leave the sender in the map for the
|
||||
/// life of the connection.
|
||||
struct StreamGuard<'a> {
|
||||
streams: &'a GitStreamRegistry,
|
||||
id: u64,
|
||||
@@ -596,36 +417,17 @@ impl Drop for StreamGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One chunk of a running [`ControlRequest::GitStream`], as the reader thread
|
||||
/// hands it to the thread that asked for the stream.
|
||||
enum GitStreamMsg {
|
||||
Chunk(Vec<u8>),
|
||||
/// The stream is over: git's exit code, and whether the server failed to
|
||||
/// run it at all.
|
||||
End {
|
||||
code: Option<i32>,
|
||||
failed: bool,
|
||||
},
|
||||
/// This stream fell far enough behind to hit [`GIT_STREAM_QUEUE_BUDGET`],
|
||||
/// so the reader thread cut it loose. Always the last message: its sender
|
||||
/// is off the table by the time it is sent.
|
||||
End { code: Option<i32>, failed: bool },
|
||||
Overrun,
|
||||
}
|
||||
|
||||
/// Where one running stream's pushes go, plus what it owes.
|
||||
struct StreamSink {
|
||||
tx: mpsc::Sender<GitStreamMsg>,
|
||||
/// Bytes handed to `tx` and not yet taken off it. Written by the reader
|
||||
/// thread, subtracted by the drainer — the one number both sides of the
|
||||
/// queue can see, and the only thing standing between an unbounded queue
|
||||
/// and the whole diff. See [`GIT_STREAM_QUEUE_BUDGET`].
|
||||
queued: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Receivers for git streams currently running on this connection, keyed by the
|
||||
/// id the client chose for each. Entries are inserted *before* the request goes
|
||||
/// out and removed when the stream ends, so no chunk can arrive with nowhere to
|
||||
/// go — see [`ControlRequest::GitStream`].
|
||||
#[derive(Default)]
|
||||
struct GitStreamRegistry {
|
||||
streams: Mutex<StreamTable>,
|
||||
@@ -634,11 +436,6 @@ struct GitStreamRegistry {
|
||||
#[derive(Default)]
|
||||
struct StreamTable {
|
||||
senders: HashMap<u64, StreamSink>,
|
||||
/// Set by [`GitStreamRegistry::close_all`] and never cleared: a
|
||||
/// `ControlClient` never comes back up, so once the link is gone no stream
|
||||
/// registered afterwards could ever be answered. Without it a `git_lines`
|
||||
/// that registered just after the teardown swept the table would park on a
|
||||
/// sender nothing will ever close.
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
@@ -649,8 +446,6 @@ impl GitStreamRegistry {
|
||||
{
|
||||
m.senders.insert(id, sink);
|
||||
}
|
||||
// Dropped rather than filed when the link is already down, which closes
|
||||
// the channel and sends the caller straight down the mid-stream arm.
|
||||
}
|
||||
|
||||
fn remove(&self, id: u64) {
|
||||
@@ -659,39 +454,14 @@ impl GitStreamRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake every reader still draining a stream, because the link they were
|
||||
/// being fed by is gone.
|
||||
///
|
||||
/// The counterpart to `ClientInner::fail_all`, and the reason this is
|
||||
/// needed at all: a `GitStream` reply arrives long before its data, so from
|
||||
/// `fail_all`'s point of view the request is finished and there is nobody
|
||||
/// to fail. The thread is really parked on the channel below, which lives
|
||||
/// here and outlives the reader thread — so unless the senders are closed
|
||||
/// deliberately, a dropped connection parks that thread forever and the
|
||||
/// caller's in-flight bookkeeping is never unwound.
|
||||
fn close_all(&self) {
|
||||
let Ok(mut m) = self.streams.lock() else {
|
||||
return;
|
||||
};
|
||||
m.closed = true;
|
||||
// Dropping every sender is the signal: chunks already queued stay
|
||||
// readable and the receiver then sees `Disconnected` rather than
|
||||
// waiting out its idle timeout for a link that is already gone.
|
||||
m.senders.clear();
|
||||
}
|
||||
|
||||
/// Route one push. Runs on the reader thread, so it must not block — the
|
||||
/// channel is unbounded and `send` never waits. A chunk for an id that has
|
||||
/// already finished (a cancelled read the server had not noticed yet) is
|
||||
/// dropped, which is the same unknown-id rule watches follow.
|
||||
///
|
||||
/// Not blocking is what makes the queue everyone's problem, so this is also
|
||||
/// where it is bounded: each chunk is charged to the stream's arrears, and a
|
||||
/// stream whose drainer has fallen [`GIT_STREAM_QUEUE_BUDGET`] behind is cut
|
||||
/// loose with an [`Overrun`](GitStreamMsg::Overrun) instead of being fed
|
||||
/// further. Cutting it loose — rather than dropping the chunk — is the only
|
||||
/// honest option: the queue is a byte stream being reassembled into lines, so
|
||||
/// a hole in the middle of it is not a shorter diff, it is a wrong one.
|
||||
fn dispatch(&self, event: ControlEvent) {
|
||||
let (id, msg) = match event {
|
||||
ControlEvent::GitChunk { id, bytes } => (id, GitStreamMsg::Chunk(bytes)),
|
||||
@@ -707,16 +477,9 @@ impl GitStreamRegistry {
|
||||
sink.queued.fetch_add(bytes.len(), Ordering::AcqRel) + bytes.len()
|
||||
> GIT_STREAM_QUEUE_BUDGET
|
||||
}
|
||||
// `End` and `Overrun` carry no payload to charge for, and an end must
|
||||
// always get through — a stream that stops speaking without one is
|
||||
// the shape the idle timeout exists to catch, at a cost of two
|
||||
// minutes.
|
||||
(Some(_), _) => false,
|
||||
};
|
||||
if over {
|
||||
// Taken off the table first, so the chunks still arriving for this id
|
||||
// meet the unknown-id rule above instead of queueing behind a message
|
||||
// that says the queue is full.
|
||||
if let Some(sink) = m.senders.remove(&id) {
|
||||
let _ = sink.tx.send(GitStreamMsg::Overrun);
|
||||
}
|
||||
@@ -728,11 +491,6 @@ impl GitStreamRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live subscriptions, keyed by the id the server assigned.
|
||||
///
|
||||
/// It has to be shared rather than owned by the host because the reader thread
|
||||
/// delivers into it and the host reads from it, and neither may wait on the
|
||||
/// other.
|
||||
#[derive(Default)]
|
||||
struct WatchRegistry {
|
||||
subs: Mutex<HashMap<u64, WatchEntry>>,
|
||||
@@ -740,8 +498,6 @@ struct WatchRegistry {
|
||||
|
||||
struct WatchEntry {
|
||||
tx: smol::channel::Sender<Vec<PathBuf>>,
|
||||
/// The directories currently watched. Kept so an overflow can be answered
|
||||
/// with a full re-report — see [`WatchRegistry::dispatch`].
|
||||
dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -766,18 +522,11 @@ impl WatchRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one server push. Runs on the reader thread, so it must not block —
|
||||
/// hence `try_send` into an unbounded channel.
|
||||
fn dispatch(&self, event: ControlEvent) {
|
||||
let (id, paths) = match event {
|
||||
ControlEvent::Watch { id, paths } => {
|
||||
(id, paths.into_iter().map(PathBuf::from).collect::<Vec<_>>())
|
||||
}
|
||||
// Overflow means "too many paths changed to enumerate". There is no
|
||||
// separate overflow signal on `WatchSub` — and there does not need
|
||||
// to be: reporting every watched directory as changed produces
|
||||
// exactly the behavior wanted, a re-listing of the whole watched
|
||||
// set, through the path the consumer already handles.
|
||||
ControlEvent::WatchOverflow { id } => {
|
||||
let dirs = self
|
||||
.subs
|
||||
@@ -787,7 +536,6 @@ impl WatchRegistry {
|
||||
.unwrap_or_default();
|
||||
(id, dirs)
|
||||
}
|
||||
// Not this layer's business; the workspace handles them.
|
||||
other => {
|
||||
log::trace!("control event not routed by RemoteHost: {other:?}");
|
||||
return;
|
||||
@@ -799,14 +547,11 @@ impl WatchRegistry {
|
||||
}
|
||||
let Ok(subs) = self.subs.lock() else { return };
|
||||
if let Some(entry) = subs.get(&id) {
|
||||
// A closed receiver means the `WatchSub` is being dropped; the
|
||||
// `WatchClose` is already on its way.
|
||||
let _ = entry.tx.try_send(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The implementation half of a remote [`WatchSub`].
|
||||
struct RemoteWatch {
|
||||
id: u64,
|
||||
client: Arc<ControlClient>,
|
||||
@@ -831,29 +576,12 @@ impl WatchHandle for RemoteWatch {
|
||||
impl Drop for RemoteWatch {
|
||||
fn drop(&mut self) {
|
||||
self.watches.remove(self.id);
|
||||
// Best effort: on a link that has already died there is nothing to tell,
|
||||
// and the server drops its watchers when the connection goes anyway.
|
||||
if self.client.is_connected() {
|
||||
let _ = self.client.call(ControlRequest::WatchClose { id: self.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keepalive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watch the link for silence.
|
||||
///
|
||||
/// Two separate jobs, which is why the thresholds differ: *prove* the link is
|
||||
/// alive when nothing else is using it (a ping after
|
||||
/// [`KEEPALIVE_IDLE_BEFORE_PING`] of quiet), and *declare it dead* when even
|
||||
/// that gets no answer ([`KEEPALIVE_DEAD_AFTER`], three ping intervals — two
|
||||
/// may be lost without a false positive). A busy connection proves itself and
|
||||
/// is never pinged.
|
||||
///
|
||||
/// Holds a `Weak`, so dropping the last `RemoteHost` ends the thread rather
|
||||
/// than keeping a connection alive for nobody.
|
||||
fn spawn_keepalive(client: Weak<ControlClient>) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("tty7-control-keepalive".into())
|
||||
@@ -877,8 +605,6 @@ fn spawn_keepalive(client: Weak<ControlClient>) {
|
||||
&& last_ping.elapsed() >= KEEPALIVE_PING_INTERVAL
|
||||
{
|
||||
last_ping = Instant::now();
|
||||
// A failed ping is not itself fatal — the deadline above is
|
||||
// what decides, so one lost packet doesn't drop a workspace.
|
||||
if let Err(e) = client.ping() {
|
||||
log::debug!("control keepalive ping failed: {e}");
|
||||
}
|
||||
@@ -887,10 +613,6 @@ fn spawn_keepalive(client: Weak<ControlClient>) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -929,8 +651,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted peer: every request it receives is forwarded to `seen`, and
|
||||
/// answered by `answer`.
|
||||
fn host_with_peer<F>(
|
||||
separator: char,
|
||||
answer: F,
|
||||
@@ -955,9 +675,6 @@ mod tests {
|
||||
let (req_id, req) = match ControlClientMsg::read(&mut sock) {
|
||||
Ok(ControlClientMsg::Request { req_id, req }) => (req_id, req),
|
||||
Ok(ControlClientMsg::RequestBlob { req_id, req, blob }) => {
|
||||
// Echo the blob back through the seen channel by way of
|
||||
// the request itself: tests that care assert on it via
|
||||
// a closure over their own state.
|
||||
let _ = blob;
|
||||
(req_id, req)
|
||||
}
|
||||
@@ -996,9 +713,6 @@ mod tests {
|
||||
(host, seen_rx)
|
||||
}
|
||||
|
||||
/// `git_lines` asks the peer to stream, and reassembles the chunks it pushes
|
||||
/// into lines. The id in the request is the one the client chose, which is
|
||||
/// what let it register the receiver before sending.
|
||||
#[test]
|
||||
fn git_lines_streams_over_the_wire() {
|
||||
let (host, seen) = host_with_peer_streaming();
|
||||
@@ -1022,10 +736,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer that serves `GitStream`: it answers the request, then pushes the
|
||||
/// output as chunks and a terminating `GitEnd`. Deliberately splits a line
|
||||
/// across two chunks, since that is the case the client's reassembly exists
|
||||
/// for.
|
||||
fn host_with_peer_streaming() -> (Arc<RemoteHost>, mpsc::Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -1082,24 +792,9 @@ mod tests {
|
||||
(host, seen_rx)
|
||||
}
|
||||
|
||||
/// A stream that goes quiet without ending gives up rather than parking
|
||||
/// forever.
|
||||
///
|
||||
/// This is the case nothing else on the client can see. Keepalive watches
|
||||
/// the *link*, and the link is fine — a server whose `git` is wedged on a
|
||||
/// network filesystem keeps answering pings. The request deadline was
|
||||
/// satisfied by the immediate `Unit` reply, long before any data. So without
|
||||
/// this the calling thread — one of a small pool — is parked for the life of
|
||||
/// the process, and the repo it was probing never gets another answer.
|
||||
///
|
||||
/// Driven through the extracted drain loop with a short idle so the test
|
||||
/// costs milliseconds instead of `GIT_STREAM_IDLE_TIMEOUT`.
|
||||
#[test]
|
||||
fn a_stream_that_goes_silent_times_out() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// A live sender that simply never speaks again — the wedged-server
|
||||
// shape. Dropping it would exercise `Disconnected` instead, which is a
|
||||
// different arm.
|
||||
tx.send(GitStreamMsg::Chunk(b"alpha\n".to_vec())).unwrap();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
@@ -1119,15 +814,11 @@ mod tests {
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
/// The idle timer measures the gap *between* chunks, not the stream's total
|
||||
/// length: a slow-but-alive read must be allowed to take as long as it
|
||||
/// takes, which is why a total deadline would be the wrong instrument.
|
||||
#[test]
|
||||
fn a_slow_stream_outlives_its_idle_timeout() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let idle = Duration::from_millis(150);
|
||||
thread::spawn(move || {
|
||||
// Five gaps, each under the idle limit; together well past it.
|
||||
for i in 0..5 {
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
let _ = tx.send(GitStreamMsg::Chunk(format!("line {i}\n").into_bytes()));
|
||||
@@ -1152,20 +843,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A stream whose drainer falls far enough behind is cut loose instead of
|
||||
/// being queued without limit.
|
||||
///
|
||||
/// This is the bound that makes the streaming path's memory claim true on a
|
||||
/// *remote* host. The read is incremental on both ends — 64 KiB at the
|
||||
/// server, one line at the client — but between them sits a queue the reader
|
||||
/// thread never waits on, and it cannot wait on it: that thread serves the
|
||||
/// whole connection, so parking it there would stall every other reply and
|
||||
/// the keepalive with it. Unbounded, a peer pushing faster than this client
|
||||
/// parses rebuilds the whole-diff peak in the channel, which is the one thing
|
||||
/// the buffered read was replaced to avoid.
|
||||
///
|
||||
/// Driven through `dispatch`, not by hand, because the accounting is split
|
||||
/// across the two threads and only their pairing is worth asserting.
|
||||
#[test]
|
||||
fn a_stream_that_outruns_its_queue_budget_is_cut_loose() {
|
||||
let registry = GitStreamRegistry::default();
|
||||
@@ -1179,8 +856,6 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
// Nobody is draining, so every chunk is arrears. One megabyte at a time
|
||||
// to keep the test's own allocation modest.
|
||||
let chunk = vec![b'x'; 1024 * 1024];
|
||||
let pushes = GIT_STREAM_QUEUE_BUDGET / chunk.len() + 2;
|
||||
for _ in 0..pushes {
|
||||
@@ -1194,15 +869,11 @@ mod tests {
|
||||
"the arrears stopped growing at the budget, not at the diff's size"
|
||||
);
|
||||
|
||||
// The reader thread also stops routing to it, so a stream that keeps
|
||||
// arriving cannot queue behind the notice.
|
||||
registry.dispatch(ControlEvent::GitChunk {
|
||||
id: 1,
|
||||
bytes: chunk.clone(),
|
||||
});
|
||||
|
||||
// What the drainer sees: the chunks that fit, then the overrun, and an
|
||||
// error rather than a short read reported as a success.
|
||||
let mut lines = Vec::new();
|
||||
let err = drain_git_stream(&rx, &queued, Duration::from_secs(5), &mut |l| {
|
||||
lines.push(l.to_string())
|
||||
@@ -1211,15 +882,6 @@ mod tests {
|
||||
assert!(err.to_string().contains("outran"), "{err}");
|
||||
}
|
||||
|
||||
/// The budget must not fire on a stream that is merely *large*. It bounds
|
||||
/// how far the consumer may fall behind, not how much may cross — a drainer
|
||||
/// keeping up returns the arrears as fast as they are charged, so a diff of
|
||||
/// any size passes through a queue that never grows.
|
||||
///
|
||||
/// The feeder throttles itself on the same counter the reader thread charges,
|
||||
/// which is what "a consumer keeping up" means here and is what keeps this
|
||||
/// test a statement about the accounting rather than a race between two
|
||||
/// threads' speeds.
|
||||
#[test]
|
||||
fn a_large_but_drained_stream_never_trips_the_budget() {
|
||||
let registry = Arc::new(GitStreamRegistry::default());
|
||||
@@ -1233,8 +895,6 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
// Twice the budget in total, in 1 MiB chunks, never more than 4 MiB of it
|
||||
// outstanding at once.
|
||||
let feeder = Arc::clone(®istry);
|
||||
let feeder_queued = Arc::clone(&queued);
|
||||
let chunks = GIT_STREAM_QUEUE_BUDGET / (1024 * 1024) * 2;
|
||||
@@ -1243,7 +903,7 @@ mod tests {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while feeder_queued.load(Ordering::Acquire) > 4 * 1024 * 1024 {
|
||||
if Instant::now() > deadline {
|
||||
break; // the drainer is wedged; let the assertions say so
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
@@ -1266,14 +926,6 @@ mod tests {
|
||||
assert_eq!(queued.load(Ordering::Acquire), 0, "the arrears settled");
|
||||
}
|
||||
|
||||
/// A link that dies mid-stream ends the read with an error rather than
|
||||
/// parking the thread that was draining it.
|
||||
///
|
||||
/// The regression this guards is a *hang*, not a wrong answer, so the call
|
||||
/// is made on its own thread and the assertion is on it having returned at
|
||||
/// all. A stream is answered by pushes, so the failure path every other
|
||||
/// method relies on — a deadline, or `fail_all` emptying `pending` — has
|
||||
/// nothing to fail here; only closing the stream's own channel wakes it.
|
||||
#[test]
|
||||
fn a_link_that_dies_mid_stream_ends_the_read() {
|
||||
let host = host_with_peer_dying_mid_stream();
|
||||
@@ -1294,8 +946,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer that accepts a `GitStream`, pushes part of it, and then hangs up
|
||||
/// without a `GitEnd` — an SSH link dropping mid-diff.
|
||||
fn host_with_peer_dying_mid_stream() -> Arc<RemoteHost> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -1329,9 +979,6 @@ mod tests {
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
sock.flush().unwrap();
|
||||
// Dropping the socket here is the whole point: the client is now
|
||||
// waiting on chunks that will never come, with the reply it was
|
||||
// told to wait for already delivered.
|
||||
});
|
||||
|
||||
let sock = TcpStream::connect(addr).unwrap();
|
||||
@@ -1343,10 +990,6 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The dropdown of a remote window is built from the *server's* shells.
|
||||
/// This is the whole point: a menu filled from the client's `/etc/shells`
|
||||
/// offers `/bin/zsh` on a box whose zsh lives elsewhere, and every pick
|
||||
/// fails to spawn.
|
||||
#[test]
|
||||
fn shells_come_from_the_peer() {
|
||||
let (host, seen) = host_with_peer('/', |req| match req {
|
||||
@@ -1370,9 +1013,6 @@ mod tests {
|
||||
assert_eq!(inv.shells[0].program, "/usr/bin/zsh");
|
||||
}
|
||||
|
||||
/// Path arithmetic follows the *peer's* separator, not the client's. On a
|
||||
/// Windows client this is the difference between `/home/me/src` and
|
||||
/// `/home/me\src`, and between "absolute" and "drive-relative".
|
||||
#[test]
|
||||
fn path_arithmetic_follows_the_peer_not_the_client() {
|
||||
let (host, _seen) =
|
||||
@@ -1393,8 +1033,6 @@ mod tests {
|
||||
assert!(!host.is_absolute(Path::new("C:/home")));
|
||||
}
|
||||
|
||||
/// A remote Windows host gets Windows semantics — the separator is a
|
||||
/// property of the peer, so this must work in both directions.
|
||||
#[test]
|
||||
fn a_windows_peer_gets_windows_path_semantics() {
|
||||
let (host, _seen) =
|
||||
@@ -1411,8 +1049,6 @@ mod tests {
|
||||
assert!(!host.is_absolute(Path::new("src\\main.rs")));
|
||||
}
|
||||
|
||||
/// Each read method sends the request its name implies and unwraps the
|
||||
/// reply's payload — one round trip, no probing, no second call.
|
||||
#[test]
|
||||
fn read_methods_map_to_one_request_each() {
|
||||
let (host, seen) = host_with_peer('/', |req| {
|
||||
@@ -1470,8 +1106,6 @@ mod tests {
|
||||
let sent: Vec<_> = (0..7).map(|_| seen.recv().unwrap()).collect();
|
||||
assert!(matches!(sent[0], ControlRequest::ReadDir { .. }));
|
||||
assert!(matches!(sent[1], ControlRequest::Stat { .. }));
|
||||
// `exists` must not degrade into a `stat`: that would ship metadata
|
||||
// across an ocean to answer a yes/no question.
|
||||
assert!(matches!(sent[2], ControlRequest::Exists { .. }));
|
||||
assert!(matches!(sent[3], ControlRequest::Canonicalize { .. }));
|
||||
assert!(matches!(sent[4], ControlRequest::RepoRoot { .. }));
|
||||
@@ -1479,9 +1113,6 @@ mod tests {
|
||||
assert!(matches!(sent[6], ControlRequest::Git { .. }));
|
||||
}
|
||||
|
||||
/// `rename` states its intent once and trusts the server's `AlreadyExists`
|
||||
/// guarantee — a client-side `exists` probe first would be an extra round
|
||||
/// trip *and* racy.
|
||||
#[test]
|
||||
fn mutations_are_a_single_request_with_no_probe() {
|
||||
let (host, seen) = host_with_peer('/', |req| {
|
||||
@@ -1517,9 +1148,6 @@ mod tests {
|
||||
assert_eq!(seen.try_recv().ok(), None, "no probing round trips");
|
||||
}
|
||||
|
||||
/// `read_file` gets its content from the reply's blob, and `write_file`
|
||||
/// puts its content in the request's — the reason bulk frames carry a JSON
|
||||
/// head at all is that the path and metadata travel beside the bytes.
|
||||
#[test]
|
||||
fn file_contents_ride_the_blob_in_both_directions() {
|
||||
let content: Vec<u8> = (0..=255u8).cycle().take(70_000).collect();
|
||||
@@ -1540,9 +1168,6 @@ mod tests {
|
||||
h.write_file(Path::new("/f"), &content).unwrap();
|
||||
}
|
||||
|
||||
/// An oversize file is refused by the server *before* the bytes move. The
|
||||
/// error has to arrive as `FileTooLarge` so the editor can say so rather
|
||||
/// than showing a generic failure.
|
||||
#[test]
|
||||
fn read_file_over_the_limit_fails_without_transferring() {
|
||||
let (host, _seen) = host_with_peer('/', |_| {
|
||||
@@ -1561,9 +1186,6 @@ mod tests {
|
||||
assert!(e.to_string().contains("900 MB"));
|
||||
}
|
||||
|
||||
/// A non-zero git exit is `Ok`. This is the invariant that lets every
|
||||
/// existing git call site keep its `Option`/`Result<String, String>` shape
|
||||
/// unchanged when the repo moves to another machine.
|
||||
#[test]
|
||||
fn a_nonzero_git_exit_is_ok_not_err() {
|
||||
let (host, _seen) = host_with_peer('/', |_| {
|
||||
@@ -1582,9 +1204,6 @@ mod tests {
|
||||
assert_eq!(out.stderr_trimmed(), "not a git repository");
|
||||
}
|
||||
|
||||
/// The id comes from the *connection*, not the workspace, so two workspaces
|
||||
/// on one machine share a host id — and therefore share its git-status
|
||||
/// cache instead of each maintaining a private one.
|
||||
#[test]
|
||||
fn the_host_id_is_derived_from_the_connection_and_is_stable() {
|
||||
let (a, _sa) = host_with_peer('/', |_| Some((ControlReply::Ok(ReplyOk::Unit), vec![])));
|
||||
@@ -1595,8 +1214,6 @@ mod tests {
|
||||
assert_eq!(a.id(), HostId::from_connection_key("ssh-alias:testbox"));
|
||||
}
|
||||
|
||||
/// Watch events reach the subscription's channel, and `set_dirs` replaces
|
||||
/// the set in place rather than rebuilding the subscription.
|
||||
#[test]
|
||||
fn watch_events_reach_the_subscription() {
|
||||
let (host, seen) = host_with_peer('/', |req| match req {
|
||||
@@ -1615,7 +1232,6 @@ mod tests {
|
||||
ControlRequest::WatchOpen { .. }
|
||||
));
|
||||
|
||||
// A push routed by id lands as a batch on the subscription.
|
||||
host.watches.dispatch(ControlEvent::Watch {
|
||||
id: 7,
|
||||
paths: vec!["/p/a".into(), "/p/b".into()],
|
||||
@@ -1625,7 +1241,6 @@ mod tests {
|
||||
vec![PathBuf::from("/p/a"), PathBuf::from("/p/b")]
|
||||
);
|
||||
|
||||
// An event for an id nobody holds is dropped, not delivered elsewhere.
|
||||
host.watches.dispatch(ControlEvent::Watch {
|
||||
id: 999,
|
||||
paths: vec!["/elsewhere".into()],
|
||||
@@ -1639,8 +1254,6 @@ mod tests {
|
||||
ControlRequest::WatchSet { .. }
|
||||
));
|
||||
|
||||
// Overflow re-reports the whole watched set, which is how "invalidate
|
||||
// everything" reaches a consumer that only understands path batches.
|
||||
host.watches.dispatch(ControlEvent::WatchOverflow { id: 7 });
|
||||
assert_eq!(
|
||||
sub.events().recv_blocking().unwrap(),
|
||||
@@ -1649,8 +1262,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropping the subscription unsubscribes on the server, rather than
|
||||
/// leaving a watcher running for a client that stopped caring.
|
||||
#[test]
|
||||
fn dropping_the_subscription_closes_it_on_the_server() {
|
||||
let (host, seen) = host_with_peer('/', |req| match req {
|
||||
@@ -1672,11 +1283,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A host whose link has died reports it, so call sites can keep showing
|
||||
/// the last good listing instead of flashing an error at every repaint.
|
||||
#[test]
|
||||
fn a_dead_link_reports_disconnected() {
|
||||
let (host, _seen) = host_with_peer('/', |_| None); // answers nothing, then hangs up
|
||||
let (host, _seen) = host_with_peer('/', |_| None);
|
||||
let h: &dyn Host = host.as_ref();
|
||||
assert!(h.is_connected());
|
||||
let e = h.stat(Path::new("/f")).unwrap_err();
|
||||
@@ -1684,8 +1293,6 @@ mod tests {
|
||||
assert!(!h.is_connected());
|
||||
}
|
||||
|
||||
/// A reply of the wrong shape is called out as a peer bug rather than
|
||||
/// being silently read as an empty result.
|
||||
#[test]
|
||||
fn a_reply_of_the_wrong_shape_is_invalid_data() {
|
||||
let (host, _seen) =
|
||||
@@ -1695,8 +1302,6 @@ mod tests {
|
||||
assert!(e.to_string().contains("Pong"));
|
||||
}
|
||||
|
||||
/// `RemoteHost` is usable as `Arc<dyn Host>` — object safety is not an
|
||||
/// abstract property here, it is what the whole tree's `SharedHost` needs.
|
||||
#[test]
|
||||
fn remote_host_is_object_safe() {
|
||||
let (host, _seen) =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,3 @@
|
||||
//! tty7's framework-free core.
|
||||
//!
|
||||
//! Everything that has to run on a machine with no display lives here: the
|
||||
//! wire protocol, the session daemon (PTY ownership, replay rings, fan-out),
|
||||
//! the native SSH engine, and the parts of the domain model — config, session
|
||||
//! layout, shell/agent knowledge, git — that the GUI and the headless
|
||||
//! `tty7-server` must agree on byte for byte.
|
||||
//!
|
||||
//! **This crate must never depend on gpui.** That is the invariant the split
|
||||
//! exists to enforce;
|
||||
//! `cargo tree -p tty7-core | grep gpui` must stay empty. Where a type genuinely
|
||||
//! needs a gpui shape — `Config` as a `Global`, `WindowState` as a `Bounds`,
|
||||
//! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing
|
||||
//! layer on top.
|
||||
//!
|
||||
//! The module paths deliberately mirror what they were inside the old single
|
||||
//! crate (`crate::core::config`, `crate::daemon::protocol`), and the GUI crate
|
||||
//! re-exports them under the same names, so call sites read identically on
|
||||
//! either side of the boundary.
|
||||
|
||||
pub mod core;
|
||||
pub mod daemon;
|
||||
pub mod host;
|
||||
|
||||
@@ -1,49 +1,3 @@
|
||||
//! `tty7-server` — the tty7 session daemon with no GUI attached.
|
||||
//!
|
||||
//! This is the binary that runs on the machine a *remote* workspace lives on.
|
||||
//! It runs the same
|
||||
//! `daemon::server` the local GUI auto-spawns, plus the control listener that
|
||||
//! backs a remote `Host`; the only difference from the GUI's daemon is that
|
||||
//! nothing on this side ever opens a window, which is why the code it needs had
|
||||
//! to leave the GUI crate first.
|
||||
//!
|
||||
//! | Command | Effect |
|
||||
//! |---|---|
|
||||
//! | `--daemon` | Serve panes *and* control connections in the foreground until killed |
|
||||
//! | `--stdio` | Carry one control connection on this process's stdin/stdout |
|
||||
//! | `agent-hook <agent> <event>` | Emit one agent sentinel event; the same code the GUI binary runs |
|
||||
//!
|
||||
//! # The two sockets
|
||||
//!
|
||||
//! `--daemon` listens twice, on purpose:
|
||||
//!
|
||||
//! | Dialect | Endpoint | Served by |
|
||||
//! |---|---|---|
|
||||
//! | Panes (`daemon::protocol`) | `<config-dir>/daemon.sock` | `daemon::server::run` |
|
||||
//! | Control (`daemon::control`) | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | `host::server` |
|
||||
//!
|
||||
//! They are separate because the roles are separate. A machine can back a remote
|
||||
//! workspace's file tree without hosting a single pane, and a pane daemon that
|
||||
//! predates the control dialect must keep working untouched. Folding control
|
||||
//! into the pane listener would have made every existing client's version
|
||||
//! negotiation answer for a feature it does not use.
|
||||
//!
|
||||
//! # `--stdio`
|
||||
//!
|
||||
//! Two jobs behind one flag, chosen by whether a control server is already
|
||||
//! listening on this machine:
|
||||
//!
|
||||
//! | Situation | Mode | Why |
|
||||
//! |---|---|---|
|
||||
//! | A `--daemon` is up | **bridge** — copy bytes between stdio and its socket | One server per machine owns the state; a second one would fork it |
|
||||
//! | Nothing is listening | **serve** — answer control requests here | A box with no daemon still has to be reachable |
|
||||
//!
|
||||
//! `--bridge` and `--serve` force one or the other. The auto choice is what
|
||||
//! makes `ssh host tty7-server --stdio` work whether or not the remote already
|
||||
//! had a daemon, which is the fallback path for
|
||||
//! `AllowStreamLocalForwarding no`, the only path under WSL, and how the
|
||||
//! end-to-end conformance test reaches a real server without an sshd.
|
||||
|
||||
use std::io;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -72,11 +26,6 @@ OPTIONS:
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// The agent-hook emitter runs before anything else touches config, logging
|
||||
// or the crash handler: it is a fire-and-forget child of an agent's hook
|
||||
// runner that must stay silent and exit fast, and the same code the GUI
|
||||
// binary runs for `tty7 agent-hook`. Only the binary that carries it
|
||||
// changed — a remote machine has a `tty7-server` and no `tty7`.
|
||||
if args.first().map(String::as_str) == Some("agent-hook") {
|
||||
if let (Some(agent), Some(event)) = (args.get(1), args.get(2)) {
|
||||
tty7_core::core::agent_hooks::run_agent_hook(agent, event);
|
||||
@@ -88,12 +37,6 @@ fn main() -> ExitCode {
|
||||
println!("tty7-server {}", env!("CARGO_PKG_VERSION"));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
// Before the config dir, the crash handler and the logger, like `--version`:
|
||||
// a client asking what this binary speaks must not touch the machine's
|
||||
// state, and must answer even on a box where the config dir is unwritable.
|
||||
//
|
||||
// One line of JSON on stdout, because the reader is a client parsing SSH
|
||||
// output rather than a person (`install::RemoteProtocol::parse`).
|
||||
if args
|
||||
.iter()
|
||||
.any(|a| a == tty7_core::daemon::install::PROTOCOL_FLAG)
|
||||
@@ -109,26 +52,15 @@ fn main() -> ExitCode {
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// Resolve the config-dir override before anything touches config, session,
|
||||
// or the socket path — they all resolve under it, so the order matters.
|
||||
// Same parsing as the GUI's `apply_config_dir_arg`.
|
||||
apply_config_dir_arg(&args);
|
||||
|
||||
// Panics in the server are recorded to `crash.log` in the config dir, for
|
||||
// the same reason the GUI does it: on a headless box there is no console to
|
||||
// read a backtrace off, and the process that notices the crash is a client
|
||||
// on the other end of a socket.
|
||||
tty7_core::core::crash::install("server");
|
||||
// Same reasoning for the ordinary log records: a headless box has no
|
||||
// console, and the client that notices a problem is on the far end of a
|
||||
// socket. Off unless `TTY7_LOG` asks for it.
|
||||
tty7_core::core::logfile::install("server");
|
||||
|
||||
if args.iter().any(|a| a == "--stdio") {
|
||||
return match run_stdio(&args) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
// stderr, never stdout: stdout is the protocol.
|
||||
eprintln!("tty7-server: stdio session ended with error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
@@ -143,12 +75,6 @@ fn main() -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// Serve panes and control connections until killed.
|
||||
///
|
||||
/// The whole of it lives in [`tty7_core::daemon::server::run_daemon`], shared
|
||||
/// verbatim with `tty7 --daemon`: local and remote machines run the identical
|
||||
/// daemon, which is what makes "one machine = one daemon = one workspace tree"
|
||||
/// a fact rather than a convention.
|
||||
fn run_daemon() -> ExitCode {
|
||||
if let Err(e) = tty7_core::daemon::server::run_daemon() {
|
||||
eprintln!("tty7-server: daemon exited with error: {e}");
|
||||
@@ -157,7 +83,6 @@ fn run_daemon() -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Carry one control connection on this process's stdin/stdout.
|
||||
fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
@@ -200,9 +125,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
None => server::control_socket_path()?,
|
||||
};
|
||||
|
||||
// Probe unless told which mode to use. Connecting is the only way to
|
||||
// tell a live server from a socket file a crash left behind, and it is
|
||||
// also exactly the connection the bridge would have made anyway.
|
||||
let upstream = if force_serve {
|
||||
None
|
||||
} else {
|
||||
@@ -214,20 +136,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
"no control server at {} ({e})",
|
||||
sock.display()
|
||||
));
|
||||
// One control server per machine, started if nobody has —
|
||||
// the same rule `bridge_panes` follows one dialect over,
|
||||
// and for the same reason. Two `--stdio` sessions both
|
||||
// falling through to serving in-process would each hold
|
||||
// their own `MachineStore` over the one file, and
|
||||
// `persist` writes the whole document: the second to save
|
||||
// silently drops the first's changes. Their attachment
|
||||
// registries would be separate too, which makes design
|
||||
// the takeover a no-op between them — both clients would
|
||||
// hold the same workspace and neither would be told.
|
||||
//
|
||||
// Not attempted when the caller named a socket: starting a
|
||||
// daemon binds the machine's default endpoint, not theirs,
|
||||
// so it would be a daemon nobody asked for and nobody uses.
|
||||
if may_start_daemon(args) {
|
||||
match spawn::ensure_running()
|
||||
.map_err(io::Error::other)
|
||||
@@ -255,8 +163,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
match upstream {
|
||||
Some(s) => bridge(s),
|
||||
None => {
|
||||
// Takes stdin/stdout away from the rest of the process before a
|
||||
// single frame is written — see `StdioDuplex::take`.
|
||||
let link = StdioDuplex::take()?;
|
||||
server::serve_with(
|
||||
link,
|
||||
@@ -268,24 +174,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry one **pane** connection on stdin/stdout, bridged to this machine's pane
|
||||
/// socket.
|
||||
///
|
||||
/// # Why panes need their own stdio mode
|
||||
///
|
||||
/// `--daemon` listens twice (see this module's header), and a routed connection
|
||||
/// is for exactly one of the two dialects. The control half already had a way in
|
||||
/// — plain `--stdio`. The pane half had none, which is why a remote workspace
|
||||
/// could browse a file tree and could not open a single terminal.
|
||||
///
|
||||
/// # Why it always bridges and never serves
|
||||
///
|
||||
/// Panes are *state*. Serving them in this process would give every routed
|
||||
/// connection its own registry, so a pane would die with the window that opened
|
||||
/// it and `List` would never see anything anyone else spawned — the exact
|
||||
/// failure `install::wsl::ensure_wsl_server`'s doc warns about, one layer down.
|
||||
/// There is one pane daemon per machine and this connects to it, starting it
|
||||
/// first if nobody has.
|
||||
#[cfg(unix)]
|
||||
fn bridge_panes() -> io::Result<()> {
|
||||
use tty7_core::daemon::{spawn, transport};
|
||||
@@ -293,10 +181,6 @@ fn bridge_panes() -> io::Result<()> {
|
||||
let upstream = match transport::connect() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// `ensure_running` re-execs *this* binary with `--daemon`, which is
|
||||
// what starts both listeners. Normally the install path has already
|
||||
// done it and this never runs; it covers the daemon dying between
|
||||
// that check and this connection.
|
||||
log_stderr(format_args!(
|
||||
"no pane daemon at {} ({e}); starting one",
|
||||
transport::endpoint_display()
|
||||
@@ -308,13 +192,6 @@ fn bridge_panes() -> io::Result<()> {
|
||||
bridge(upstream)
|
||||
}
|
||||
|
||||
/// Copy bytes between this process's stdio and an already-running control
|
||||
/// server, in both directions, until either side stops.
|
||||
///
|
||||
/// Deliberately dumb: it parses nothing. The version handshake this stream
|
||||
/// carries is between the *client* and the server at the far end, and a bridge
|
||||
/// that understood the frames would be a third opinion about
|
||||
/// the protocol version, which is exactly the coupling the design forbids.
|
||||
#[cfg(unix)]
|
||||
fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
use std::io::{Read as _, Write as _};
|
||||
@@ -323,21 +200,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
let mut up_read = upstream.try_clone()?;
|
||||
let mut up_write = upstream.try_clone()?;
|
||||
|
||||
// Upstream → stdout on this thread, stdin → upstream on another. Either
|
||||
// direction ending means the session is over, so whichever finishes first
|
||||
// shuts the socket down and the other returns immediately instead of
|
||||
// parking on a peer that will never speak again.
|
||||
//
|
||||
// **The feeder is never joined.** Shutting the socket down wakes a thread
|
||||
// blocked on *the socket*, but this one is blocked on `stdin`, and nothing
|
||||
// this process can do wakes that — the far end of the pipe is `ssh`, or a
|
||||
// parent that has no reason to close it. Joining it turns "the server hung
|
||||
// up" into a bridge that never exits and, worse, never closes its stdout, so
|
||||
// the client at the far end waits forever for an EOF that is sitting in this
|
||||
// process. Returning lets the process exit, which closes stdout, which is
|
||||
// the signal the client is actually waiting for. The takeover is
|
||||
// the case that made this visible: the server closes the displaced session's
|
||||
// link, and that has to reach the client through this bridge.
|
||||
let feeder_socket = upstream.try_clone()?;
|
||||
let feeder = std::thread::Builder::new()
|
||||
.name("tty7-stdio-bridge-in".into())
|
||||
@@ -354,9 +216,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
stdout.write_all(&buf[..n])?;
|
||||
// Flushed per read, not per buffer: a control reply is useless
|
||||
// sitting in a buffer waiting for the next one, and the peer is
|
||||
// blocked on it.
|
||||
stdout.flush()?;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
@@ -373,14 +232,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--flag <value>` or `--flag=<value>`, first occurrence wins.
|
||||
/// Whether a failed control probe may start the machine's daemon.
|
||||
///
|
||||
/// Only when the caller did not name a socket. `--control-sock` says "this
|
||||
/// endpoint", and `spawn::ensure_running` binds the machine's default one — so
|
||||
/// starting a daemon there would leave a process nobody asked for and nobody
|
||||
/// reaches. It is also what keeps the test suite, and any `--config-dir`
|
||||
/// isolation built on it, from spraying daemons across a developer's machine.
|
||||
fn may_start_daemon(args: &[String]) -> bool {
|
||||
flag_value(args, "--control-sock").is_none()
|
||||
}
|
||||
@@ -399,14 +250,10 @@ fn flag_value(args: &[String], flag: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// stderr only. In `--stdio` mode stdout belongs to the protocol, and the
|
||||
/// `log` crate has no sink configured in this binary.
|
||||
fn log_stderr(args: std::fmt::Arguments<'_>) {
|
||||
eprintln!("tty7-server: {args}");
|
||||
}
|
||||
|
||||
/// Honour `--config-dir <dir>` / `--config-dir=<dir>`, first occurrence wins —
|
||||
/// the same contract (and the same first-call-wins `set_config_dir`) as the GUI.
|
||||
fn apply_config_dir_arg(args: &[String]) {
|
||||
if let Some(path) = flag_value(args, "--config-dir") {
|
||||
tty7_core::core::config::set_config_dir(path.into());
|
||||
@@ -421,9 +268,6 @@ mod tests {
|
||||
args.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A named socket suppresses the daemon start, in both spellings of the
|
||||
/// flag. Without this guard every `--stdio` in the test suite that points
|
||||
/// at a temp socket would start a real daemon on the developer's machine.
|
||||
#[test]
|
||||
fn a_named_control_socket_suppresses_starting_a_daemon() {
|
||||
assert!(may_start_daemon(&argv(&[])));
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
//! The `tty7-server` command line: the three subcommands, and the two shapes
|
||||
//! `--stdio` takes.
|
||||
//!
|
||||
//! `stdio_conformance.rs` proves the *protocol* over `--stdio --serve`. This
|
||||
//! file proves the argument handling and the byte bridge — the mode that carries
|
||||
//! a connection to a control server that is already running, which is the path
|
||||
//! an `ssh host tty7-server --stdio` takes on a machine with a live daemon and
|
||||
//! which no amount of `Host` conformance would exercise.
|
||||
//!
|
||||
//! Everything `--stdio` is Unix-only — the flag is refused on Windows, where a
|
||||
//! machine is reached over its own transport rather than by shipping a server
|
||||
//! onto it. The plain argument handling below is not, and runs
|
||||
//! everywhere.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -50,7 +36,6 @@ impl LinkShutdown for ServerProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start `tty7-server --stdio <args>` and connect a `RemoteHost` to its pipes.
|
||||
#[cfg(unix)]
|
||||
fn stdio_child(args: &[&str]) -> io::Result<Arc<RemoteHost>> {
|
||||
let mut child = Command::new(EXE)
|
||||
@@ -67,7 +52,6 @@ fn stdio_child(args: &[&str]) -> io::Result<Arc<RemoteHost>> {
|
||||
RemoteHost::connect_with(out, inp, Some(closer), "stdio:cli", &hello)
|
||||
}
|
||||
|
||||
/// A control server on a temp socket, for the bridge to reach.
|
||||
#[cfg(unix)]
|
||||
fn listening_server(dir: &tempfile::TempDir) -> PathBuf {
|
||||
let sock = dir.path().join("control.sock");
|
||||
@@ -76,13 +60,6 @@ fn listening_server(dir: &tempfile::TempDir) -> PathBuf {
|
||||
sock
|
||||
}
|
||||
|
||||
/// **The bridge.** `--stdio --bridge` forwards bytes between its own pipes and a
|
||||
/// control server that is already listening, parsing nothing on the way.
|
||||
///
|
||||
/// That "parsing nothing" is the load-bearing part: the version handshake this
|
||||
/// stream carries belongs to the client and the server at the far end, and a
|
||||
/// bridge with an opinion about the protocol would become a third party to a
|
||||
/// negotiation it is not qualified to join.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_bridge_carries_a_whole_session() {
|
||||
@@ -97,22 +74,15 @@ fn the_bridge_carries_a_whole_session() {
|
||||
host.write_file(&f, b"two hops").unwrap();
|
||||
assert_eq!(host.read_file(&f, 1024).unwrap(), b"two hops");
|
||||
|
||||
// A payload big enough that it cannot arrive in one read, so the bridge's
|
||||
// copy loop is doing real work rather than passing a single buffer through.
|
||||
let big = host.join(sandbox.path(), "big.bin");
|
||||
let body: Vec<u8> = (0..2 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
|
||||
host.write_file(&big, &body).unwrap();
|
||||
assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body);
|
||||
|
||||
// Out-of-order replies survive the extra hop too: the bridge must not
|
||||
// serialize what the server took care to keep concurrent.
|
||||
let entries = host.read_dir(sandbox.path(), None).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
|
||||
/// `--bridge` with nowhere to bridge to fails rather than quietly serving
|
||||
/// itself. An operator who asked for the bridge is telling us a server exists;
|
||||
/// silently becoming that server would fork the machine's state in two.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_explicit_bridge_with_no_server_fails() {
|
||||
@@ -125,8 +95,6 @@ fn an_explicit_bridge_with_no_server_fails() {
|
||||
);
|
||||
}
|
||||
|
||||
/// With neither flag, `--stdio` probes: nothing listening means serve here, so a
|
||||
/// machine that has never run a daemon is still reachable over ssh.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_default_mode_serves_when_nothing_is_listening() {
|
||||
@@ -138,8 +106,6 @@ fn the_default_mode_serves_when_nothing_is_listening() {
|
||||
assert!(host.exists(sandbox.path()));
|
||||
}
|
||||
|
||||
/// ...and something listening means bridge to it, so a second `--stdio` session
|
||||
/// joins the machine's existing server instead of standing up a rival.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_default_mode_bridges_when_a_server_is_listening() {
|
||||
@@ -153,7 +119,6 @@ fn the_default_mode_bridges_when_a_server_is_listening() {
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"ok");
|
||||
}
|
||||
|
||||
/// Contradictory flags are refused rather than one silently winning.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn serve_and_bridge_together_are_refused() {
|
||||
@@ -170,12 +135,6 @@ fn serve_and_bridge_together_are_refused() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `agent-hook` runs the same emitter the GUI binary does, and stays quiet.
|
||||
///
|
||||
/// Quiet is the requirement, not a nicety: this runs as a child of an agent's
|
||||
/// hook runner, and anything it prints lands in the agent's own transcript. With
|
||||
/// no controlling terminal there is nowhere to emit to, and it still has to
|
||||
/// succeed — a hook that fails is a hook the agent reports as broken.
|
||||
#[test]
|
||||
fn agent_hook_is_quiet_and_succeeds() {
|
||||
let out = Command::new(EXE)
|
||||
@@ -187,8 +146,6 @@ fn agent_hook_is_quiet_and_succeeds() {
|
||||
assert!(out.stdout.is_empty(), "agent-hook wrote to stdout");
|
||||
}
|
||||
|
||||
/// A malformed `agent-hook` invocation is still silent and still succeeds — the
|
||||
/// emitter's whole contract is that it never becomes the agent's problem.
|
||||
#[test]
|
||||
fn agent_hook_without_arguments_still_succeeds() {
|
||||
let out = Command::new(EXE)
|
||||
@@ -214,7 +171,6 @@ fn version_and_help_report_on_stdout() {
|
||||
}
|
||||
}
|
||||
|
||||
/// No arguments is a usage error, not a process that sits there doing nothing.
|
||||
#[test]
|
||||
fn no_arguments_is_a_usage_error() {
|
||||
let out = Command::new(EXE).output().unwrap();
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
//! The machine-owned workspace tree, end to end against a real `tty7-server`
|
||||
//! child process.
|
||||
//!
|
||||
//! The client is the shipped `ControlClient`, the wire is the control dialect
|
||||
//! over real pipes, and the server is the shipped binary owning its tree in a
|
||||
//! file. What the process boundary buys here specifically:
|
||||
//!
|
||||
//! | | Why an in-process store would not do |
|
||||
//! |---|---|
|
||||
//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership |
|
||||
//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up |
|
||||
//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push |
|
||||
//!
|
||||
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
|
||||
//! another's leftovers and nothing here can touch a developer's real tree.
|
||||
|
||||
// Unix-only: the server under test is a `--stdio` child, and the two-client
|
||||
// case stands up a control socket.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io;
|
||||
@@ -30,8 +12,6 @@ use tty7_core::daemon::control::{
|
||||
feature,
|
||||
};
|
||||
|
||||
/// The child, and the only way to end it — a process-backed link is reaped by
|
||||
/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
@@ -47,7 +27,6 @@ impl LinkShutdown for ServerProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected client: the RPC channel, plus everything the server pushed.
|
||||
struct Client {
|
||||
control: ControlClient,
|
||||
events: Arc<Mutex<Vec<ControlEvent>>>,
|
||||
@@ -55,9 +34,6 @@ struct Client {
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Wait for a `Layout` delta about `workspace` matching `want`, or fail
|
||||
/// saying what did arrive. Polled because a push and the reply that caused
|
||||
/// it race by construction.
|
||||
fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) {
|
||||
let key = workspace.to_string();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
@@ -90,9 +66,6 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and
|
||||
/// connect a client to it. `--serve` for the same reason as everywhere else in
|
||||
/// these tests: a developer's real daemon must never be bridged into.
|
||||
fn connect(data_dir: &Path, token: &str) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--serve"])
|
||||
@@ -145,10 +118,6 @@ fn seed(pane: u64, cwd: &str) -> PaneSeed {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The capability bit is the client's cue that the tree verbs are worth a
|
||||
/// round trip, and it has to reflect what the shipped binary wired up.
|
||||
#[test]
|
||||
fn the_server_advertises_the_machine_tree() {
|
||||
let dir = data_dir();
|
||||
@@ -163,15 +132,11 @@ fn the_server_advertises_the_machine_tree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The semantic operations against a real server, and the tree ends up in a
|
||||
/// file that server owns. This is "the daemon owns the structure" as a
|
||||
/// syscall someone else made, not as a diagram.
|
||||
#[test]
|
||||
fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "ops");
|
||||
|
||||
// Build: a workspace, a tab, a split.
|
||||
let ws = match client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceCreate {
|
||||
@@ -208,7 +173,6 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
||||
})
|
||||
.expect("split");
|
||||
|
||||
// Read back through the wire.
|
||||
let machine = match client.control.call(ControlRequest::MachineGet).unwrap() {
|
||||
ReplyOk::MachineTree(m) => *m,
|
||||
other => panic!("expected MachineTree, got {other:?}"),
|
||||
@@ -222,11 +186,9 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
||||
"panes this server was told about in its own lifetime are live"
|
||||
);
|
||||
|
||||
// The file is the server's: the test process never wrote it.
|
||||
let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree");
|
||||
assert!(text.contains(&ws.id.to_string()), "{text}");
|
||||
|
||||
// A refusal is a client-visible error, not a dropped reply.
|
||||
let missing = client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceTree {
|
||||
@@ -236,9 +198,6 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
||||
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
/// **The revival contract, across a real restart.** A second server process
|
||||
/// reads the first one's tree; every pane in it is dead (`live == false`), the
|
||||
/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor.
|
||||
#[test]
|
||||
fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() {
|
||||
let dir = data_dir();
|
||||
@@ -268,7 +227,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors(
|
||||
ws
|
||||
};
|
||||
|
||||
// A brand-new server process over the same file.
|
||||
let second = connect(dir.path(), "second");
|
||||
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
|
||||
ReplyOk::MachineTree(m) => *m,
|
||||
@@ -291,7 +249,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors(
|
||||
"the leaf still names the dead pane — the revival slot"
|
||||
);
|
||||
|
||||
// Revive: a fresh pane takes the leaf, the spent record goes.
|
||||
second
|
||||
.control
|
||||
.call(ControlRequest::PaneReplace {
|
||||
@@ -311,9 +268,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors(
|
||||
assert!(machine.panes.iter().all(|p| p.id != 7));
|
||||
}
|
||||
|
||||
/// Two clients on one server. An operation by one reaches the other as a
|
||||
/// `Layout` delta and never comes back to its author — the mechanism that
|
||||
/// replaces whole-record last-writer-wins with edits that all land.
|
||||
#[test]
|
||||
fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
use tty7_core::host::local::LocalHost;
|
||||
@@ -342,8 +296,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
.iter()
|
||||
.any(|f| f == feature::MACHINE_TREE)
|
||||
);
|
||||
// Make sure the watcher's subscription is up (its server thread subscribes
|
||||
// before answering its first request).
|
||||
watcher.control.call(ControlRequest::Ping).unwrap();
|
||||
|
||||
let ws = match writer
|
||||
@@ -379,8 +331,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id),
|
||||
);
|
||||
// The created tab became active, and the *change of active tab* is its own
|
||||
// delta — implicit activation must not be something a client re-derives.
|
||||
watcher.expect_delta(
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id),
|
||||
@@ -391,7 +341,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
"a client must not be pushed its own operation"
|
||||
);
|
||||
|
||||
// …and the rule holds in the other direction.
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::TabRename {
|
||||
@@ -407,10 +356,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops");
|
||||
}
|
||||
|
||||
/// Takeover semantics on the new tree, with **no record store served at
|
||||
/// all**: the attach verbs predate the tree, and their contract — newcomer
|
||||
/// wins, the displaced session is told, a stale detach cannot evict the
|
||||
/// usurper — must survive the record store's retirement.
|
||||
#[test]
|
||||
fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
use tty7_core::host::local::LocalHost;
|
||||
@@ -452,8 +397,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
"the tree's own record says who holds the workspace"
|
||||
);
|
||||
|
||||
// The newcomer wins, learns whom it displaced, and the displaced session
|
||||
// is pushed a Preempted notice.
|
||||
match attach(&desktop).expect("takeover") {
|
||||
ReplyOk::Attached { took_over_from } => {
|
||||
assert_eq!(took_over_from.as_deref(), Some("laptop"));
|
||||
@@ -473,7 +416,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
// The preempted session tidying up must not evict the usurper.
|
||||
laptop
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDetach {
|
||||
@@ -486,8 +428,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `--stdio --bridge` child connected to an already-listening control
|
||||
/// socket — the two-hop shape a real multi-client machine has.
|
||||
fn bridged(sock: &Path, token: &str) -> Client {
|
||||
let hello = ControlHello::host_rpc(token, token);
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
//! The local daemon's [`RemoteRouter`] in front of a real `tty7-server`, and
|
||||
//! the remote socket path the two sides have to agree on.
|
||||
//!
|
||||
//! `stdio_conformance.rs` proves the protocol over `--stdio`; `cli.rs` proves
|
||||
//! the server's own byte bridge. This file proves the hop *before* both of
|
||||
//! them — the one where a GUI's local connection is handed to a machine that is
|
||||
//! not this one — over the `--stdio` fallback, which is the transport a host
|
||||
//! with `AllowStreamLocalForwarding no` gets and the only one that can be
|
||||
//! exercised without an sshd.
|
||||
//!
|
||||
//! The `direct-streamlocal` half deliberately has no test here: it needs a
|
||||
//! running sshd with the option flipped both ways. What *is* testable is the
|
||||
//! decision between them, which lives in `remote_link::choose_entry` and is
|
||||
//! unit-tested there.
|
||||
|
||||
// Unix-only: the hub this stands up is a Unix-domain socket, which is also the
|
||||
// only shape the remote side of a routed connection takes. The
|
||||
// Windows client reaches a *remote* server the same way; it is the local hop
|
||||
// that differs, and `daemon::router` covers that with its own `cfg`.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -31,17 +12,12 @@ use tty7_core::host::remote::RemoteHost;
|
||||
|
||||
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
|
||||
|
||||
/// **The fallback path, end to end.** A client connects to a local socket,
|
||||
/// names a target, and gets a `Host` backed by a `tty7-server` process it never
|
||||
/// spoke to directly — every byte of the control dialect crossing a router that
|
||||
/// does not know what a control frame is.
|
||||
#[test]
|
||||
fn a_routed_connection_reaches_a_real_server() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let hub = dir.path().join("hub.sock");
|
||||
let listener = UnixListener::bind(&hub).unwrap();
|
||||
|
||||
// The local daemon's side: accept, read the route header, forward forever.
|
||||
let router = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = stream.try_clone().unwrap();
|
||||
@@ -51,8 +27,6 @@ fn a_routed_connection_reaches_a_real_server() {
|
||||
RemoteRouter::route(stream, &header)
|
||||
});
|
||||
|
||||
// The client's side: one extra frame in front of an otherwise ordinary
|
||||
// control connection.
|
||||
let mut sock = UnixStream::connect(&hub).unwrap();
|
||||
let missing = dir.path().join("nobody-here.sock");
|
||||
let header = RouteHeader::local_stdio(
|
||||
@@ -73,9 +47,6 @@ fn a_routed_connection_reaches_a_real_server() {
|
||||
let host = RemoteHost::over_unix(sock, "routed:local-stdio", &hello)
|
||||
.expect("handshake through the router");
|
||||
|
||||
// The handshake itself already crossed the router in both directions; these
|
||||
// prove it keeps working for payloads that span many reads, which is where
|
||||
// a router that buffered or reframed would come apart.
|
||||
let sandbox = tempfile::TempDir::new().unwrap();
|
||||
let file = host.join(sandbox.path(), "through-the-router.txt");
|
||||
host.write_file(&file, b"two hops and a pipe").unwrap();
|
||||
@@ -86,20 +57,12 @@ fn a_routed_connection_reaches_a_real_server() {
|
||||
host.write_file(&big, &body).unwrap();
|
||||
assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body);
|
||||
|
||||
// Out-of-order replies survive the hop: the router must not serialize what
|
||||
// the server took care to keep concurrent.
|
||||
assert_eq!(host.read_dir(sandbox.path(), None).unwrap().len(), 2);
|
||||
|
||||
drop(host);
|
||||
let _ = router.join().unwrap();
|
||||
}
|
||||
|
||||
/// A route to a target that cannot be opened comes back as a *reason*.
|
||||
///
|
||||
/// Without the ack the client would see a socket that closed with no
|
||||
/// explanation, which for a remote workspace is the difference between "the
|
||||
/// binary isn't installed on that box" and a bug report saying "it doesn't
|
||||
/// work".
|
||||
#[test]
|
||||
fn an_unreachable_target_is_reported_not_dropped() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -127,17 +90,8 @@ fn an_unreachable_target_is_reported_not_dropped() {
|
||||
assert!(router.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
/// **The two sides derive the same path.** `remote_link::remote_control_socket`
|
||||
/// computes, from a remote's environment, the socket a `direct-streamlocal`
|
||||
/// channel is pointed at; `host::server::control_socket_path` computes, in the
|
||||
/// server process, the socket it binds. Nothing reconciles them at run time —
|
||||
/// a mismatch is a connection that fails with `connect failed` and no hint
|
||||
/// which side is wrong — so the agreement is checked against the real binary
|
||||
/// rather than asserted in prose.
|
||||
#[test]
|
||||
fn the_derived_remote_socket_is_the_one_the_server_binds() {
|
||||
// Both orders the server resolves: `$XDG_RUNTIME_DIR` when it has one, and
|
||||
// `$HOME/.local/share` when it does not (macOS, minimal containers).
|
||||
let with_runtime = tempfile::TempDir::new().unwrap();
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let runtime_path = with_runtime.path().to_string_lossy().to_string();
|
||||
@@ -162,8 +116,6 @@ fn the_derived_remote_socket_is_the_one_the_server_binds() {
|
||||
assert_eq!(derived.as_deref(), Some(bound.as_str()));
|
||||
}
|
||||
|
||||
/// Start `tty7-server --daemon` under a controlled environment and read back
|
||||
/// the control socket it actually bound (it prints it on stderr), then stop it.
|
||||
fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String {
|
||||
let config = tempfile::TempDir::new().unwrap();
|
||||
let mut cmd = Command::new(EXE);
|
||||
@@ -187,9 +139,6 @@ fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String {
|
||||
bound = Some(path.to_string());
|
||||
break;
|
||||
}
|
||||
// The listener reports its own failures on the same stream; a test that
|
||||
// silently timed out here would be far harder to read than one that
|
||||
// says what the server said.
|
||||
assert!(
|
||||
!line.contains("control listener unavailable"),
|
||||
"the server could not bind at all: {line}"
|
||||
|
||||
@@ -1,32 +1,3 @@
|
||||
//! **A remote workspace's pane, end to end, with no sshd and no network.**
|
||||
//!
|
||||
//! `remote_router.rs` proves the *control* dialect crosses the router; this file
|
||||
//! proves the other one — the pane protocol — which is the half a remote
|
||||
//! workspace needs before it can run anything at all. Until it existed a remote
|
||||
//! window opened, listed files, and could not spawn a terminal.
|
||||
//!
|
||||
//! ## What stands in for what
|
||||
//!
|
||||
//! | Real thing | Here |
|
||||
//! |---|---|
|
||||
//! | The GUI's `RemoteTerminal` | a `UnixStream` speaking `ClientMsg`/`DaemonMsg` |
|
||||
//! | The user's local daemon | a `UnixListener` + `RemoteRouter::route` |
|
||||
//! | The SSH channel | `RouteTarget::LocalStdio` → a child process |
|
||||
//! | The remote `tty7-server --daemon` | `tty7-server --stdio --pane` bridging to one |
|
||||
//!
|
||||
//! Only the middle hop is faked, and it is faked with the same
|
||||
//! `RemoteRouter::route` the daemon calls. Everything on the far side is the
|
||||
//! real binary: a real `--daemon` process, a real PTY, a real shell.
|
||||
//!
|
||||
//! ## Why `--config-dir` per test
|
||||
//!
|
||||
//! The "remote" pane daemon this stands up is a *real* daemon on this machine.
|
||||
//! Pointing it at a temp config dir gives it its own socket, so it can neither
|
||||
//! see nor be seen by the developer's own tty7 — and `Shutdown` at the end of
|
||||
//! each test reaps it rather than leaving one per CI run.
|
||||
|
||||
// Unix-only for the same reason `remote_router.rs` is: the hop being tested is a
|
||||
// Unix-domain socket, and `--stdio` is a Unix path by construction.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::Read;
|
||||
@@ -39,9 +10,6 @@ use tty7_core::daemon::router::{RemoteRouter, RouteChannel, RouteHeader, negotia
|
||||
|
||||
const EXE: &str = env!("CARGO_BIN_EXE_tty7-server");
|
||||
|
||||
/// How long a test waits for a shell to say something. Generous: a cold daemon
|
||||
/// launch plus a shell start on a loaded CI box is not instant, and a flaky
|
||||
/// timeout here would read as a routing bug.
|
||||
const OUTPUT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn win() -> WinSize {
|
||||
@@ -53,8 +21,6 @@ fn win() -> WinSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell with no startup files, so what comes back is the command's output and
|
||||
/// not somebody's prompt theme.
|
||||
fn plain_shell() -> ShellSpec {
|
||||
ShellSpec {
|
||||
program: "/bin/sh".to_string(),
|
||||
@@ -63,10 +29,6 @@ fn plain_shell() -> ShellSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand up the local hop: a socket that routes one connection and then returns.
|
||||
///
|
||||
/// One connection per hub, because that is exactly what the GUI does — a pane is
|
||||
/// a connection, and `handle_conn` hands each one to the router separately.
|
||||
fn hub(dir: &Path, name: &str) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
||||
let path = dir.join(name);
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
@@ -76,15 +38,11 @@ fn hub(dir: &Path, name: &str) -> (std::path::PathBuf, std::thread::JoinHandle<(
|
||||
let (kind, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap();
|
||||
assert_eq!(kind, tty7_core::daemon::router::ROUTE_KIND);
|
||||
let header = RouteHeader::decode(&payload).unwrap();
|
||||
// The far end outliving the near one is normal (the client hangs up
|
||||
// first), so a closed pipe here is not a failure.
|
||||
let _ = RemoteRouter::route(stream, &header);
|
||||
});
|
||||
(path, thread)
|
||||
}
|
||||
|
||||
/// The header a pane of a remote workspace writes, with this machine standing in
|
||||
/// for the remote.
|
||||
fn pane_header(config_dir: &Path) -> RouteHeader {
|
||||
RouteHeader::local_stdio(
|
||||
EXE,
|
||||
@@ -98,7 +56,6 @@ fn pane_header(config_dir: &Path) -> RouteHeader {
|
||||
.for_pane()
|
||||
}
|
||||
|
||||
/// Open a routed pane connection through a fresh hub.
|
||||
fn routed(dir: &Path, name: &str, config_dir: &Path) -> (UnixStream, std::thread::JoinHandle<()>) {
|
||||
let (path, thread) = hub(dir, name);
|
||||
let mut sock = UnixStream::connect(&path).unwrap();
|
||||
@@ -107,11 +64,6 @@ fn routed(dir: &Path, name: &str, config_dir: &Path) -> (UnixStream, std::thread
|
||||
(sock, thread)
|
||||
}
|
||||
|
||||
/// Read frames until `needle` shows up in the accumulated PTY bytes.
|
||||
///
|
||||
/// Accumulating rather than matching per frame is the point: a PTY splits output
|
||||
/// wherever it likes, and a test that expected one frame per line would pass or
|
||||
/// fail on scheduling.
|
||||
fn read_until(sock: &mut UnixStream, needle: &str) -> String {
|
||||
let deadline = Instant::now() + OUTPUT_TIMEOUT;
|
||||
let mut seen = String::new();
|
||||
@@ -134,35 +86,23 @@ fn read_until(sock: &mut UnixStream, needle: &str) -> String {
|
||||
panic!("timed out waiting for {needle:?}\nsaw: {seen:?}");
|
||||
}
|
||||
|
||||
/// Stop the daemon this test started, so it does not outlive the run.
|
||||
fn shutdown(dir: &Path, config_dir: &Path) {
|
||||
let (path, thread) = hub(dir, "shutdown.sock");
|
||||
if let Ok(mut sock) = UnixStream::connect(&path)
|
||||
&& negotiate(&mut sock, &pane_header(config_dir)).is_ok()
|
||||
{
|
||||
let _ = ClientMsg::Shutdown.encode(&mut sock);
|
||||
// The daemon exits without replying, so read to EOF rather than
|
||||
// expecting a frame.
|
||||
let _ = sock.read(&mut [0u8; 64]);
|
||||
}
|
||||
let _ = thread.join();
|
||||
}
|
||||
|
||||
/// **The milestone's proof.** Open a pane on the "remote", type at it, see what
|
||||
/// it printed, hang up, come back, and find the pane still there with its
|
||||
/// scrollback.
|
||||
///
|
||||
/// Every claim a remote workspace makes is in this one test: the pane exists on
|
||||
/// the far machine (it survives the connection that made it), the hot path
|
||||
/// crosses the router intact in both directions, and reattach finds the same
|
||||
/// pane rather than a new one.
|
||||
#[test]
|
||||
fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config = dir.path().join("remote-config");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
|
||||
// ---- connect, spawn ---------------------------------------------------
|
||||
let (mut sock, hub_thread) = routed(dir.path(), "pane-1.sock", &config);
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(dir.path().to_path_buf()),
|
||||
@@ -178,22 +118,15 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
||||
other => panic!("expected Spawned through the router, got {other:?}"),
|
||||
};
|
||||
|
||||
// ---- input → output ---------------------------------------------------
|
||||
// A marker no shell prompt would produce on its own, echoed by a command
|
||||
// that exists in every POSIX shell.
|
||||
ClientMsg::Input(b"echo rou''ted-pane-alive\n".to_vec())
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
read_until(&mut sock, "routed-pane-alive");
|
||||
|
||||
// ---- disconnect -------------------------------------------------------
|
||||
// `Detach`, not `Kill`: the pane is meant to keep running on the far side,
|
||||
// which is the entire proposition of a remote workspace.
|
||||
ClientMsg::Detach.encode(&mut sock).unwrap();
|
||||
drop(sock);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// ---- reconnect --------------------------------------------------------
|
||||
let (mut back, hub_thread) = routed(dir.path(), "pane-2.sock", &config);
|
||||
ClientMsg::Attach {
|
||||
pane_id,
|
||||
@@ -202,11 +135,8 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
||||
.encode(&mut back)
|
||||
.unwrap();
|
||||
|
||||
// The snapshot replays the ring the *remote* daemon kept, so the marker
|
||||
// printed before the disconnect is still there.
|
||||
read_until(&mut back, "routed-pane-alive");
|
||||
|
||||
// And it is live, not just a recording.
|
||||
ClientMsg::Input(b"echo st''ill-here\n".to_vec())
|
||||
.encode(&mut back)
|
||||
.unwrap();
|
||||
@@ -217,21 +147,12 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() {
|
||||
shutdown(dir.path(), &config);
|
||||
}
|
||||
|
||||
/// The pane channel and the control channel are **not** interchangeable.
|
||||
///
|
||||
/// A header that forgets `for_pane()` reaches the control socket, where a
|
||||
/// `Spawn` is an unknown frame. This is what "the window opens but nothing runs
|
||||
/// in it" looked like, so it is pinned rather than left to the reader.
|
||||
#[test]
|
||||
fn the_channel_decides_which_dialect_the_route_carries() {
|
||||
let control = RouteHeader::local_stdio(EXE, &["--stdio"]);
|
||||
assert_eq!(control.channel, RouteChannel::Control);
|
||||
assert_eq!(control.clone().for_pane().channel, RouteChannel::Pane);
|
||||
|
||||
// The wire tag is what a *different* build matches on, so it is pinned
|
||||
// rather than left to the variant name — and the default has to keep
|
||||
// decoding as `control`, because that is what every header written before
|
||||
// the field existed meant.
|
||||
let mut buf = Vec::new();
|
||||
control.clone().for_pane().write(&mut buf).unwrap();
|
||||
let (_, payload) = tty7_core::daemon::protocol::read_frame(&mut buf.as_slice()).unwrap();
|
||||
@@ -243,11 +164,6 @@ fn the_channel_decides_which_dialect_the_route_carries() {
|
||||
assert_eq!(decoded.channel, RouteChannel::Control);
|
||||
}
|
||||
|
||||
/// A routed pane's `Kill` reaches the machine the pane is on.
|
||||
///
|
||||
/// Pane ids are per-daemon, so this is not a convenience: an unrouted `Kill`
|
||||
/// does not fail, it succeeds against whatever local pane happens to hold the
|
||||
/// same number.
|
||||
#[test]
|
||||
fn a_routed_kill_reaches_the_pane_it_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -271,7 +187,6 @@ fn a_routed_kill_reaches_the_pane_it_names() {
|
||||
drop(sock);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// It is on the remote's registry...
|
||||
let (mut list, hub_thread) = routed(dir.path(), "list-1.sock", &config);
|
||||
ClientMsg::List.encode(&mut list).unwrap();
|
||||
let before = match DaemonMsg::read(&mut list).unwrap() {
|
||||
@@ -282,7 +197,6 @@ fn a_routed_kill_reaches_the_pane_it_names() {
|
||||
drop(list);
|
||||
let _ = hub_thread.join();
|
||||
|
||||
// ...and a routed Kill takes it off.
|
||||
let (mut kill, hub_thread) = routed(dir.path(), "kill-1.sock", &config);
|
||||
ClientMsg::Kill { pane_id }.encode(&mut kill).unwrap();
|
||||
let _ = kill.shutdown(std::net::Shutdown::Write);
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
//! **The milestone's proof**: every `Host` conformance case, run against a real
|
||||
//! `tty7-server --stdio` child process over real pipes.
|
||||
//!
|
||||
//! Not a mock, not an in-process socket pair, and — the part that matters — not
|
||||
//! an sshd. The client is `RemoteHost`, the wire is the control dialect, the
|
||||
//! server is the shipped binary answering out of its own address space, and the
|
||||
//! only thing standing in for SSH is a pair of pipes. Everything between the
|
||||
//! `Host` call and the syscall is the code a transcontinental workspace runs.
|
||||
//!
|
||||
//! That is what makes remote workspaces testable in CI at all. The alternative —
|
||||
//! provisioning a machine, an sshd, a key, and a network for every pull request
|
||||
//! — is expensive enough that in practice it does not get run, which means the
|
||||
//! two `Host` implementations drift and nobody finds out until someone opens a
|
||||
//! remote directory. Here the identical list of cases runs against `LocalHost`
|
||||
//! in `tty7-core` and against this, and a divergence is a red test.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! One child process and one sandbox **per case**, via
|
||||
//! [`host_conformance_suite!`](tty7_core::host_conformance_suite). Spawning
|
||||
//! forty-six servers costs a few hundred milliseconds in total and buys complete
|
||||
//! isolation: no case can be explained by another's leftover state, a hung
|
||||
//! server fails exactly one case, and a crash names the behaviour that caused it.
|
||||
|
||||
// Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on
|
||||
// Windows by design — a Windows machine is reached over its own transport, not
|
||||
// by shipping a server onto it.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io;
|
||||
@@ -37,13 +10,6 @@ use tty7_core::host::SharedHost;
|
||||
use tty7_core::host::conformance::Sandbox;
|
||||
use tty7_core::host::remote::RemoteHost;
|
||||
|
||||
/// The child, and the only way to end it.
|
||||
///
|
||||
/// `RemoteHost` closes its link through [`LinkShutdown`]; for a socket that is
|
||||
/// `shutdown(2)`, and for a child process it is this. Without it, dropping the
|
||||
/// host would leave the reader thread parked on a pipe the server has no reason
|
||||
/// to write to and the server parked on a pipe the client has no reason to write
|
||||
/// to — the exact standoff `LinkShutdown` exists to break, one transport over.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
@@ -51,19 +17,14 @@ struct ServerProcess {
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||
return Ok(()); // already reaped; `close` and `Drop` both call this
|
||||
return Ok(());
|
||||
};
|
||||
let _ = child.kill();
|
||||
// Reaped here rather than left to the OS: forty-six cases running in
|
||||
// parallel would otherwise accumulate forty-six zombies for the life of
|
||||
// the test binary.
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A temp directory on the machine the server is on — which, this being the
|
||||
/// stdio path, is also this one.
|
||||
struct TempSandbox(tempfile::TempDir);
|
||||
|
||||
impl Sandbox for TempSandbox {
|
||||
@@ -84,23 +45,13 @@ impl Sandbox for TempSandbox {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a server and connect a `RemoteHost` to it.
|
||||
fn stdio_host() -> (SharedHost, TempSandbox) {
|
||||
let sandbox = TempSandbox(tempfile::TempDir::new().unwrap());
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
// `--serve` rather than letting the mode be probed: a developer running
|
||||
// these tests may well have a real `tty7-server --daemon` up, and a
|
||||
// bridge to *that* would be testing their machine's state instead of
|
||||
// this build.
|
||||
.args(["--stdio", "--serve"])
|
||||
// The server opens its machine tree at startup. None of these cases
|
||||
// touch it, but pointing it at the sandbox keeps forty-six child
|
||||
// processes off the developer's real `~/.local/share/tty7`.
|
||||
.env("TTY7_DATA_DIR", sandbox.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
// The server's diagnostics are not this test's output. A failure shows
|
||||
// up as a failed request, which names the case.
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start tty7-server --stdio");
|
||||
@@ -118,13 +69,8 @@ fn stdio_host() -> (SharedHost, TempSandbox) {
|
||||
(host.into_shared(), sandbox)
|
||||
}
|
||||
|
||||
// Every case in `tty7-core`'s shared suite, over the pipes. This is the same
|
||||
// list `LocalHost` runs; the point is that it is not a *similar* list.
|
||||
tty7_core::host_conformance_suite!(remote_stdio, stdio_host);
|
||||
|
||||
/// The suite above only proves the cases pass — it cannot prove they were the
|
||||
/// whole suite. This checks the count the registry actually carries, so a case
|
||||
/// silently dropped upstream shows up here as well as there.
|
||||
#[test]
|
||||
fn the_whole_suite_ran_against_the_server() {
|
||||
let names: Vec<&str> = tty7_core::host::conformance::CASES
|
||||
@@ -138,22 +84,14 @@ fn the_whole_suite_ran_against_the_server() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The server is a *separate process* answering out of its own memory. Easy to
|
||||
/// lose by accident — an in-process fallback would keep every case above green
|
||||
/// while testing nothing that this milestone is about.
|
||||
#[test]
|
||||
fn the_server_really_is_another_process() {
|
||||
let (host, sandbox) = stdio_host();
|
||||
let marker = host.join(sandbox.path(), "written-over-the-wire.txt");
|
||||
host.write_file(&marker, b"from the client").unwrap();
|
||||
|
||||
// This side reads it with plain `std::fs`: if the bytes are there, they went
|
||||
// out through a pipe and came back through a syscall someone else made.
|
||||
assert_eq!(std::fs::read(&marker).unwrap(), b"from the client");
|
||||
|
||||
// And the reverse: a change this process makes with `std::fs` is visible to
|
||||
// the server, so both ends really are looking at one filesystem through two
|
||||
// different code paths.
|
||||
let from_here = sandbox.path().join("written-locally.txt");
|
||||
std::fs::write(&from_here, b"from the test").unwrap();
|
||||
assert_eq!(
|
||||
@@ -164,13 +102,9 @@ fn the_server_really_is_another_process() {
|
||||
assert!(host.is_connected());
|
||||
}
|
||||
|
||||
/// Dropping the host kills the child. A test binary that leaked one server per
|
||||
/// case would leave forty-six processes behind on every run.
|
||||
#[test]
|
||||
fn dropping_the_host_reaps_the_server() {
|
||||
let (host, sandbox) = stdio_host();
|
||||
assert!(host.exists(sandbox.path()));
|
||||
drop(host);
|
||||
// Nothing to assert beyond "this returns": the reap happens inside the drop,
|
||||
// and a shutdown that did not wake the reader would hang here instead.
|
||||
}
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
//! Menu / keyboard actions, defined in one place so both the application shell
|
||||
//! (`app.rs`) and the terminal view (`terminal::view`) can reference them
|
||||
//! without depending on each other. They drive the macOS menu bar and the
|
||||
//! keymap, so a click and a shortcut go through exactly the same path.
|
||||
|
||||
use gpui::actions;
|
||||
|
||||
actions!(
|
||||
tty7,
|
||||
[
|
||||
NewTab,
|
||||
// Create a workspace and the window that shows it. One workspace is
|
||||
// shown by exactly one window and vice versa — there is deliberately no
|
||||
// "new window on the same workspace", which would need two clients on
|
||||
// one set of daemon panes (the daemon allows only one).
|
||||
NewWorkspace,
|
||||
// Stop the current workspace: kill its sessions and close its window,
|
||||
// keeping its layout on file so it can be started again. The deliberate
|
||||
// opposite of a window close, which only detaches — hence the verb.
|
||||
StopWorkspace,
|
||||
// Stop it *and* forget the layout. The only irreversible one.
|
||||
DeleteWorkspace,
|
||||
// Rename the current workspace in place, from the title-bar chip.
|
||||
// Until now `Workspace.name` could only ever be the derived repo name —
|
||||
// there was no way for the user to set one.
|
||||
RenameWorkspace,
|
||||
// Open the workspace switcher: every workspace on every machine, in one
|
||||
// panel. The title-bar chip opens the same thing, so this is the
|
||||
// keyboard's half of a control that is otherwise mouse-only.
|
||||
ToggleSwitcher,
|
||||
// Show the Nth workspace in the Window menu's order (see
|
||||
// `ui::windows::menu_order`). Unit actions rather than one
|
||||
// parameterized action, matching `ActivateTab1..9` — it keeps them
|
||||
// nameable in config/Settings like every other binding.
|
||||
SelectWorkspace1,
|
||||
SelectWorkspace2,
|
||||
SelectWorkspace3,
|
||||
@@ -42,58 +19,34 @@ actions!(
|
||||
SelectWorkspace8,
|
||||
SelectWorkspace9,
|
||||
CloseActiveTab,
|
||||
// Tab operations that until now existed only as tab-context-menu rows,
|
||||
// reachable by right-clicking the *right* chip. As actions they also
|
||||
// reach the menu bar, the palette, and Settings → Keybindings; each acts
|
||||
// on the active tab, which is what "this tab" means with no chip clicked.
|
||||
RenameTab,
|
||||
NewWorktreeTab,
|
||||
CloseOtherTabs,
|
||||
CloseTabsToTheRight,
|
||||
CopyWorkingDirectory,
|
||||
MarkTabUnread,
|
||||
// Branch the coding-agent session running in this tab into a second,
|
||||
// independent one by shelling the agent's own fork command (issue
|
||||
// #211). Placement follows where the user asked from: the bare action —
|
||||
// menu bar, palette, a bound key — and the tab context menu open a new
|
||||
// tab, while the pane right-click menu offers the four split directions
|
||||
// below, since a pane-level ask is a spatial one.
|
||||
ForkAgentSession,
|
||||
ForkAgentSessionRight,
|
||||
ForkAgentSessionLeft,
|
||||
ForkAgentSessionDown,
|
||||
ForkAgentSessionUp,
|
||||
// Put the agent's *native* session id on the clipboard, beside "Copy
|
||||
// Working Directory". Codex has no copy/duplicate subcommand, so
|
||||
// "copy the session" means copying its id — paste it into `codex
|
||||
// resume`, a bug report, or another tool.
|
||||
CopyAgentSessionId,
|
||||
SplitRight,
|
||||
SplitDown,
|
||||
FocusNextPane,
|
||||
FocusPrevPane,
|
||||
// Directional pane focus (tmux `prefix ←/→/↑/↓`): move focus to the
|
||||
// adjacent pane in that direction.
|
||||
FocusPaneLeft,
|
||||
FocusPaneRight,
|
||||
FocusPaneUp,
|
||||
FocusPaneDown,
|
||||
// Grow (Right/Down) or shrink (Left/Up) the focused pane along the
|
||||
// matching axis by nudging its nearest enclosing split's ratio.
|
||||
ResizePaneLeft,
|
||||
ResizePaneRight,
|
||||
ResizePaneUp,
|
||||
ResizePaneDown,
|
||||
// Swap the focused pane with its next / previous sibling in leaf order
|
||||
// (tmux `prefix }` / `prefix {`); focus follows the moved pane.
|
||||
SwapPaneNext,
|
||||
SwapPanePrev,
|
||||
// Relative tab navigation (tmux `prefix n` / `prefix p`).
|
||||
NextTab,
|
||||
PrevTab,
|
||||
// Jump straight to tab 1‑9 (⌘/Ctrl+1‑9, tmux `prefix 1‑9`). Unit actions
|
||||
// rather than one parameterized action so config/Settings can index them
|
||||
// by name like every other binding.
|
||||
ActivateTab1,
|
||||
ActivateTab2,
|
||||
ActivateTab3,
|
||||
@@ -110,68 +63,31 @@ actions!(
|
||||
ReopenClosedTab,
|
||||
ToggleMaximizePane,
|
||||
ToggleFullscreen,
|
||||
// Switch the tab bar between the horizontal title-bar strip and the
|
||||
// vertical left-side sidebar (persists `tab_bar_position`).
|
||||
ToggleTabSidebar,
|
||||
// Collapse/expand the left tab sidebar in place (persists
|
||||
// `sidebar_collapsed`). Unlike `ToggleTabSidebar` this does not switch
|
||||
// the tab bar to the horizontal strip — the rail just goes away and
|
||||
// comes back at the same width.
|
||||
ToggleLeftPanel,
|
||||
// Show/hide the right detail panel — session info, working-tree changes,
|
||||
// and the file tree (persists `right_panel_visible`).
|
||||
ToggleRightPanel,
|
||||
// Jump straight to one of the right panel's tabs, opening the panel if
|
||||
// it was closed. Unit actions rather than one parameterized action so
|
||||
// config/Settings can bind them by name; unbound by default, since the
|
||||
// panel's own tab row is the primary way in.
|
||||
ShowRightPanelInfo,
|
||||
ShowRightPanelOutline,
|
||||
ShowRightPanelChanges,
|
||||
ShowRightPanelFiles,
|
||||
OpenSettings,
|
||||
// Open Settings straight to its Keybindings section — the Help menu's
|
||||
// "Keyboard Shortcuts" and the palette's shortcut entry both land here,
|
||||
// rather than making the user open Settings and then find the section.
|
||||
ShowKeyboardShortcuts,
|
||||
// Open Settings on the About section. The macOS App menu's first item
|
||||
// has to exist and has to be called "About tty7"; routing it to the
|
||||
// section that already carries version/links keeps one About, not two.
|
||||
About,
|
||||
// Run the same update check the app does at startup (see `core::update`)
|
||||
// on demand, then report the outcome. Previously only the tray offered
|
||||
// this, which is not where a Mac user looks for it.
|
||||
CheckForUpdates,
|
||||
// Standard macOS App-menu items. gpui exposes the platform calls but
|
||||
// binds nothing by default, so they need real actions to hang off.
|
||||
HideApp,
|
||||
HideOthers,
|
||||
ShowAll,
|
||||
// Standard macOS Window-menu items.
|
||||
MinimizeWindow,
|
||||
ZoomWindow,
|
||||
// Help menu destinations. Each opens a URL in the default browser; kept
|
||||
// as separate actions (rather than one parameterized one) so they can be
|
||||
// bound and searched by name like everything else.
|
||||
OpenDocumentation,
|
||||
OpenDiscord,
|
||||
ReportIssue,
|
||||
RestartDaemon,
|
||||
// Show the detail panel's Files tab, which browses the focused pane's
|
||||
// remote filesystem over SFTP when that pane is native SSH (WS5).
|
||||
ToggleSftp,
|
||||
// Open the detail panel's Info tab on the focused native-SSH pane with
|
||||
// the add-forward form expanded (WS4). The band itself is always on that
|
||||
// tab; this is the way in that doesn't require the panel to be open.
|
||||
ShowSshForwards,
|
||||
// Toggle the code panel: a full-body overlay of [file tree | editor]
|
||||
// covering the terminal (settings-overlay style).
|
||||
ToggleCodePanel,
|
||||
// Save the editor panel's active file (⌘S).
|
||||
EditorSave,
|
||||
// Open the SSH profile manager/editor full-window page (WS6, FR-P1).
|
||||
OpenSshProfiles,
|
||||
// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
|
||||
RestartSshSession,
|
||||
SendTab,
|
||||
SendBackTab,
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
//! Prompt builders that feed terminal context *back into* a running CLI coding
|
||||
//! agent — the review-prompt / selection-range-prompt idea, sized to tty7:
|
||||
//! take what the user is looking at (a selection in some
|
||||
//! pane, the repo's `git diff`) and phrase it as one self-contained prompt to
|
||||
//! paste into the agent's PTY. Pure string builders, unit-tested; the UI layer
|
||||
//! owns finding the agent pane and writing the bytes.
|
||||
|
||||
/// Cap on embedded context (selection or diff) so a pathological selection or
|
||||
/// a giant diff can't flood the agent's input buffer. Anything longer is
|
||||
/// truncated with an explicit note — the agent can always ask for more.
|
||||
const MAX_CONTEXT_BYTES: usize = 24 * 1024;
|
||||
|
||||
/// Truncate `text` to [`MAX_CONTEXT_BYTES`] on a char boundary, appending a
|
||||
/// note when anything was cut.
|
||||
fn capped(text: &str) -> String {
|
||||
if text.len() <= MAX_CONTEXT_BYTES {
|
||||
return text.to_string();
|
||||
@@ -26,8 +14,6 @@ fn capped(text: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// A prompt asking the agent to look at terminal output the user selected
|
||||
/// (a build error, a stack trace, a failing test). `cwd` locates the context.
|
||||
pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let selection = selection.trim_end();
|
||||
if selection.trim().is_empty() {
|
||||
@@ -45,9 +31,6 @@ pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option<Stri
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
/// A prompt asking the agent to review the working tree's diff. `diff` is the
|
||||
/// combined `git diff` (+ `git diff --cached`) output, embedded so the agent
|
||||
/// needn't re-run it; an empty diff yields `None` (nothing to review).
|
||||
pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let diff = diff.trim_end();
|
||||
if diff.trim().is_empty() {
|
||||
@@ -67,11 +50,6 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String>
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
/// The bytes that deliver `prompt` into an agent's PTY: a bracketed paste (so
|
||||
/// multi-line prompts insert as one block instead of submitting line by line —
|
||||
/// every recognized agent's TUI enables bracketed paste), followed by CR to
|
||||
/// submit. ESC bytes inside the prompt are stripped, same as the clipboard
|
||||
/// paste path, so embedded content can't fake the paste terminator.
|
||||
pub fn submit_bytes(prompt: &str) -> Vec<u8> {
|
||||
let mut bytes = b"\x1b[200~".to_vec();
|
||||
bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
|
||||
@@ -90,7 +68,6 @@ mod tests {
|
||||
assert!(p.contains("error[E0308]"));
|
||||
assert!(p.contains("/work/tty7"));
|
||||
assert!(p.contains("```"));
|
||||
// Empty / whitespace selections build nothing.
|
||||
assert_eq!(build_selection_prompt(" \n", None), None);
|
||||
}
|
||||
|
||||
@@ -115,7 +92,6 @@ mod tests {
|
||||
let bytes = submit_bytes("fix this\nplease");
|
||||
assert!(bytes.starts_with(b"\x1b[200~"));
|
||||
assert!(bytes.ends_with(b"\x1b[201~\r"));
|
||||
// Embedded ESC can't terminate the paste early.
|
||||
let sneaky = submit_bytes("a\x1b[201~; rm -rf /\nb");
|
||||
let inner = &sneaky[6..sneaky.len() - 7];
|
||||
assert!(!inner.contains(&0x1b));
|
||||
|
||||
@@ -1,67 +1,19 @@
|
||||
//! The gpui-facing half of the configuration model.
|
||||
//!
|
||||
//! Every field, every default, every parse rule and all of the `config.json` IO
|
||||
//! live in `tty7-core` — the daemon and the headless server read the same file
|
||||
//! and must agree with the GUI byte for byte, and neither of them links gpui.
|
||||
//! Two things are left here, and both exist only because they *are* gpui:
|
||||
//!
|
||||
//! 1. **[`Config`] as a global.** gpui keys its global map by type and
|
||||
//! `gpui::Global` is a foreign trait, so it cannot be implemented for the
|
||||
//! core struct from this crate. [`Config`] is therefore a transparent
|
||||
//! newtype around [`tty7_core::core::config::Config`] that carries the
|
||||
//! `Global` impl; it `Deref`s to the core struct, so `cx.global::<Config>()
|
||||
//! .font_size` and friends read exactly as they always did.
|
||||
//! 2. **[`gpui_font_features`]**, which converts the stored feature list into
|
||||
//! the `gpui::FontFeatures` the text system wants.
|
||||
//!
|
||||
//! Everything else is re-exported unchanged, so `crate::core::config::…` still
|
||||
//! resolves to the same items across the whole GUI.
|
||||
|
||||
// Everything else — every enum, helper and constant — passes straight through,
|
||||
// so `crate::core::config::…` resolves exactly as it did before the split. The
|
||||
// `Config` this glob would bring in is shadowed by the newtype below.
|
||||
pub use tty7_core::core::config::*;
|
||||
|
||||
/// The core configuration struct, under a name this module's own [`Config`]
|
||||
/// wrapper doesn't shadow.
|
||||
pub use tty7_core::core::config::Config as CoreConfig;
|
||||
|
||||
/// The app's live configuration, as gpui holds it: a newtype over
|
||||
/// [`CoreConfig`] whose only job is to carry the `gpui::Global` impl the orphan
|
||||
/// rule won't let us put on the core struct directly.
|
||||
///
|
||||
/// It `Deref`s (and `DerefMut`s) to the core struct, so reads and writes go
|
||||
/// through untouched — `cx.global::<Config>().font_size`,
|
||||
/// `cx.global_mut::<Config>().window_blur = Some(on)`,
|
||||
/// `cx.global::<Config>().save()`. Construct one with [`Config::load`],
|
||||
/// `Config::default()`, or `Config(core_config)`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config(pub CoreConfig);
|
||||
|
||||
impl gpui::Global for Config {}
|
||||
|
||||
impl Config {
|
||||
/// Load the config, falling back to defaults if the file is absent or
|
||||
/// unreadable — see [`CoreConfig::load`].
|
||||
pub fn load() -> Self {
|
||||
#[cfg(test)]
|
||||
assert_scratch_config_dir("Config::load");
|
||||
Self(CoreConfig::load())
|
||||
}
|
||||
|
||||
/// Test-only guard that shadows [`CoreConfig::save`].
|
||||
///
|
||||
/// `save` is a *full* overwrite of `config.json`, and the config dir is
|
||||
/// resolved process-wide from `$HOME` unless a test pins it. A GUI test that
|
||||
/// forgets to pin therefore doesn't just leak a file — it resets the
|
||||
/// developer's entire live config to whatever the test built (this is not
|
||||
/// hypothetical: the keybinding tests in `ui::app` did exactly that, which is
|
||||
/// how this guard came to exist). An inherent method wins over the `Deref` to
|
||||
/// [`CoreConfig`], so every `cfg.save()` in the crate routes through here
|
||||
/// under `cargo test` and through the core method otherwise — no call site
|
||||
/// has to opt in.
|
||||
///
|
||||
/// Pin a scratch dir with [`pin_test_config_dir`] in the test's harness.
|
||||
#[cfg(test)]
|
||||
pub fn save(&self) {
|
||||
assert_scratch_config_dir("Config::save");
|
||||
@@ -69,18 +21,11 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `dir` is the platform's real per-user config dir — the one a
|
||||
/// developer's own tty7 reads and writes.
|
||||
///
|
||||
/// `None` (nothing resolves — no `$HOME`) is not "real": IO there is a no-op, so
|
||||
/// there is nothing to protect.
|
||||
#[cfg(test)]
|
||||
fn is_real_user_config_dir(dir: Option<&std::path::Path>) -> bool {
|
||||
dir.is_some() && dir == default_config_dir().as_deref()
|
||||
}
|
||||
|
||||
/// Panic unless the config dir has been pinned away from the developer's real
|
||||
/// one. See [`Config::save`] for why.
|
||||
#[cfg(test)]
|
||||
fn assert_scratch_config_dir(what: &str) {
|
||||
assert!(
|
||||
@@ -92,15 +37,6 @@ fn assert_scratch_config_dir(what: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Point this process's config dir at a scratch directory, so config-dir IO in
|
||||
/// tests can't reach the developer's real `~/.config/tty7`.
|
||||
///
|
||||
/// Every test in the binary must pin **this same path**. `set_config_dir` is
|
||||
/// first-call-wins and process-wide, so a test that pinned a scratch dir of its
|
||||
/// own would silently redirect whichever tests lost the race away from the
|
||||
/// directory they then read back — one shared path makes the race outcome
|
||||
/// irrelevant. That is why this takes no name: the single call site for the
|
||||
/// path is the point.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pin_test_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
@@ -128,12 +64,6 @@ impl From<CoreConfig> for Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured OpenType features, in the shape gpui's text system takes.
|
||||
///
|
||||
/// The stored form is a `tty7-core` replica of `gpui::FontFeatures` with an
|
||||
/// identical wire format (see [`FontFeatures`]); this is the one place the two
|
||||
/// meet, so the conversion — and the test below that pins their serializations
|
||||
/// together — is all that keeps them honest.
|
||||
pub fn gpui_font_features(features: &FontFeatures) -> gpui::FontFeatures {
|
||||
gpui::FontFeatures(std::sync::Arc::new(features.tag_value_list().to_vec()))
|
||||
}
|
||||
@@ -143,40 +73,24 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
/// The guard behind [`Config::save`]: it has to recognize the real dir (so a
|
||||
/// forgotten pin is caught) and clear a scratch one (so pinned tests run).
|
||||
/// Testing the predicate rather than the panic keeps this independent of
|
||||
/// which test pinned the process first — `set_config_dir` is first-call-wins,
|
||||
/// so an unpinned state can't be staged once any test has run.
|
||||
#[test]
|
||||
fn the_real_config_dir_is_the_only_one_the_guard_rejects() {
|
||||
// Whatever the platform resolves to for this user is exactly what tests
|
||||
// must never write to.
|
||||
if let Some(real) = default_config_dir() {
|
||||
assert!(is_real_user_config_dir(Some(&real)));
|
||||
// A scratch dir under it is still not *it* — the guard compares the
|
||||
// dir itself, not an ancestor.
|
||||
assert!(!is_real_user_config_dir(Some(&real.join("scratch"))));
|
||||
}
|
||||
assert!(!is_real_user_config_dir(Some(Path::new(
|
||||
"/tmp/tty7-scratch"
|
||||
))));
|
||||
// Nothing resolves (no `$HOME`) → config IO is a no-op, nothing to guard.
|
||||
assert!(!is_real_user_config_dir(None));
|
||||
}
|
||||
|
||||
/// The pin helper must land somewhere the guard accepts — otherwise every
|
||||
/// harness that follows this advice would still panic.
|
||||
#[test]
|
||||
fn pinning_lands_outside_the_real_config_dir() {
|
||||
pin_test_config_dir();
|
||||
assert!(!is_real_user_config_dir(config_dir_path().as_deref()));
|
||||
}
|
||||
|
||||
/// `font_features` is a real key in the user's `config.json`, and the type
|
||||
/// backing it moved out of gpui when the core crate split off. The two must
|
||||
/// still parse the same JSON to the same feature list and write it back
|
||||
/// identically — otherwise the split silently rewrote user config.
|
||||
#[test]
|
||||
fn font_features_match_gpui_byte_for_byte() {
|
||||
const JSON: &str = r#"{"calt":true,"liga":1,"ss01":0,"zero":false,"bad":1,"kern":null}"#;
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
//! The *storage* half of the SSH credential vault: the [`CredentialStore`]
|
||||
//! trait, its OS-keychain backend and the in-memory test double.
|
||||
//!
|
||||
//! The naming half — [`CredentialKind`], [`CredentialRef`], [`endpoint_account`]
|
||||
//! and the two service constants — lives one crate down in
|
||||
//! `tty7_core::core::keychain` and is re-exported here, so every call site keeps
|
||||
//! using `crate::core::keychain::…` for both halves.
|
||||
//!
|
||||
//! **Why the split.** `tty7-core` also builds the headless `tty7-server`, a
|
||||
//! static binary meant to be small enough to push onto an arbitrary box. That
|
||||
//! machine has no OS keychain and nothing in `tty7-core` ever reads a secret —
|
||||
//! the daemon receives secrets already resolved by the GUI (see
|
||||
//! `daemon::protocol`'s `NativeSshSpec`). Leaving `keyring` in the core manifest
|
||||
//! made the server link `zbus` / `secret-service` and thirty-odd crates behind
|
||||
//! them for code it can never call. So the store moved up here, where its callers
|
||||
//! already were (`ui::ssh_prompt`, `ui::ssh_connect`, `ui::settings`, `ui::app`).
|
||||
//!
|
||||
//! Secrets are never logged. The typed helpers below deliberately keep secret
|
||||
//! values out of `Debug`/log output.
|
||||
|
||||
pub use tty7_core::core::keychain::{
|
||||
CredentialKind, CredentialRef, SERVICE_KEY_PASSPHRASE, SERVICE_PASSWORD, endpoint_account,
|
||||
key_account_from_contents,
|
||||
};
|
||||
|
||||
/// A backend failure while talking to the credential store. Intentionally never
|
||||
/// carries a secret value — only a human-readable reason from the backend.
|
||||
#[derive(Debug)]
|
||||
pub enum CredentialError {
|
||||
/// The underlying store failed (keychain locked, access denied, IO error).
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
@@ -41,34 +18,19 @@ impl std::fmt::Display for CredentialError {
|
||||
|
||||
impl std::error::Error for CredentialError {}
|
||||
|
||||
/// Result alias for credential-store operations.
|
||||
pub type CredentialResult<T> = Result<T, CredentialError>;
|
||||
|
||||
/// A secret store keyed by `(service, account)`. Implementors talk to a real OS
|
||||
/// keychain or an in-memory map.
|
||||
///
|
||||
/// Contract:
|
||||
/// - `get` returns `Ok(None)` when the entry is absent (not an error).
|
||||
/// - `delete` is idempotent: deleting an absent entry returns `Ok(())`.
|
||||
/// - Implementors must never log secret values.
|
||||
pub trait CredentialStore: Send + Sync {
|
||||
/// Fetch the secret for `(service, account)`, or `Ok(None)` if absent.
|
||||
fn get(&self, service: &str, account: &str) -> CredentialResult<Option<String>>;
|
||||
|
||||
/// Store `secret` under `(service, account)`, overwriting any existing value.
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> CredentialResult<()>;
|
||||
|
||||
/// Remove the entry at `(service, account)`. Absent entry ⇒ `Ok(())`.
|
||||
fn delete(&self, service: &str, account: &str) -> CredentialResult<()>;
|
||||
|
||||
// ── Typed endpoint/key helpers (default methods over get/set/delete) ──────
|
||||
|
||||
/// The stored password for an endpoint, if any.
|
||||
fn password_for(&self, user: &str, host: &str, port: u16) -> CredentialResult<Option<String>> {
|
||||
self.get(SERVICE_PASSWORD, &endpoint_account(user, host, port))
|
||||
}
|
||||
|
||||
/// Store a password for an endpoint and return the [`CredentialRef`] naming it.
|
||||
fn set_password(
|
||||
&self,
|
||||
user: &str,
|
||||
@@ -84,17 +46,14 @@ pub trait CredentialStore: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the stored password for an endpoint (idempotent).
|
||||
fn delete_password(&self, user: &str, host: &str, port: u16) -> CredentialResult<()> {
|
||||
self.delete(SERVICE_PASSWORD, &endpoint_account(user, host, port))
|
||||
}
|
||||
|
||||
/// The stored passphrase for a private key (keyed by its sha512-hex), if any.
|
||||
fn passphrase_for_key(&self, key_sha512_hex: &str) -> CredentialResult<Option<String>> {
|
||||
self.get(SERVICE_KEY_PASSPHRASE, key_sha512_hex)
|
||||
}
|
||||
|
||||
/// Store a passphrase for a private key and return the [`CredentialRef`].
|
||||
fn set_key_passphrase(
|
||||
&self,
|
||||
key_sha512_hex: &str,
|
||||
@@ -104,36 +63,22 @@ pub trait CredentialStore: Send + Sync {
|
||||
Ok(CredentialRef::key_passphrase(key_sha512_hex.to_string()))
|
||||
}
|
||||
|
||||
// The three below are unused outside tests today. Unlike `tty7-core`, this is a
|
||||
// *binary* crate, where `pub` does not escape and `dead_code` therefore fires
|
||||
// on them; they are kept because the trait's five verbs (`password_*`,
|
||||
// `*_key_passphrase`, `*_ref`) only make sense as a set — a store you can
|
||||
// write a ref to but not read one back from is a trap for the next caller.
|
||||
/// Delete the stored passphrase for a private key (idempotent).
|
||||
#[allow(dead_code)]
|
||||
fn delete_key_passphrase(&self, key_sha512_hex: &str) -> CredentialResult<()> {
|
||||
self.delete(SERVICE_KEY_PASSPHRASE, key_sha512_hex)
|
||||
}
|
||||
|
||||
/// Resolve a [`CredentialRef`] to its secret, or `Ok(None)` if absent.
|
||||
#[allow(dead_code)]
|
||||
fn get_ref(&self, cref: &CredentialRef) -> CredentialResult<Option<String>> {
|
||||
self.get(cref.service(), &cref.account)
|
||||
}
|
||||
|
||||
/// Delete the entry a [`CredentialRef`] names (idempotent).
|
||||
#[allow(dead_code)]
|
||||
fn delete_ref(&self, cref: &CredentialRef) -> CredentialResult<()> {
|
||||
self.delete(cref.service(), &cref.account)
|
||||
}
|
||||
}
|
||||
|
||||
/// The production store backed by the OS keychain via the `keyring` crate.
|
||||
///
|
||||
/// `keyring` 4.x's default `v1` feature auto-selects the platform store on first
|
||||
/// use, so this needs no per-platform wiring. A missing entry surfaces as
|
||||
/// `Ok(None)`; every other failure becomes [`CredentialError::Backend`] with the
|
||||
/// backend's message (never a secret).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct OsCredentialStore;
|
||||
|
||||
@@ -166,27 +111,18 @@ impl CredentialStore for OsCredentialStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory store for tests. Never touches the OS keychain.
|
||||
///
|
||||
/// `#[cfg(test)]` because this crate is a binary: a test-only type left in a
|
||||
/// normal build is dead code here, where in `tty7-core` (a library) `pub` alone
|
||||
/// kept the lint quiet.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryCredentialStore {
|
||||
// Keyed by (service, account). Behind a Mutex so the store is `Sync` and can
|
||||
// be shared like the real one.
|
||||
entries: std::sync::Mutex<std::collections::HashMap<(String, String), String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl InMemoryCredentialStore {
|
||||
/// A fresh, empty store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of stored entries (test introspection).
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries
|
||||
.lock()
|
||||
@@ -194,7 +130,6 @@ impl InMemoryCredentialStore {
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Whether the store holds no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
@@ -234,10 +169,8 @@ mod tests {
|
||||
let store = InMemoryCredentialStore::new();
|
||||
assert!(store.is_empty());
|
||||
|
||||
// Absent → None (not an error).
|
||||
assert_eq!(store.password_for("deploy", "host", 22).unwrap(), None);
|
||||
|
||||
// Set returns a ref that resolves back to the secret.
|
||||
let cref = store.set_password("deploy", "host", 22, "hunter2").unwrap();
|
||||
assert_eq!(cref, CredentialRef::password("deploy", "host", 22));
|
||||
assert_eq!(store.get_ref(&cref).unwrap().as_deref(), Some("hunter2"));
|
||||
@@ -246,7 +179,6 @@ mod tests {
|
||||
Some("hunter2")
|
||||
);
|
||||
|
||||
// Overwrite replaces in place (endpoint keying — one entry per endpoint).
|
||||
store.set_password("deploy", "host", 22, "newpass").unwrap();
|
||||
assert_eq!(store.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -254,7 +186,6 @@ mod tests {
|
||||
Some("newpass")
|
||||
);
|
||||
|
||||
// Delete is idempotent.
|
||||
store.delete_password("deploy", "host", 22).unwrap();
|
||||
assert_eq!(store.password_for("deploy", "host", 22).unwrap(), None);
|
||||
store.delete_password("deploy", "host", 22).unwrap();
|
||||
@@ -275,7 +206,6 @@ mod tests {
|
||||
Some("s3cret")
|
||||
);
|
||||
|
||||
// A password with the same account string does NOT collide (different service).
|
||||
store.set_password("deploy", "host", 22, "pw").unwrap();
|
||||
assert_eq!(store.len(), 2);
|
||||
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
//! Domain core: the configuration model, session persistence, the action
|
||||
//! vocabulary shared by the shell and the terminal view, and the streaming OSC
|
||||
//! tokenizer shared by the daemon- and client-side output scanners.
|
||||
//!
|
||||
//! These modules are framework-light and depend on neither `ui` nor `terminal`,
|
||||
//! so the dependency arrow always points *inward* to here.
|
||||
//!
|
||||
//! Most of it now lives one crate down, in `tty7-core`, so the headless
|
||||
//! `tty7-server` can share it — the modules re-exported below are that crate's,
|
||||
//! reachable under their original `crate::core::…` paths. What stays declared
|
||||
//! here is either gpui-shaped outright (`actions`, `update`) or the gpui half
|
||||
//! of a type whose data moved down (`config`, `session`, `window_state`).
|
||||
|
||||
// A glob, so every module `tty7-core` grows is reachable here for free. The
|
||||
// four `pub mod`s below deliberately shadow their glob-imported namesakes: each
|
||||
// is a thin layer that re-exports the core module's contents itself — gpui for
|
||||
// `config` / `session` / `window_state`, the OS keychain for `keychain`.
|
||||
pub use tty7_core::core::*;
|
||||
|
||||
pub mod actions;
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
//! The gpui-facing half of view-state persistence.
|
||||
//!
|
||||
//! The on-disk model — [`WindowView`], [`WindowViews`] and the `views.json`
|
||||
//! IO — lives in `tty7-core` beside the in-memory [`Session`] shapes. What is
|
||||
//! left here is [`WorkspaceStore`], which is a gpui `Global` and threads every
|
||||
//! mutation through `&mut App`.
|
||||
//!
|
||||
//! The store holds **no layout**. A workspace's tabs and panes live in its
|
||||
//! machine's daemon-owned tree; this file remembers only what that tree cannot
|
||||
//! — which workspaces this client knows, which machine each is on, window
|
||||
//! geometry, the open flag, and focus recency.
|
||||
|
||||
pub use tty7_core::core::session::{
|
||||
RemoteRef, RemoteTarget, Session, SessionAxis, SessionPane, SessionTab, WindowView,
|
||||
WindowViews, WorkspaceId,
|
||||
};
|
||||
pub use tty7_core::host::HostId;
|
||||
|
||||
/// App-level owner of `views.json`, and the single writer to it.
|
||||
///
|
||||
/// Windows never touch the file themselves. Each one pushes *its* view state
|
||||
/// in and the store persists the merged whole — without that, two windows
|
||||
/// doing read-modify-write on the shared file would have the last writer
|
||||
/// clobber the other's entries. It also means a window that is closing can
|
||||
/// record its final state after its own entity is already being torn down.
|
||||
pub struct WorkspaceStore {
|
||||
views: WindowViews,
|
||||
}
|
||||
@@ -30,32 +11,16 @@ pub struct WorkspaceStore {
|
||||
impl gpui::Global for WorkspaceStore {}
|
||||
|
||||
impl WorkspaceStore {
|
||||
/// Read `views.json` and install the result as the app global. Call once,
|
||||
/// before the first window is built.
|
||||
pub fn init(cx: &mut gpui::App) {
|
||||
let views = WindowViews::load().unwrap_or_default();
|
||||
cx.set_global(Self { views });
|
||||
}
|
||||
|
||||
/// Install a store holding exactly `views`.
|
||||
///
|
||||
/// Tests only, and it exists because [`init`](Self::init) reads the
|
||||
/// developer's real `views.json`: a test that needs a workspace to be on
|
||||
/// file must neither depend on what happens to be there nor risk writing to
|
||||
/// it. Every mutating helper already no-ops without the global, so this is
|
||||
/// the one thing a test cannot do for itself.
|
||||
#[cfg(test)]
|
||||
pub fn install_for_test(cx: &mut gpui::App, views: WindowViews) {
|
||||
cx.set_global(Self { views });
|
||||
}
|
||||
|
||||
/// Every known workspace. Read-only — mutations go through the helpers so
|
||||
/// the file stays in step.
|
||||
///
|
||||
/// Reads as empty when the store was never installed. That is the headless
|
||||
/// test harness, which builds windows directly rather than through
|
||||
/// `ui::windows::open`; "no saved workspaces" is the correct reading there,
|
||||
/// and it keeps a missing global from panicking a render.
|
||||
pub fn all(cx: &gpui::App) -> &WindowViews {
|
||||
static EMPTY: std::sync::OnceLock<WindowViews> = std::sync::OnceLock::new();
|
||||
match cx.try_global::<Self>() {
|
||||
@@ -64,21 +29,12 @@ impl WorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The store, or `None` when it was never installed (tests). Every mutating
|
||||
/// helper goes through this so a headless window is a no-op rather than a
|
||||
/// panic — and, importantly, so tests never write to a real `views.json`.
|
||||
fn try_store(cx: &mut gpui::App) -> Option<&mut Self> {
|
||||
cx.has_global::<Self>().then(|| cx.global_mut::<Self>())
|
||||
}
|
||||
|
||||
/// Take over an existing workspace to show in a window, or mint a fresh one
|
||||
/// when `id` is `None` / no longer on file (the "New Workspace" path).
|
||||
/// Marks it open and returns its id. The layout is not this store's to
|
||||
/// hand out — the window opens empty and the tree hydration fills it.
|
||||
pub fn claim(cx: &mut gpui::App, id: Option<WorkspaceId>) -> WorkspaceId {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
// No store (tests): hand back a detached identity so the window
|
||||
// still builds, but nothing is persisted.
|
||||
return WorkspaceId::new();
|
||||
};
|
||||
let id = id.filter(|id| store.views.get(*id).is_some());
|
||||
@@ -97,14 +53,6 @@ impl WorkspaceStore {
|
||||
claimed
|
||||
}
|
||||
|
||||
/// Record a window's geometry and persist. Called on every structural
|
||||
/// change (the same funnel the tree sync rides), so reopening the
|
||||
/// workspace lands where the user left it.
|
||||
///
|
||||
/// The display hint rides along for the same reason the geometry does: it is
|
||||
/// what the picker needs about a workspace whose machine is *not* answering,
|
||||
/// and the moment to capture it is while it still is. Read before the store
|
||||
/// is borrowed — the answer comes from another global.
|
||||
pub fn record_geometry(
|
||||
cx: &mut gpui::App,
|
||||
id: WorkspaceId,
|
||||
@@ -117,13 +65,9 @@ impl WorkspaceStore {
|
||||
return;
|
||||
};
|
||||
let Some(view) = store.views.get_mut(id) else {
|
||||
// The workspace was closed out from under us (its window is
|
||||
// tearing down); nothing to record.
|
||||
return;
|
||||
};
|
||||
view.window = Some(window);
|
||||
// Only ever replaced by something better: a machine that has gone quiet
|
||||
// must not blank the label it gave us while it was up.
|
||||
if let Some((label, subject)) = hint {
|
||||
view.label = Some(label);
|
||||
view.subject = subject;
|
||||
@@ -131,8 +75,6 @@ impl WorkspaceStore {
|
||||
store.views.save();
|
||||
}
|
||||
|
||||
/// Mark the focused workspace, so the next launch restores focus to the
|
||||
/// window the user was actually in.
|
||||
pub fn focus(cx: &mut gpui::App, id: WorkspaceId) {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
return;
|
||||
@@ -142,27 +84,11 @@ impl WorkspaceStore {
|
||||
}
|
||||
store.views.active = Some(id);
|
||||
store.views.save();
|
||||
// The machine's tree keeps its own recency (its pickers order by it),
|
||||
// so the focus is a fact to report there too.
|
||||
crate::ui::tree_sync::fire_workspace_op(cx, id, |ws| {
|
||||
tty7_core::daemon::control::ControlRequest::WorkspaceTouch { workspace: ws }
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick the one workspace launch will show, and detach every other one that
|
||||
/// was still open at the last quit.
|
||||
///
|
||||
/// The detaching is the point: `open` means "a window is showing this", and
|
||||
/// launch is about to make that false for all but one of them. Leaving the
|
||||
/// rest marked open would have the switcher badge them "open" with no window
|
||||
/// to switch to, and would have the *next* quit believe they were on screen.
|
||||
/// Their panes are untouched — this is exactly the state
|
||||
/// [`close_window`](Self::close_window) leaves behind, reached in bulk.
|
||||
///
|
||||
/// The workspace kept need not have been open at all: quitting with every
|
||||
/// window closed comes back to the one closed last (see
|
||||
/// [`WindowViews::workspace_to_restore`]). `None` means there are no saved
|
||||
/// workspaces whatsoever — a first run.
|
||||
pub fn restore_one(cx: &mut gpui::App) -> Option<WorkspaceId> {
|
||||
let store = Self::try_store(cx)?;
|
||||
let keep = store.views.workspace_to_restore()?;
|
||||
@@ -184,13 +110,7 @@ impl WorkspaceStore {
|
||||
Some(keep)
|
||||
}
|
||||
|
||||
/// Detach a workspace: its window is gone, but the panes keep running in
|
||||
/// the daemon and the entry stays for the picker to reopen.
|
||||
pub fn close_window(cx: &mut gpui::App, id: WorkspaceId) {
|
||||
// The last moment this client can see what the machine calls the
|
||||
// workspace — and a detached workspace is precisely what the picker
|
||||
// lists, so the hint matters most here. Read before the borrow, as in
|
||||
// [`record_geometry`](Self::record_geometry).
|
||||
let hint = Self::all(cx)
|
||||
.get(id)
|
||||
.and_then(|view| crate::ui::machine_mirror::display_hint(cx, view));
|
||||
@@ -208,9 +128,6 @@ impl WorkspaceStore {
|
||||
store.views.save();
|
||||
}
|
||||
|
||||
/// Forget a workspace entirely — the explicit "Close Workspace" action.
|
||||
/// The caller is responsible for the machine-side half (killing panes,
|
||||
/// `WorkspaceRemove`); this only drops the client's pointer.
|
||||
pub fn remove(cx: &mut gpui::App, id: WorkspaceId) {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
return;
|
||||
@@ -222,26 +139,14 @@ impl WorkspaceStore {
|
||||
store.views.save();
|
||||
}
|
||||
|
||||
// ----- the client / machine split -------------------
|
||||
|
||||
/// The machine a workspace's panes are on. `HostId::LOCAL` for a workspace
|
||||
/// this client owns, and for an id that is no longer on file — a window
|
||||
/// whose workspace vanished is showing nothing, and "nothing" is here.
|
||||
pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId {
|
||||
host_for(Self::all(cx), id)
|
||||
}
|
||||
|
||||
/// The remote a workspace points at, or `None` when it is a local one.
|
||||
pub fn remote_ref(cx: &gpui::App, id: WorkspaceId) -> Option<RemoteRef> {
|
||||
Self::all(cx).get(id).and_then(|w| w.host.clone())
|
||||
}
|
||||
|
||||
/// Whether this client can reach the machine `id`'s panes are on *right
|
||||
/// now*.
|
||||
///
|
||||
/// A local workspace is always reachable: its daemon is this machine's, and
|
||||
/// a gate that could answer otherwise for a local window would stop it
|
||||
/// acting on its own workspace.
|
||||
pub fn machine_is_connected(cx: &mut gpui::App, id: WorkspaceId) -> bool {
|
||||
let Some(host) = Self::remote_ref(cx, id) else {
|
||||
return true;
|
||||
@@ -249,20 +154,6 @@ impl WorkspaceStore {
|
||||
crate::ui::remote_connect::HostLinks::get(cx, host.host_id()).is_some()
|
||||
}
|
||||
|
||||
/// The client-side entry for `host` — the existing one if this machine has
|
||||
/// seen that workspace before, a fresh one otherwise.
|
||||
///
|
||||
/// The two ids are deliberately different things: the entry has its own
|
||||
/// [`WorkspaceId`] (this client's handle, what the window registry and the
|
||||
/// Window menu key on), and `host.workspace` is the id **on the remote**,
|
||||
/// which is what the machine-tree operations carry. Reusing
|
||||
/// one id for both would collide the moment two machines minted the same
|
||||
/// uuid, and would quietly make a client id meaningful off this machine.
|
||||
///
|
||||
/// The entry is matched on the whole [`RemoteRef`], so the same workspace id
|
||||
/// on two different machines is two entries, and reconnecting to one you
|
||||
/// have opened before reuses its window geometry rather than cascading a new
|
||||
/// window every time.
|
||||
pub fn claim_remote(cx: &mut gpui::App, host: RemoteRef) -> WorkspaceId {
|
||||
let Some(store) = Self::try_store(cx) else {
|
||||
return WorkspaceId::new();
|
||||
@@ -287,24 +178,10 @@ impl WorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine a window showing `id` is bound to.
|
||||
///
|
||||
/// The whole of "one window, one machine" reduces to this being a *function*: a
|
||||
/// window shows one workspace, a workspace names one host, so a window has one
|
||||
/// host and there is no arrangement of the data in which it has two. Split out
|
||||
/// from [`WorkspaceStore::host_of`] so it can be tested against a view set
|
||||
/// built by hand, with no globals and nothing written to disk.
|
||||
///
|
||||
/// An id that is not on file answers `LOCAL`: a window whose workspace was
|
||||
/// deleted out from under it is showing nothing, and "nothing" is here — the
|
||||
/// safe answer, because it is the one that refuses no local action.
|
||||
pub(crate) fn host_for(views: &WindowViews, id: WorkspaceId) -> HostId {
|
||||
views.get(id).map(|w| w.host_id()).unwrap_or(HostId::LOCAL)
|
||||
}
|
||||
|
||||
/// Whether rebinding a window from `previous` to `current` moved it to another
|
||||
/// machine — the moment every piece of per-*window* state that outlived the
|
||||
/// swap has to be reconsidered.
|
||||
pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool {
|
||||
previous != current
|
||||
}
|
||||
@@ -313,18 +190,6 @@ pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **The window/host invariant, as a test.**
|
||||
///
|
||||
/// A window is one machine. The inverse is listed under
|
||||
/// *never do this*, and the M5 data layer spends that guarantee — a
|
||||
/// workspace stores `host` once instead of per pane, and `sidebar_group`
|
||||
/// stays a bare `PathBuf` — so it has to be nailed down rather than
|
||||
/// believed.
|
||||
///
|
||||
/// What is actually being asserted: for any view set containing local
|
||||
/// and remote entries on several machines, the host a window binds to is a
|
||||
/// *function* of the workspace it shows. Every id answers exactly one
|
||||
/// machine, and no id answers two.
|
||||
#[test]
|
||||
fn a_window_binds_to_exactly_one_machine() {
|
||||
let build = RemoteTarget::Alias {
|
||||
@@ -344,7 +209,6 @@ mod tests {
|
||||
..WindowViews::default()
|
||||
};
|
||||
|
||||
// Three machines are represented, and they stay apart.
|
||||
let l = host_for(&views, local_id);
|
||||
let b1 = host_for(&views, build_a_id);
|
||||
let b2 = host_for(&views, build_b_id);
|
||||
@@ -355,24 +219,15 @@ mod tests {
|
||||
assert_ne!(b1, l);
|
||||
assert_ne!(g, l);
|
||||
|
||||
// The answer is stable: asking twice cannot give a window a second host.
|
||||
assert_eq!(host_for(&views, build_a_id), b1);
|
||||
|
||||
// And a window whose workspace was deleted underneath it falls back to
|
||||
// local rather than to some other machine's id.
|
||||
assert_eq!(host_for(&views, WorkspaceId::new()), HostId::LOCAL);
|
||||
|
||||
// Only a host change is a machine change — the trigger for dropping the
|
||||
// per-window state (the closed-tab stack) that could otherwise carry a
|
||||
// tab across.
|
||||
assert!(!crosses_machines(b1, b2));
|
||||
assert!(crosses_machines(l, b1));
|
||||
assert!(crosses_machines(b1, g));
|
||||
}
|
||||
|
||||
/// Two workspaces on one machine answer one `HostId`; a workspace on another
|
||||
/// machine answers a different one. That equality is what every "is this the
|
||||
/// same machine?" check in the window layer is built on.
|
||||
#[test]
|
||||
fn host_ids_group_by_machine_not_by_workspace() {
|
||||
let build = RemoteTarget::Alias {
|
||||
|
||||
+7
-155
@@ -1,14 +1,3 @@
|
||||
//! `~/.ssh/config` parsing: alias resolution for typed connects and the
|
||||
//! Settings-page import (PRD §3.3).
|
||||
//!
|
||||
//! Saved profiles are the app's single listed source of SSH hosts; this module
|
||||
//! never feeds a UI list directly. It resolves a *named* alias on demand
|
||||
//! (`resolve_alias_to_profile`, used when a typed target names a config Host)
|
||||
//! and turns the whole config into managed profiles on explicit import
|
||||
//! (`import_profiles` + `merge_imported`, behind Settings → SSH → "Import
|
||||
//! from ~/.ssh/config"). `Match` blocks and `canonicalize` are intentionally
|
||||
//! not evaluated.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -17,9 +6,6 @@ use crate::core::ssh_profile::{ForwardKind, ForwardRule, HostPort, SshProfile as
|
||||
const MAX_INCLUDE_DEPTH: usize = 8;
|
||||
const MAX_CONFIG_FILES: usize = 256;
|
||||
|
||||
/// The `group` label stamped on profiles imported from `~/.ssh/config` (also the
|
||||
/// marker used to recognize them). Newly imported entries get this; an existing
|
||||
/// profile's group is preserved on re-import.
|
||||
pub const IMPORTED_GROUP: &str = "Imported from ssh_config";
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
@@ -37,9 +23,6 @@ fn home_dir() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `HostName` percent-tokens: `%h` → the alias being resolved, `%%` → a
|
||||
/// literal `%`. Unknown tokens stay verbatim (matching
|
||||
/// `expand_identity_placeholders`' policy).
|
||||
fn expand_hostname_tokens(hostname: &str, alias: &str) -> String {
|
||||
let mut out = String::with_capacity(hostname.len());
|
||||
let mut chars = hostname.chars();
|
||||
@@ -61,10 +44,6 @@ fn expand_hostname_tokens(hostname: &str, alias: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// OpenSSH's ssh_config has no trailing-comment syntax: `#` only starts a
|
||||
/// comment at the beginning of a (whitespace-trimmed) line, and a `#` inside a
|
||||
/// value (a `ProxyCommand` fragment, a filename) is literal. Truncating
|
||||
/// mid-line would silently corrupt such values.
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
if line.trim_start().starts_with('#') {
|
||||
""
|
||||
@@ -178,43 +157,12 @@ fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
inner(pattern.as_bytes(), text.as_bytes())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ssh_config → profile import (PRD §3.3)
|
||||
//
|
||||
// The code below resolves the russh-mappable fields of each concrete
|
||||
// `Host` alias into a [`ManagedProfile`], so a config entry can connect natively
|
||||
// (there is no system-ssh fallback). Scope, per PRD §3.3:
|
||||
//
|
||||
// - fields resolved onto the native spec: HostName, User, Port, IdentityFile
|
||||
// (multiple), ProxyJump, ProxyCommand, ForwardAgent, ConnectTimeout,
|
||||
// ServerAliveInterval, ServerAliveCountMax, Ciphers, MACs, KexAlgorithms,
|
||||
// HostKeyAlgorithms, Compression, ForwardX11, StrictHostKeyChecking, and
|
||||
// LocalForward / RemoteForward / DynamicForward;
|
||||
// - first-match-wins per OpenSSH semantics, including wildcard `Host *` fallbacks;
|
||||
// IdentityFile and the forward directives accumulate across matching blocks;
|
||||
// - algorithm lists (`Ciphers`/`MACs`/…) are taken verbatim as an explicit list;
|
||||
// OpenSSH's `+`/`-`/`^` modifier syntax is NOT applied (such values are dropped);
|
||||
// - `Match` blocks and `canonicalize` are intentionally NOT evaluated, and there
|
||||
// is no fallback for a config that needs them (explicit tradeoff — see the doc);
|
||||
// - import is explicit and repeatable: re-importing an unchanged config is a
|
||||
// no-op (existing profiles are matched by name and their ids/secrets/flags kept).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One imported alias: the resolved profile plus the raw `ProxyJump` target (if
|
||||
/// any). Jump targets are strings here; mapping them to a profile id happens in
|
||||
/// [`merge_imported`], once all imported profiles have ids.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImportedProfile {
|
||||
/// The resolved profile (its `jump_host` is always `None` at this stage).
|
||||
pub profile: ManagedProfile,
|
||||
/// The raw `ProxyJump` target as written (e.g. `bastion`, `me@jump:2222`), if
|
||||
/// the alias set one.
|
||||
pub proxy_jump: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse `~/.ssh/config` (following `Include`) and resolve every concrete `Host`
|
||||
/// alias into an [`ImportedProfile`]. Returns an empty vec when no config exists.
|
||||
// Consumed by the import UI (a later workstream); unused until that merges.
|
||||
#[allow(dead_code)]
|
||||
pub fn import_profiles() -> Vec<ImportedProfile> {
|
||||
let Some(home) = home_dir() else {
|
||||
@@ -223,12 +171,9 @@ pub fn import_profiles() -> Vec<ImportedProfile> {
|
||||
import_profiles_from(home.join(".ssh/config"), &home)
|
||||
}
|
||||
|
||||
/// [`import_profiles`] against an explicit root/home (for tests).
|
||||
pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec<ImportedProfile> {
|
||||
let blocks = parse_config_blocks(root, home);
|
||||
|
||||
// Collect concrete aliases in first-seen order (dedup, skip wildcards/negations
|
||||
// and the synthetic pre-Host global block).
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for block in &blocks {
|
||||
@@ -255,29 +200,17 @@ pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec<ImportedProfile>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One alias resolved against `~/.ssh/config` into a transient in-memory profile,
|
||||
/// plus the raw `ProxyJump` target the alias set (if any). Unlike an
|
||||
/// [`ImportedProfile`], this is *not* persisted: it's built fresh per connect for
|
||||
/// the native (russh) path, so it carries a new id, no group, and no credential
|
||||
/// reference. The `proxy_jump` string is conveyed alongside because a transient
|
||||
/// profile has no store to resolve a jump *profile* against — the caller resolves
|
||||
/// the raw hop (another alias, or `user@host:port`) into the nested spec itself.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResolvedAlias {
|
||||
pub profile: ManagedProfile,
|
||||
pub proxy_jump: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve a single `~/.ssh/config` alias into a transient [`ManagedProfile`] for
|
||||
/// a native connect (PRD §3.3). Returns `None` when nothing in the config applies
|
||||
/// to `alias` (no matching `Host` block and no `HostName`), so the caller can fall
|
||||
/// back to treating the alias string as a bare hostname.
|
||||
pub fn resolve_alias_to_profile(alias: &str) -> Option<ResolvedAlias> {
|
||||
let home = home_dir()?;
|
||||
resolve_alias_to_profile_from(home.join(".ssh/config"), &home, alias)
|
||||
}
|
||||
|
||||
/// [`resolve_alias_to_profile`] against an explicit root/home (for tests).
|
||||
pub fn resolve_alias_to_profile_from(
|
||||
root: PathBuf,
|
||||
home: &Path,
|
||||
@@ -286,7 +219,6 @@ pub fn resolve_alias_to_profile_from(
|
||||
let blocks = parse_config_blocks(root, home);
|
||||
let matched = blocks.iter().any(|block| block_matches(block, alias));
|
||||
let resolved = resolve_alias(alias, &blocks);
|
||||
// Nothing in the config touches this alias — let the caller use it as a host.
|
||||
if !matched && resolved.hostname.is_none() {
|
||||
return None;
|
||||
}
|
||||
@@ -298,13 +230,7 @@ pub fn resolve_alias_to_profile_from(
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a [`ResolvedHost`] onto `profile`'s connection/session/algorithm/forward
|
||||
/// fields, returning the raw `ProxyJump` target (resolved to an id / nested spec
|
||||
/// by the caller). Shared by the import path and the transient-alias resolver.
|
||||
fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) -> Option<String> {
|
||||
// OpenSSH expands `%h` in HostName to the name given on the command line
|
||||
// (the alias) — the common `Host *.corp` + `HostName %h.internal` pattern
|
||||
// relies on it; taken verbatim it would try to resolve a literal `%h.…`.
|
||||
profile.host = r
|
||||
.hostname
|
||||
.map(|h| expand_hostname_tokens(&h, alias))
|
||||
@@ -323,8 +249,6 @@ fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) ->
|
||||
profile.algorithms.mac = r.macs.unwrap_or_default();
|
||||
profile.algorithms.kex = r.kex.unwrap_or_default();
|
||||
profile.algorithms.hostkey = r.hostkey_algorithms.unwrap_or_default();
|
||||
// ssh_config `Compression yes` → offer the OpenSSH compression set; anything
|
||||
// else leaves the list empty (russh defaults, i.e. no compression).
|
||||
profile.algorithms.compression = if r.compression == Some(true) {
|
||||
vec![
|
||||
"zlib@openssh.com".to_string(),
|
||||
@@ -338,20 +262,8 @@ fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) ->
|
||||
r.proxy_jump
|
||||
}
|
||||
|
||||
/// Upsert `imported` into `existing`, matched by profile **name** (the alias).
|
||||
///
|
||||
/// - New alias → pushed with a fresh id and the [`IMPORTED_GROUP`] label.
|
||||
/// - Existing name → connection fields are overwritten (host/port/user/identity
|
||||
/// files/proxy command/agent-forward/jump host); the user-owned id, group,
|
||||
/// `credential_ref`, auth, forwards, and other flags are preserved.
|
||||
///
|
||||
/// `ProxyJump` targets are resolved to `jump_host` ids in a second pass by
|
||||
/// matching the jump alias against a profile name; unresolved targets leave
|
||||
/// `jump_host` as `None`.
|
||||
// Consumed by the import UI (a later workstream); unused until that merges.
|
||||
#[allow(dead_code)]
|
||||
pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<ImportedProfile>) {
|
||||
// Remember each imported alias's raw jump target for the resolve pass.
|
||||
let mut jump_targets: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for entry in imported {
|
||||
@@ -364,7 +276,6 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported
|
||||
}
|
||||
match existing.iter_mut().find(|p| p.name == profile.name) {
|
||||
Some(current) => {
|
||||
// Overwrite connection fields; keep everything user-owned.
|
||||
current.host = profile.host;
|
||||
current.port = profile.port;
|
||||
current.user = profile.user;
|
||||
@@ -376,7 +287,6 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: resolve jump aliases → profile ids now that all names exist.
|
||||
for (name, raw) in jump_targets {
|
||||
let Some(target_alias) = jump_alias(&raw) else {
|
||||
continue;
|
||||
@@ -391,9 +301,7 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the alias/host from a `ProxyJump` target, taking the first hop of a
|
||||
/// comma-separated chain and stripping any `user@`/`:port` (bracketed IPv6 aware).
|
||||
#[allow(dead_code)] // only reached via merge_imported (a later workstream's entry point)
|
||||
#[allow(dead_code)]
|
||||
fn jump_alias(raw: &str) -> Option<String> {
|
||||
let first = raw.split(',').next().unwrap_or(raw).trim();
|
||||
if first.is_empty() {
|
||||
@@ -402,13 +310,11 @@ fn jump_alias(raw: &str) -> Option<String> {
|
||||
crate::core::ssh_profile::parse_quick_connect(first).map(|q| q.host)
|
||||
}
|
||||
|
||||
/// A single `Host <patterns>` block with its option lines (keyword lowercased).
|
||||
struct HostBlock {
|
||||
patterns: Vec<String>,
|
||||
options: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The subset of resolved options an import cares about.
|
||||
#[derive(Default)]
|
||||
struct ResolvedHost {
|
||||
hostname: Option<String>,
|
||||
@@ -427,17 +333,11 @@ struct ResolvedHost {
|
||||
hostkey_algorithms: Option<Vec<String>>,
|
||||
compression: Option<bool>,
|
||||
forward_x11: Option<bool>,
|
||||
/// `None` = follow the global setting; `Some(false)` = `StrictHostKeyChecking
|
||||
/// no` (disable verification). Only "no" maps; `accept-new`/`yes`/default leave
|
||||
/// this `None`. `strict_seen` gives first-match-wins over the tri-state.
|
||||
verify_host_keys: Option<bool>,
|
||||
strict_seen: bool,
|
||||
/// LocalForward / RemoteForward / DynamicForward, in config order (accumulated).
|
||||
forwards: Vec<ForwardRule>,
|
||||
}
|
||||
|
||||
/// Walk the config (expanding `Include` inline so file order — and thus
|
||||
/// first-match-wins — is preserved) into an ordered list of [`HostBlock`]s.
|
||||
fn parse_config_blocks(root: PathBuf, home: &Path) -> Vec<HostBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -465,11 +365,7 @@ fn parse_config_file(
|
||||
let base = path.parent().unwrap_or(home).to_path_buf();
|
||||
|
||||
let mut current: Option<HostBlock> = None;
|
||||
// Options appearing before the first `Host` apply globally; model them as a
|
||||
// synthetic `Host *` block so first-match-wins picks them up as a fallback.
|
||||
let mut global: Option<HostBlock> = None;
|
||||
// Inside an (unsupported) `Match` block, ignore option lines until the next
|
||||
// `Host`.
|
||||
let mut in_match = false;
|
||||
|
||||
for line in text.lines() {
|
||||
@@ -491,7 +387,6 @@ fn parse_config_file(
|
||||
options: Vec::new(),
|
||||
});
|
||||
} else if key.eq_ignore_ascii_case("match") {
|
||||
// Match is not evaluated; flush the current block and skip its options.
|
||||
if let Some(block) = current.take() {
|
||||
blocks.push(block);
|
||||
}
|
||||
@@ -500,8 +395,6 @@ fn parse_config_file(
|
||||
if in_match {
|
||||
continue;
|
||||
}
|
||||
// Flush the current block so included content sorts after it (close
|
||||
// enough for first-match-wins; nested-within-a-Host includes are rare).
|
||||
if let Some(block) = current.take() {
|
||||
blocks.push(block);
|
||||
}
|
||||
@@ -535,8 +428,6 @@ fn parse_config_file(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one alias against the ordered blocks with first-match-wins semantics
|
||||
/// (wildcard blocks included). `IdentityFile` accumulates across matching blocks.
|
||||
fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost {
|
||||
let mut r = ResolvedHost::default();
|
||||
for block in blocks {
|
||||
@@ -568,7 +459,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost {
|
||||
}
|
||||
}
|
||||
"proxycommand" if r.proxy_command.is_none() => {
|
||||
// A ProxyCommand is a whole command line — do not tokenize it.
|
||||
let v = val.trim();
|
||||
if !v.is_empty() && !v.eq_ignore_ascii_case("none") {
|
||||
r.proxy_command = Some(v.to_string());
|
||||
@@ -606,8 +496,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost {
|
||||
}
|
||||
"stricthostkeychecking" if !r.strict_seen => {
|
||||
r.strict_seen = true;
|
||||
// Only an explicit "no" (disable) maps to a native override;
|
||||
// accept-new / yes / ask / default leave it to the global check.
|
||||
if first_word(val).is_some_and(|v| {
|
||||
matches!(v.to_ascii_lowercase().as_str(), "no" | "off" | "false")
|
||||
}) {
|
||||
@@ -636,8 +524,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether a block's pattern list matches `alias` (OpenSSH semantics: at least one
|
||||
/// positive `*`/`?` glob matches and no negated `!pattern` matches).
|
||||
fn block_matches(block: &HostBlock, alias: &str) -> bool {
|
||||
let mut positive = false;
|
||||
for pat in &block.patterns {
|
||||
@@ -652,20 +538,14 @@ fn block_matches(block: &HostBlock, alias: &str) -> bool {
|
||||
positive
|
||||
}
|
||||
|
||||
/// The first whitespace-delimited word of a value, respecting quotes.
|
||||
fn first_word(value: &str) -> Option<String> {
|
||||
split_words(value).into_iter().next()
|
||||
}
|
||||
|
||||
/// Parse an OpenSSH yes/no-style boolean (case-insensitive; `true`/`false` too).
|
||||
fn yes_no(value: &str) -> bool {
|
||||
matches!(value.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
|
||||
/// Parse a comma-separated algorithm list (`Ciphers`/`MACs`/`KexAlgorithms`/…)
|
||||
/// into an explicit list. OpenSSH's `+`/`-`/`^` modifier syntax (append / remove /
|
||||
/// move-to-front relative to the built-in defaults) is NOT applied — such values
|
||||
/// are dropped (`None`) rather than mis-interpreted as an absolute list.
|
||||
fn parse_algorithm_list(value: &str) -> Option<Vec<String>> {
|
||||
let token = first_word(value)?;
|
||||
if token.starts_with(['+', '-', '^']) {
|
||||
@@ -679,9 +559,6 @@ fn parse_algorithm_list(value: &str) -> Option<Vec<String>> {
|
||||
(!list.is_empty()).then_some(list)
|
||||
}
|
||||
|
||||
/// Parse a `LocalForward`/`RemoteForward` (`[bind:]port host:hostport`) or a
|
||||
/// `DynamicForward` (`[bind:]port`) value into a [`ForwardRule`]. Returns `None`
|
||||
/// for a malformed line (so a single bad forward is skipped, never fatal).
|
||||
fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
|
||||
let words = split_words(value);
|
||||
let (bind_host, bind_port) = parse_forward_endpoint(words.first()?)?;
|
||||
@@ -691,7 +568,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
|
||||
ForwardKind::Local | ForwardKind::Remote => {
|
||||
let (target_host, target_port) = parse_forward_endpoint(words.get(1)?)?;
|
||||
if target_host.is_empty() {
|
||||
return None; // a Local/Remote forward target needs a host
|
||||
return None;
|
||||
}
|
||||
HostPort::new(target_host, target_port)
|
||||
}
|
||||
@@ -704,8 +581,6 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `[host:]port` / `[ipv6]:port` forward endpoint. An omitted host yields
|
||||
/// an empty string (the listen side may omit it).
|
||||
fn parse_forward_endpoint(token: &str) -> Option<(String, u16)> {
|
||||
if let Some(rest) = token.strip_prefix('[') {
|
||||
let close = rest.find(']')?;
|
||||
@@ -723,8 +598,6 @@ fn parse_forward_endpoint(token: &str) -> Option<(String, u16)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A listen-side bind host with the OpenSSH default (loopback) substituted for an
|
||||
/// omitted address.
|
||||
fn forward_bind_host(host: String) -> String {
|
||||
if host.is_empty() {
|
||||
"127.0.0.1".to_string()
|
||||
@@ -804,15 +677,13 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let imported = import_profiles_from(ssh.join("config"), &root);
|
||||
// Sorted by alias: bastion, prod.
|
||||
let names: Vec<_> = imported.iter().map(|i| i.profile.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["bastion", "prod"]);
|
||||
|
||||
let prod = &imported[1];
|
||||
assert_eq!(prod.profile.host, "10.0.0.5");
|
||||
assert_eq!(prod.profile.user, "deploy"); // specific block wins over Host *
|
||||
assert_eq!(prod.profile.user, "deploy");
|
||||
assert_eq!(prod.profile.port, 2222);
|
||||
// IdentityFile accumulates: the profile's own, then the Host * fallback.
|
||||
assert_eq!(
|
||||
prod.profile.identity_files,
|
||||
vec!["~/.ssh/id_prod".to_string(), "~/.ssh/id_common".to_string()]
|
||||
@@ -823,7 +694,6 @@ mod tests {
|
||||
|
||||
let bastion = &imported[0];
|
||||
assert_eq!(bastion.profile.host, "jump.example.com");
|
||||
// No User set → falls back to Host *.
|
||||
assert_eq!(bastion.profile.user, "fallback-user");
|
||||
assert_eq!(
|
||||
bastion.profile.proxy_command.as_deref(),
|
||||
@@ -843,7 +713,7 @@ mod tests {
|
||||
" HostName real.example.com\n",
|
||||
"Match host secure\n",
|
||||
" User should-be-ignored\n",
|
||||
"Host web !web-staging\n", // negation-bearing pattern list (not concrete)
|
||||
"Host web !web-staging\n",
|
||||
" HostName web.example.com\n",
|
||||
),
|
||||
)
|
||||
@@ -851,9 +721,7 @@ mod tests {
|
||||
|
||||
let imported = import_profiles_from(ssh.join("config"), &root);
|
||||
let names: Vec<_> = imported.iter().map(|i| i.profile.name.as_str()).collect();
|
||||
// `secure` and `web` are concrete; `!web-staging` is a negation, not an alias.
|
||||
assert_eq!(names, vec!["secure", "web"]);
|
||||
// The Match block's User must not leak onto `secure`.
|
||||
let secure = imported
|
||||
.iter()
|
||||
.find(|i| i.profile.name == "secure")
|
||||
@@ -875,7 +743,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A user already has a `prod` profile with a credential + custom auth.
|
||||
let mut existing = vec![{
|
||||
let mut p = ManagedProfile::new("prod");
|
||||
p.host = "old-host".to_string();
|
||||
@@ -889,11 +756,9 @@ mod tests {
|
||||
let imported = import_profiles_from(ssh.join("config"), &root);
|
||||
merge_imported(&mut existing, imported);
|
||||
|
||||
assert_eq!(existing.len(), 2); // prod updated + bastion added
|
||||
assert_eq!(existing.len(), 2);
|
||||
let prod = existing.iter().find(|p| p.name == "prod").unwrap();
|
||||
// Connection field overwritten...
|
||||
assert_eq!(prod.host, "10.0.0.5");
|
||||
// ...but id, group, credential, and auth preserved.
|
||||
assert_eq!(prod.id, prod_id);
|
||||
assert_eq!(prod.group.as_deref(), Some("My Servers"));
|
||||
assert_eq!(prod.auth, AuthMode::Password);
|
||||
@@ -902,8 +767,6 @@ mod tests {
|
||||
let bastion = existing.iter().find(|p| p.name == "bastion").unwrap();
|
||||
assert_eq!(bastion.group.as_deref(), Some(IMPORTED_GROUP));
|
||||
|
||||
// Re-import of the unchanged config is a no-op (idempotent): same ids, same
|
||||
// count, same fields.
|
||||
let snapshot = existing.clone();
|
||||
let imported_again = import_profiles_from(ssh.join("config"), &root);
|
||||
merge_imported(&mut existing, imported_again);
|
||||
@@ -929,7 +792,6 @@ mod tests {
|
||||
|
||||
let bastion_id = existing.iter().find(|p| p.name == "bastion").unwrap().id;
|
||||
let prod = existing.iter().find(|p| p.name == "prod").unwrap();
|
||||
// The `me@bastion:2222` jump target resolves to the `bastion` profile's id.
|
||||
assert_eq!(prod.jump_host, Some(bastion_id));
|
||||
}
|
||||
|
||||
@@ -953,9 +815,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hash_only_comments_whole_lines_not_values() {
|
||||
// OpenSSH has no trailing-comment syntax: a `#` inside a value is
|
||||
// literal (e.g. in a ProxyCommand), while a line starting with `#`
|
||||
// (after leading whitespace) is a comment.
|
||||
let root = temp_root("resolve-hash");
|
||||
let ssh = root.join(".ssh");
|
||||
std::fs::create_dir_all(&ssh).unwrap();
|
||||
@@ -1023,14 +882,12 @@ mod tests {
|
||||
assert_eq!(p.algorithms.mac, vec!["hmac-sha2-256"]);
|
||||
assert_eq!(p.algorithms.kex, vec!["curve25519-sha256"]);
|
||||
assert_eq!(p.algorithms.hostkey, vec!["ssh-ed25519"]);
|
||||
assert!(!p.algorithms.compression.is_empty()); // Compression yes → offered
|
||||
assert!(!p.algorithms.compression.is_empty());
|
||||
assert!(p.x11);
|
||||
assert_eq!(p.verify_host_keys, Some(false)); // StrictHostKeyChecking no
|
||||
// Transient profile: fresh id, no group, no credential.
|
||||
assert_eq!(p.verify_host_keys, Some(false));
|
||||
assert!(p.group.is_none());
|
||||
assert!(p.credential_ref.is_none());
|
||||
|
||||
// Forwards: Local, Remote, Dynamic — in config order, loopback bind default.
|
||||
let forwards = &p.forwards;
|
||||
assert_eq!(forwards.len(), 3);
|
||||
assert_eq!(forwards[0].kind, ForwardKind::Local);
|
||||
@@ -1048,7 +905,6 @@ mod tests {
|
||||
let root = temp_root("resolve-modifiers");
|
||||
let ssh = root.join(".ssh");
|
||||
std::fs::create_dir_all(&ssh).unwrap();
|
||||
// A `+`-prefixed Ciphers list modifies the defaults; we don't apply it.
|
||||
std::fs::write(
|
||||
ssh.join("config"),
|
||||
"Host m\n HostName h\n Ciphers +aes256-gcm@openssh.com\n",
|
||||
@@ -1065,14 +921,11 @@ mod tests {
|
||||
std::fs::create_dir_all(&ssh).unwrap();
|
||||
std::fs::write(ssh.join("config"), "Host *\n User fallback\n").unwrap();
|
||||
|
||||
// A bare host matches `Host *`, so the fallback User applies and the host
|
||||
// is the alias itself.
|
||||
let resolved =
|
||||
resolve_alias_to_profile_from(ssh.join("config"), &root, "example.com").unwrap();
|
||||
assert_eq!(resolved.profile.host, "example.com");
|
||||
assert_eq!(resolved.profile.user, "fallback");
|
||||
|
||||
// With no config at all, resolution yields nothing → caller uses the alias.
|
||||
assert!(resolve_alias_to_profile_from(root.join("missing"), &root, "whatever").is_none());
|
||||
}
|
||||
|
||||
@@ -1089,7 +942,6 @@ mod tests {
|
||||
|
||||
let prod = resolve_alias_to_profile_from(ssh.join("config"), &root, "prod").unwrap();
|
||||
assert_eq!(prod.proxy_jump.as_deref(), Some("bastion"));
|
||||
// The raw jump alias resolves against the same config into its own profile.
|
||||
let bastion = resolve_alias_to_profile_from(ssh.join("config"), &root, "bastion").unwrap();
|
||||
assert_eq!(bastion.profile.host, "jump.example.com");
|
||||
assert_eq!(bastion.profile.user, "jumper");
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
//! Notify-only update check.
|
||||
//!
|
||||
//! On GUI startup (unless `config.check_for_updates` is off) we make one GET to
|
||||
//! the GitHub releases API, compare the latest published version against the
|
||||
//! running binary, and — if it's newer — stash an [`UpdateStatus`] global that
|
||||
//! Settings → About reads to show a "download" prompt linking to the Releases
|
||||
//! page. That's the whole feature: we never download, replace, or restart
|
||||
//! anything. The user updates by hand (drag the new `.app`, unzip, …), exactly
|
||||
//! as the README's Install section describes.
|
||||
//!
|
||||
//! Everything here fails soft: no network, a rate-limit, a private/renamed repo,
|
||||
//! an unparseable tag — all collapse to "no prompt", logged at `debug` and never
|
||||
//! surfaced. A terminal must open the same whether or not GitHub is reachable.
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::http_client::{AsyncBody, HttpClient as _, HttpRequestExt as _, RedirectPolicy};
|
||||
use gpui::{AnyWindowHandle, App, AsyncApp, Global, PromptLevel, Window, http_client};
|
||||
@@ -22,32 +8,17 @@ use std::time::Duration;
|
||||
|
||||
use crate::core::config::Config;
|
||||
|
||||
/// `owner/repo` the release check queries — matches the repository the binary is
|
||||
/// published from (see `Cargo.toml`'s `repository`).
|
||||
const REPO: &str = "l0ng-ai/tty7";
|
||||
|
||||
/// Where the "Download" prompt points. GitHub's `/releases/latest` alias always
|
||||
/// resolves to the newest published (non-prerelease) build, so it never goes
|
||||
/// stale as versions roll — no need to embed a specific tag.
|
||||
pub const RELEASES_URL: &str = "https://github.com/l0ng-ai/tty7/releases/latest";
|
||||
|
||||
/// Overall wall-clock budget for the check. `ReqwestClient` only sets a *connect*
|
||||
/// timeout, so without this a connected-but-stalled response could sit pending
|
||||
/// for the whole session; racing a timer keeps the "fail soft" behavior
|
||||
/// deterministic instead of open-ended.
|
||||
const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// A newer release than the one currently running.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AvailableUpdate {
|
||||
/// The newer version, normalized without a leading `v` (e.g. `"0.3.1"`), for
|
||||
/// display in the About panel.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// The result of the startup update check, stored as a GPUI global so the
|
||||
/// Settings view can read it. Absent until the check completes; `available` is
|
||||
/// `None` when we're already current (or the check failed / was skipped).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UpdateStatus {
|
||||
pub available: Option<AvailableUpdate>,
|
||||
@@ -55,15 +26,6 @@ pub struct UpdateStatus {
|
||||
|
||||
impl Global for UpdateStatus {}
|
||||
|
||||
/// Kick off the background update check. Returns immediately; the network work
|
||||
/// runs on a detached task and, if a newer version exists:
|
||||
/// 1. writes the [`UpdateStatus`] global (the passive Settings → About prompt,
|
||||
/// shown on every launch while outdated), and
|
||||
/// 2. pops a one-time modal dialog for that version — but only the first
|
||||
/// launch it's seen; the version is remembered in `update.json` so we never
|
||||
/// nag twice for the same release.
|
||||
///
|
||||
/// Honors `config.check_for_updates`: when off, we make no network call at all.
|
||||
pub fn spawn_check(cx: &mut App) {
|
||||
if !cx.global::<Config>().check_for_updates {
|
||||
return;
|
||||
@@ -71,14 +33,9 @@ pub fn spawn_check(cx: &mut App) {
|
||||
spawn_check_forced(cx);
|
||||
}
|
||||
|
||||
/// The same check, ignoring the `check_for_updates` toggle — for explicit
|
||||
/// user-initiated checks (the tray's "Check for Updates…"), where "I asked"
|
||||
/// overrides "don't ask on my behalf at startup".
|
||||
pub fn spawn_check_forced(cx: &mut App) {
|
||||
cx.spawn(async move |cx| {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
// Race the fetch against a timer so a stalled connection can't leave the
|
||||
// task pending forever; whichever finishes first wins.
|
||||
let latest = match fetch_latest_version()
|
||||
.or(async {
|
||||
cx.background_executor().timer(CHECK_TIMEOUT).await;
|
||||
@@ -88,8 +45,6 @@ pub fn spawn_check_forced(cx: &mut App) {
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// `{e:#}` includes the anyhow context chain; kept at debug so a
|
||||
// routine offline start doesn't spam the log.
|
||||
log::debug!("update check skipped: {e:#}");
|
||||
return;
|
||||
}
|
||||
@@ -103,8 +58,6 @@ pub fn spawn_check_forced(cx: &mut App) {
|
||||
let version = latest.trim_start_matches('v').to_string();
|
||||
log::info!("update available: {version} (running {current})");
|
||||
|
||||
// Record it for the passive Settings → About prompt and repaint so an
|
||||
// already-open About picks it up now rather than on the next interaction.
|
||||
cx.update(|cx| {
|
||||
cx.set_global(UpdateStatus {
|
||||
available: Some(AvailableUpdate {
|
||||
@@ -114,15 +67,10 @@ pub fn spawn_check_forced(cx: &mut App) {
|
||||
cx.refresh_windows();
|
||||
});
|
||||
|
||||
// Active modal: pop exactly once per version. If a previous launch
|
||||
// already showed it for this version, stop here — About still carries
|
||||
// the passive prompt.
|
||||
if UpdateState::load().last_prompted.as_deref() == Some(version.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The check can outrace the window-open task at startup; wait briefly
|
||||
// for a window to host the modal before giving up.
|
||||
let Some(window) = wait_for_window(cx).await else {
|
||||
return;
|
||||
};
|
||||
@@ -132,8 +80,6 @@ pub fn spawn_check_forced(cx: &mut App) {
|
||||
.is_ok()
|
||||
});
|
||||
|
||||
// Persist only after the modal actually went up, so a version we never
|
||||
// managed to show still gets its one prompt on a later launch.
|
||||
if shown {
|
||||
UpdateState {
|
||||
last_prompted: Some(version),
|
||||
@@ -144,11 +90,7 @@ pub fn spawn_check_forced(cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Poll (briefly) for the app's main window. Returns `None` if none appears
|
||||
/// within the window — treated as "no host for the modal", so we simply skip it.
|
||||
async fn wait_for_window(cx: &mut AsyncApp) -> Option<AnyWindowHandle> {
|
||||
// ~5s of 100ms ticks. The network round-trip almost always finishes after
|
||||
// the window is already up, so this usually returns on the first poll.
|
||||
for _ in 0..50 {
|
||||
if let Some(handle) = cx.update(|cx| cx.windows().first().copied()) {
|
||||
return Some(handle);
|
||||
@@ -160,14 +102,11 @@ async fn wait_for_window(cx: &mut AsyncApp) -> Option<AnyWindowHandle> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Show the one-time "update available" modal, and open the Releases page if the
|
||||
/// user picks Download. Mirrors the window-close confirmation's prompt style.
|
||||
fn prompt_update(version: &str, window: &mut Window, cx: &mut App) {
|
||||
let detail = format!(
|
||||
"tty7 {version} is available — you're on {}. Open the download page to get it.",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
// Index 1 == "Download"; index 0 (Later) and a dismissed prompt do nothing.
|
||||
let answer = window.prompt(
|
||||
PromptLevel::Info,
|
||||
"Update available",
|
||||
@@ -183,8 +122,6 @@ fn prompt_update(version: &str, window: &mut Window, cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Open the GitHub Releases page with the OS default handler. Shared by the
|
||||
/// modal's Download button and the Settings → About Download button.
|
||||
pub fn open_releases_page() {
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
@@ -198,9 +135,6 @@ pub fn open_releases_page() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny persisted state for the update checker, stored at `update.json` in the
|
||||
/// config dir (alongside `config.json` / `views.json`). Currently just the
|
||||
/// last version we popped the modal for, so we never nag twice for one release.
|
||||
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
struct UpdateState {
|
||||
#[serde(default)]
|
||||
@@ -212,8 +146,6 @@ impl UpdateState {
|
||||
crate::core::config::config_path("update.json")
|
||||
}
|
||||
|
||||
/// Load persisted state; a missing / unreadable / malformed file all yield
|
||||
/// the default (never prompted), so at worst we prompt once more.
|
||||
fn load() -> Self {
|
||||
let Some(path) = Self::path() else {
|
||||
return Self::default();
|
||||
@@ -227,7 +159,6 @@ impl UpdateState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist state; IO / serialization errors are logged and swallowed.
|
||||
fn save(&self) {
|
||||
let Some(path) = Self::path() else {
|
||||
return;
|
||||
@@ -245,17 +176,12 @@ impl UpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tag_name` field of GitHub's release payload — the only piece we read.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LatestRelease {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
/// GET the repo's latest release and return its raw tag (e.g. `"v0.3.1"`).
|
||||
async fn fetch_latest_version() -> Result<String> {
|
||||
// GitHub rejects requests without a User-Agent; identify ourselves. The
|
||||
// reqwest+rustls stack this rides on is already compiled into the app via
|
||||
// `gpui-component-assets`, so constructing a client here is cheap.
|
||||
let client = ReqwestClient::user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION")))
|
||||
.context("building HTTP client")?;
|
||||
|
||||
@@ -287,23 +213,8 @@ async fn fetch_latest_version() -> Result<String> {
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
/// Parse a version string into a `(major, minor, patch, is_release)` tuple,
|
||||
/// tolerating a leading `v`. Missing minor/patch components read as `0`.
|
||||
/// Returns `None` if the numeric core doesn't parse — the caller treats that
|
||||
/// as "don't prompt".
|
||||
///
|
||||
/// The trailing `is_release` flag implements semver's pre-release ordering
|
||||
/// through plain tuple comparison: a `-nightly.20260716` (or `-rc.1`) suffix
|
||||
/// makes it `false`, which sorts *below* the same core with `true` — so a
|
||||
/// nightly binary counts as older than the stable release it previews, and the
|
||||
/// prompt fires when that stable ships. Finer ordering *between* pre-releases
|
||||
/// isn't needed: the check only ever compares against `/releases/latest`,
|
||||
/// which never returns a pre-release. Build metadata (`+ci`) is ignored, as
|
||||
/// semver says it should be.
|
||||
fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> {
|
||||
let trimmed = s.trim();
|
||||
// Strip a single optional `v` prefix. `strip_prefix` (not `trim_start_matches`)
|
||||
// so a doubled `vv0.3.1` fails to parse instead of silently losing the extra v.
|
||||
let core = trimmed.strip_prefix('v').unwrap_or(trimmed);
|
||||
let is_release = !core.split('+').next().unwrap_or(core).contains('-');
|
||||
let core = core.split(['-', '+']).next().unwrap_or(core);
|
||||
@@ -311,17 +222,12 @@ fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> {
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next().unwrap_or("0").parse().ok()?;
|
||||
let patch = parts.next().unwrap_or("0").parse().ok()?;
|
||||
// Reject extra components (`0.3.1.1`) rather than truncating them: an
|
||||
// unrecognizable tag must never surface a bogus "update available".
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((major, minor, patch, is_release))
|
||||
}
|
||||
|
||||
/// Whether `latest` names a strictly newer version than `current`. If either
|
||||
/// side fails to parse we return `false`: an unrecognizable tag should never
|
||||
/// nag the user to "update" to something we can't even order.
|
||||
fn is_update_available(latest: &str, current: &str) -> bool {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(latest), Some(current)) => latest > current,
|
||||
@@ -338,22 +244,16 @@ mod tests {
|
||||
assert_eq!(parse_version("v0.3.1"), Some((0, 3, 1, true)));
|
||||
assert_eq!(parse_version("0.3.1"), Some((0, 3, 1, true)));
|
||||
assert_eq!(parse_version(" 1.2.0 "), Some((1, 2, 0, true)));
|
||||
// Missing components default to zero.
|
||||
assert_eq!(parse_version("v2"), Some((2, 0, 0, true)));
|
||||
assert_eq!(parse_version("v2.5"), Some((2, 5, 0, true)));
|
||||
// A pre-release suffix keeps the core but sorts below the release…
|
||||
assert_eq!(parse_version("v0.4.0-rc.1"), Some((0, 4, 0, false)));
|
||||
assert_eq!(
|
||||
parse_version("26.7.1-nightly.20260716"),
|
||||
Some((26, 7, 1, false))
|
||||
);
|
||||
// …while build metadata alone is still a release.
|
||||
assert_eq!(parse_version("0.4.0+ci.7"), Some((0, 4, 0, true)));
|
||||
// Garbage yields None.
|
||||
assert_eq!(parse_version("nightly"), None);
|
||||
assert_eq!(parse_version(""), None);
|
||||
// Malformed cores are rejected, not truncated into a bogus version:
|
||||
// extra components or a doubled prefix must not parse.
|
||||
assert_eq!(parse_version("v0.3.1.1"), None);
|
||||
assert_eq!(parse_version("0.3.1.0"), None);
|
||||
assert_eq!(parse_version("vv0.3.1"), None);
|
||||
@@ -364,7 +264,6 @@ mod tests {
|
||||
assert!(is_update_available("v0.3.1", "0.3.0"));
|
||||
assert!(is_update_available("v1.0.0", "0.9.9"));
|
||||
assert!(is_update_available("0.4.0", "0.3.99"));
|
||||
// CalVer jump over the old 0.x tags still orders correctly.
|
||||
assert!(is_update_available("v26.7.0", "0.17.0"));
|
||||
}
|
||||
|
||||
@@ -377,15 +276,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nightly_binaries_prompt_when_their_stable_ships() {
|
||||
// A nightly previews the next stable: same core, sorts below it.
|
||||
assert!(is_update_available("v26.7.1", "26.7.1-nightly.20260716"));
|
||||
// …but the stable it was built *after* is not an update.
|
||||
assert!(!is_update_available("v26.7.0", "26.7.1-nightly.20260716"));
|
||||
// A stable binary is never downgraded to a pre-release of itself.
|
||||
assert!(!is_update_available("v26.7.1-rc.1", "26.7.1"));
|
||||
// Nightly-to-nightly is deliberately not an update: pre-releases with
|
||||
// the same core compare equal, and the check only ever sees
|
||||
// /releases/latest, which is never a pre-release anyway.
|
||||
assert!(!is_update_available(
|
||||
"26.7.1-nightly.20260717",
|
||||
"26.7.1-nightly.20260716"
|
||||
@@ -396,30 +289,24 @@ mod tests {
|
||||
fn unparseable_tag_never_prompts() {
|
||||
assert!(!is_update_available("garbage", "0.3.0"));
|
||||
assert!(!is_update_available("v0.3.1", "garbage"));
|
||||
// A malformed newer-looking tag must not surface a bogus update.
|
||||
assert!(!is_update_available("v0.4.0.1", "0.3.0"));
|
||||
assert!(!is_update_available("vv0.4.0", "0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_state_round_trips_and_defaults() {
|
||||
// Pin a throwaway config dir (first-call-wins; same scheme the session
|
||||
// tests use, so the whole test binary shares one temp dir).
|
||||
crate::core::config::pin_test_config_dir();
|
||||
let path = UpdateState::path().expect("config dir pinned");
|
||||
|
||||
// Missing file → default (never prompted), so we'd prompt.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(UpdateState::load().last_prompted, None);
|
||||
|
||||
// A recorded version round-trips, so a second launch skips the modal.
|
||||
UpdateState {
|
||||
last_prompted: Some("0.4.0".into()),
|
||||
}
|
||||
.save();
|
||||
assert_eq!(UpdateState::load().last_prompted.as_deref(), Some("0.4.0"));
|
||||
|
||||
// Don't leak state into other runs sharing the pinned dir.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
//! The gpui-facing half of [`WindowState`].
|
||||
//!
|
||||
//! The struct itself, its `window.json` IO, and the "is this geometry sane"
|
||||
//! guard live in `tty7-core` — `views.json` embeds the geometry in each
|
||||
//! [`WindowView`](crate::core::session::WindowView), which is defined there.
|
||||
//! What is left here is the only part that genuinely needs gpui: turning the
|
||||
//! four stored `f32`s into a [`Bounds<Pixels>`] and back.
|
||||
|
||||
use gpui::{Bounds, Pixels, point, px};
|
||||
|
||||
pub use tty7_core::core::window_state::WindowState;
|
||||
|
||||
/// Conversions between the stored geometry and gpui's window bounds.
|
||||
///
|
||||
/// An extension trait rather than inherent methods because the type lives in
|
||||
/// `tty7-core`; bring it into scope and `WindowState::from_bounds(..)` /
|
||||
/// `state.bounds()` read exactly as they did before the crate split.
|
||||
pub trait WindowGeometry: Sized {
|
||||
/// Capture a window's current bounds for persisting.
|
||||
fn from_bounds(bounds: Bounds<Pixels>) -> Self;
|
||||
/// The bounds to reopen a window at.
|
||||
fn bounds(&self) -> Bounds<Pixels>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
//! The persistent terminal daemon, as the GUI sees it.
|
||||
//!
|
||||
//! The daemon itself — PTY ownership, replay rings, fan-out, the wire protocol,
|
||||
//! the native SSH engine — moved wholesale into `tty7-core` when the headless
|
||||
//! `tty7-server` needed to run exactly the same code on a machine with no
|
||||
//! display. Nothing about it was GUI-shaped to begin with (it has never
|
||||
//! referenced gpui, `terminal`, or `ui`), so the move was a relocation, not a
|
||||
//! rewrite.
|
||||
//!
|
||||
//! This re-export keeps the GUI's call sites reading `crate::daemon::protocol`,
|
||||
//! `crate::daemon::spawn::ensure_running`, and so on, exactly as before. The
|
||||
//! client-side terminal that talks the protocol is still
|
||||
//! `terminal::remote::RemoteTerminal`.
|
||||
|
||||
pub use tty7_core::daemon::*;
|
||||
|
||||
-188
@@ -1,5 +1,3 @@
|
||||
// Hide the console window on Windows release builds; keep it in debug builds
|
||||
// so println!/eprintln! output remains visible while developing.
|
||||
#![cfg_attr(
|
||||
all(target_os = "windows", not(debug_assertions)),
|
||||
windows_subsystem = "windows"
|
||||
@@ -15,10 +13,6 @@ use crate::ui::assets::Assets;
|
||||
use crate::ui::keymap;
|
||||
use gpui::*;
|
||||
|
||||
/// Register the bundled Hack monospace faces with gpui's text system so the
|
||||
/// default `font_family` ("Hack") renders identically on every machine, with no
|
||||
/// dependency on the user having the font installed (the app bundles its
|
||||
/// own copy). The four faces cover regular / bold / italic / bold-italic.
|
||||
fn register_bundled_fonts(cx: &mut App) {
|
||||
use std::borrow::Cow;
|
||||
let fonts = vec![
|
||||
@@ -32,63 +26,28 @@ fn register_bundled_fonts(cx: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `config.json` and hot-reload the app when it changes on disk, so
|
||||
/// hand-edits (or an external tool rewriting the file) take effect live — no
|
||||
/// restart. We watch the config *directory*, not the file: editors and our own
|
||||
/// [`Config::save`] replace `config.json` via a temp-file + rename (atomic
|
||||
/// write), which severs any watch bound to the original inode. Watching the
|
||||
/// parent and filtering to `config.json` events survives the swap.
|
||||
///
|
||||
/// The `notify` callback fires on a background OS thread, which can't touch GPUI
|
||||
/// state. We bridge to the app (main) thread the same way the daemon reader does
|
||||
/// (see `terminal::remote`): a `smol::channel` carries a bare "something changed"
|
||||
/// ping, and a `cx.spawn` task on the foreground executor drains it and does the
|
||||
/// reload with a real `&mut App`.
|
||||
///
|
||||
/// Scope note: this re-applies theme + colors live (via `apply_theme`, which
|
||||
/// reads the freshly-loaded `Config` global). Font size / line height / font
|
||||
/// family are cached in `Tty7App`'s fields and pushed into each `TerminalView`,
|
||||
/// so a live change to *those* keys needs a hook in `ui::app` (owned elsewhere);
|
||||
/// they still take effect for newly-opened tabs and on restart. Font *family*
|
||||
/// changes need no font re-registration: `add_fonts` is only for bundled/custom
|
||||
/// face files (we ship Hack, registered once at startup); any other family is a
|
||||
/// system font gpui resolves by name at render time.
|
||||
fn spawn_config_watcher(cx: &mut App) {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
// Resolve the file we care about and the directory we actually watch. If the
|
||||
// config dir doesn't resolve (no override/env/$HOME) there's nothing to do.
|
||||
let Some(config_file) = crate::core::config::config_path("config.json") else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = crate::core::config::config_dir_path() else {
|
||||
return;
|
||||
};
|
||||
// The dir may not exist yet on a first run; watching a missing path errors.
|
||||
// Create it so the watch attaches (harmless — the daemon/save would too).
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
|
||||
// Coalesce a save's burst of events (truncate → write → rename can fire
|
||||
// several times) into a single reload: on the first ping we wait out a short
|
||||
// quiet period, drain anything queued, then reload once.
|
||||
const DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
|
||||
|
||||
let (tx, rx) = smol::channel::unbounded::<()>();
|
||||
let watched_file = config_file.clone();
|
||||
let handler = move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
// React to events that touch our `config.json`, or a theme file dropped
|
||||
// into the `themes/` subfolder — both feed the same registry reload below.
|
||||
// Everything else in the dir (`views.json`, `history`, the daemon
|
||||
// socket, and our own `.config.json.tmp.<pid>` / `*.yaml.tmp.<pid>` atomic
|
||||
// scratch files, whose extensions aren't theme extensions) is ignored.
|
||||
let hit = event
|
||||
.paths
|
||||
.iter()
|
||||
.any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p));
|
||||
if hit {
|
||||
// try_send: a full channel just means a reload is already pending;
|
||||
// one ping is enough to trigger the (idempotent) reload.
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
};
|
||||
@@ -100,8 +59,6 @@ fn spawn_config_watcher(cx: &mut App) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Recursive so the `themes/` subfolder is covered too (it may not exist yet;
|
||||
// FSEvents picks up subdirs created later). The handler filters the noise.
|
||||
if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) {
|
||||
log::warn!(
|
||||
"config hot-reload disabled: failed to watch {}: {e}",
|
||||
@@ -109,58 +66,25 @@ fn spawn_config_watcher(cx: &mut App) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The `RecommendedWatcher` owns the background watch thread; dropping it stops
|
||||
// watching. It has to live for the whole app, so we intentionally leak it
|
||||
// rather than thread a handle through app state (there's exactly one, for the
|
||||
// process lifetime, so a one-off leak is the simplest correct choice).
|
||||
Box::leak(Box::new(watcher));
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
while rx.recv().await.is_ok() {
|
||||
// Debounce: let the save settle, then swallow the rest of the burst so
|
||||
// we reload exactly once.
|
||||
cx.background_executor().timer(DEBOUNCE).await;
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
cx.update(|cx| {
|
||||
// `Config::load` clamps/validates and falls back to defaults on a
|
||||
// parse error (a half-written file mid-edit), so a bad reload can
|
||||
// never crash the renderer — worst case we momentarily show
|
||||
// defaults until the next (valid) save re-triggers this.
|
||||
cx.set_global(Config::load());
|
||||
// Reload the theme registry too, so edits to (or new) theme files
|
||||
// in the themes folder take effect on the same hot-reload path.
|
||||
crate::ui::presets::load_registry(cx);
|
||||
crate::ui::theme::apply_cursor_hide_mode(cx);
|
||||
// Re-paint theme + colors from the new config. We have no window
|
||||
// handle in this global task, but `apply_theme` accepts `None`:
|
||||
// it still updates the `Theme`/palette globals (what actually
|
||||
// repaints). The window-bound effects — the Transparent↔Blurred
|
||||
// background flip and traffic-light re-pinning — are covered by
|
||||
// `Tty7App::reload_from_config`, which observes the `Config`
|
||||
// global with its window and re-runs `apply_theme(Some(window))`.
|
||||
crate::ui::theme::apply_theme(None, cx);
|
||||
// Schedule every window to redraw so the new palette shows at once.
|
||||
cx.refresh_windows();
|
||||
});
|
||||
}
|
||||
// Loop only ends if every `Sender` drops — but the sole sender lives in
|
||||
// the leaked watcher's handler, so in practice this runs for the app's
|
||||
// lifetime.
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Note on feedback loops: our own `Config::save` (theme toggle, font zoom)
|
||||
// rewrites `config.json` and will trip this watcher. That's benign — the
|
||||
// reload reads back the same content we just wrote and re-applies it
|
||||
// idempotently, so it can't oscillate; it's at worst one redundant repaint.
|
||||
}
|
||||
|
||||
/// Whether `p` is a theme file living directly in the `themes/` subfolder — a
|
||||
/// `*.yaml` / `*.yml` / `*.itermcolors` whose parent directory is named `themes`.
|
||||
/// The parent check keeps a stray yaml elsewhere in the config dir from tripping
|
||||
/// a theme reload, and the extension check excludes the `*.tmp.<pid>` scratch
|
||||
/// files atomic writes leave behind mid-save.
|
||||
fn is_theme_file(p: &std::path::Path) -> bool {
|
||||
p.parent().and_then(|d| d.file_name()) == Some(std::ffi::OsStr::new("themes"))
|
||||
&& p.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
@@ -170,9 +94,6 @@ fn is_theme_file(p: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `--config-dir <path>` (or `--config-dir=<path>`) from the CLI and pin
|
||||
/// it as the process config directory before anything reads config. Lets a dev
|
||||
/// build keep its state in a throwaway folder — see the `dev` cargo alias.
|
||||
fn apply_config_dir_arg() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -189,8 +110,6 @@ fn apply_config_dir_arg() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two `:`-separated PATH lists, primary entries first, deduped, empties
|
||||
/// dropped. Pure so it can be unit-tested; the env write stays in the caller.
|
||||
#[cfg(unix)]
|
||||
fn merge_paths(primary: &str, secondary: &str) -> String {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -202,19 +121,9 @@ fn merge_paths(primary: &str, secondary: &str) -> String {
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// GUI apps launched from Finder/Dock inherit Launch Services' minimal PATH
|
||||
/// (`/usr/bin:/bin:/usr/sbin:/sbin`), not the user's shell PATH — so the
|
||||
/// completion engine's `$PATH` scan (`terminal::completion`) can't see
|
||||
/// Homebrew/cargo/… executables and command candidates silently vanish. Ask the
|
||||
/// user's login shell for its PATH once and merge it in front of ours (current
|
||||
/// entries are kept: terminal launches may carry extras like direnv paths).
|
||||
/// Login-but-not-interactive (`-l -c`) keeps it cheap: zsh reads .zprofile, not
|
||||
/// .zshrc. Shells spawned by the daemon are unaffected either way — they are
|
||||
/// login shells and rebuild PATH themselves.
|
||||
#[cfg(unix)]
|
||||
fn enrich_path_from_login_shell() {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||
// fish prints `$PATH` space-separated; ask it to join with ':' explicitly.
|
||||
let cmd = if std::path::Path::new(&shell).file_name() == Some("fish".as_ref()) {
|
||||
"string join ':' $PATH"
|
||||
} else {
|
||||
@@ -239,15 +148,9 @@ fn enrich_path_from_login_shell() {
|
||||
return;
|
||||
}
|
||||
let merged = merge_paths(&login_path, &std::env::var("PATH").unwrap_or_default());
|
||||
// SAFETY: called from `main` before any thread is spawned, so no concurrent
|
||||
// getenv can race the write.
|
||||
unsafe { std::env::set_var("PATH", merged) };
|
||||
}
|
||||
|
||||
/// A bare (non-bundled) binary — `cargo dev` / `cargo run` — has no
|
||||
/// `Info.plist` pointing the Dock at `tty7.icns`, so macOS shows the generic
|
||||
/// executable icon. Feed the Dock the bundled logo at runtime in that case;
|
||||
/// launches from the real `.app` keep the `.icns` and skip this.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_dock_icon_for_bare_binary() {
|
||||
use objc2::{AnyThread, MainThreadMarker};
|
||||
@@ -261,16 +164,12 @@ fn set_dock_icon_for_bare_binary() {
|
||||
if bundled {
|
||||
return;
|
||||
}
|
||||
// gpui's `run` closure executes on the main thread; bail defensively
|
||||
// rather than panic if that ever stops holding.
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
static ICON_PNG: &[u8] = include_bytes!("../assets/app-icon.png");
|
||||
let data = NSData::with_bytes(ICON_PNG);
|
||||
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
|
||||
// SAFETY: passing a valid NSImage on the main thread; AppKit copies the
|
||||
// reference, no ownership transferred.
|
||||
unsafe {
|
||||
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image));
|
||||
}
|
||||
@@ -278,11 +177,6 @@ fn set_dock_icon_for_bare_binary() {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
|
||||
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
|
||||
// payload from stdin, writes one OSC sequence to the controlling terminal,
|
||||
// and exits — never touching config, the daemon, or the GUI. Checked first
|
||||
// so a hook can never accidentally boot a window.
|
||||
{
|
||||
let args: Vec<String> = std::env::args().skip(1).take(3).collect();
|
||||
if args.first().map(String::as_str) == Some("agent-hook") {
|
||||
@@ -293,35 +187,16 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the config directory override (if any) up front, before any code
|
||||
// path touches config/session/history files (the daemon socket path resolves
|
||||
// under this dir too, so the order matters).
|
||||
apply_config_dir_arg();
|
||||
|
||||
// Panics inside gpui's `extern "C"` input callbacks abort instead of
|
||||
// unwinding, and the OS crash report then holds the abort rather than the
|
||||
// panic — no message, no location. Record those to `crash.log` in the config
|
||||
// dir. Installed here, right after the config dir resolves, so both the GUI
|
||||
// and the daemon below are covered from their first line of real work.
|
||||
let role = if std::env::args().any(|a| a == "--daemon") {
|
||||
"daemon"
|
||||
} else {
|
||||
"gui"
|
||||
};
|
||||
crate::core::crash::install(role);
|
||||
// And the ordinary `log::` records, which otherwise go nowhere at all —
|
||||
// the daemon's stdio is `/dev/null` by the time it is detached. Off unless
|
||||
// `TTY7_LOG` asks for it; see `core::logfile`.
|
||||
crate::core::logfile::install(role);
|
||||
|
||||
// Daemon mode: when launched with `--daemon` we run the headless persistent
|
||||
// terminal server and never open a window. This is the backing process the GUI
|
||||
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
|
||||
// the GUI. It is the *same* daemon `tty7-server --daemon` runs on a remote
|
||||
// box — panes plus the control dialect — because a local machine and a
|
||||
// remote one are the same thing seen from different distances, and the
|
||||
// workspace tree both serve lives behind control. Run to completion (the
|
||||
// accept loop blocks until killed) then return.
|
||||
if std::env::args().any(|a| a == "--daemon") {
|
||||
if let Err(e) = crate::daemon::server::run_daemon() {
|
||||
log::error!("daemon exited with error: {e}");
|
||||
@@ -329,33 +204,14 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop mode: `--stop-daemon` shuts the persistent daemon down (hanging up
|
||||
// every shell) and returns without ever opening a window. On Windows the
|
||||
// detached daemon is the running image of `tty7.exe`, so it locks the file
|
||||
// and blocks an upgrade/uninstall from replacing it; the installer runs this
|
||||
// first to release the lock. Harmless when no daemon is running.
|
||||
if std::env::args().any(|a| a == "--stop-daemon") {
|
||||
crate::daemon::spawn::stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// GUI path: repair the starved Launch Services PATH before anything reads it
|
||||
// (completion scans it per keystroke; the daemon we spawn below inherits it).
|
||||
#[cfg(unix)]
|
||||
enrich_path_from_login_shell();
|
||||
|
||||
// Make sure the persistent daemon is up before we open a window, so the
|
||||
// very first RemoteTerminal can connect. This auto-spawns a detached
|
||||
// daemon if none is running (sharing our config dir). Failure is non-fatal —
|
||||
// we log and continue; a still-absent daemon will surface later when a
|
||||
// RemoteTerminal fails to connect, rather than blocking startup here.
|
||||
//
|
||||
// When session restore is off, start the daemon *fresh* instead of reusing a
|
||||
// live one: this launch won't re-attach to the previous session's panes, so
|
||||
// reusing the daemon would leave those shells running orphaned (unreachable,
|
||||
// never hung up). `restart()` hangs up every old shell then spawns a clean
|
||||
// daemon — and is safe (equivalent to a plain spawn) when none is running.
|
||||
// Read straight off disk; the `Config` global isn't set until inside `run`.
|
||||
let restore_session = crate::core::config::Config::load().restore_session;
|
||||
let daemon_result = if restore_session {
|
||||
crate::daemon::spawn::ensure_running()
|
||||
@@ -366,8 +222,6 @@ fn main() {
|
||||
log::error!("failed to ensure daemon is running: {e}");
|
||||
}
|
||||
|
||||
// Register the bundled icon/font asset source so gpui-component `Icon`s
|
||||
// (tab glyphs, sidebar icons, etc.) can actually load their SVGs.
|
||||
gpui_platform::application()
|
||||
.with_assets(Assets)
|
||||
.run(move |cx| {
|
||||
@@ -376,63 +230,22 @@ fn main() {
|
||||
cx.activate(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
set_dock_icon_for_bare_binary();
|
||||
// Load user config once and stash it as a global for views to read.
|
||||
cx.set_global(Config::load());
|
||||
// Seed the cached OS light/dark appearance before anything resolves a
|
||||
// theme from it. It has to be read here, off the appearance-observer
|
||||
// path — see `ui::theme::SystemAppearance`.
|
||||
crate::ui::theme::refresh_system_appearance(cx);
|
||||
// Read `views.json` before any window is built: windows claim
|
||||
// their workspace from this store rather than each parsing the
|
||||
// file themselves.
|
||||
crate::core::session::WorkspaceStore::init(cx);
|
||||
// The window registry has to exist before the first window opens —
|
||||
// `ui::windows::open` registers into it.
|
||||
crate::ui::windows::WindowRegistry::init(cx);
|
||||
// Build the theme registry (built-ins + user theme files) before the
|
||||
// first window paints its theme.
|
||||
crate::ui::presets::load_registry(cx);
|
||||
// Honor `mouse_hide_while_typing` from the start.
|
||||
crate::ui::theme::apply_cursor_hide_mode(cx);
|
||||
// Start watching `config.json` so edits hot-reload theme/colors live.
|
||||
spawn_config_watcher(cx);
|
||||
// Ask GitHub (once, in the background) whether a newer release exists;
|
||||
// if so, Settings → About surfaces a download prompt. Fails soft and
|
||||
// is a no-op when `check_for_updates` is disabled.
|
||||
crate::core::update::spawn_check(cx);
|
||||
// If any agent's installed hooks point at a moved/stale tty7
|
||||
// binary (the app updated or relocated since they were
|
||||
// installed), rewrite them in place — off the startup path, since
|
||||
// it reads (and rarely writes) the agents' config files. No-op in
|
||||
// debug builds and when hooks are absent or already current.
|
||||
cx.background_executor()
|
||||
.spawn(async {
|
||||
crate::core::agent_hooks::refresh_hooks_at_launch();
|
||||
})
|
||||
.detach();
|
||||
keymap::init(cx);
|
||||
// Hold a control link to this machine's own daemon, exactly as a
|
||||
// remote machine gets one: the daemon owns the workspace tree and
|
||||
// serves it over control, so the local GUI is a control client
|
||||
// like any other. Supervised on its own forever loop — see
|
||||
// `ui::local_link`.
|
||||
crate::ui::local_link::LocalLink::install(cx);
|
||||
|
||||
// Come up on the *one* workspace the user was last in, at its own
|
||||
// remembered geometry (`ui::windows` owns that logic, since "New
|
||||
// Workspace" and the workspace picker need the identical path).
|
||||
//
|
||||
// Deliberately one window, not one per workspace that was open at
|
||||
// quit: see `WindowViews::workspace_to_restore` for why, and
|
||||
// `WorkspaceStore::restore_one` for what happens to the others (they
|
||||
// are detached, not forgotten — panes keep running and the switcher
|
||||
// lists them). Closing every window before quitting is *not* a
|
||||
// reason to come up empty: those workspaces still hold running
|
||||
// panes, so launch reattaches the one closed last.
|
||||
//
|
||||
// `None` is therefore a first run only, and it opens a single window
|
||||
// on a fresh workspace holding one terminal — exactly as every
|
||||
// pre-multi-window build did.
|
||||
let reopen = crate::core::session::WorkspaceStore::restore_one(cx);
|
||||
crate::ui::windows::open(cx, reopen);
|
||||
});
|
||||
@@ -448,7 +261,6 @@ mod tests {
|
||||
merge_paths("/opt/homebrew/bin:/usr/bin", "/usr/bin:/bin:"),
|
||||
"/opt/homebrew/bin:/usr/bin:/bin"
|
||||
);
|
||||
// A starved LS PATH gains the login entries up front.
|
||||
assert_eq!(
|
||||
merge_paths(
|
||||
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
|
||||
|
||||
+1
-224
@@ -1,51 +1,11 @@
|
||||
//! Native box-drawing: the U+2500–U+257F box characters and U+2580–U+259F
|
||||
//! block elements, drawn as geometry sized to the actual cell instead of as
|
||||
//! font glyphs.
|
||||
//!
|
||||
//! Why the font can't do this job: a glyph fills (at most) the font's own line
|
||||
//! height, but the cell it paints into is `font_size × Config::line_height` —
|
||||
//! 1.4 by default. At any line height above 1.0 a `│` covers only the middle of
|
||||
//! its cell, so every vertical run of box characters breaks into dashes with a
|
||||
//! gap at each row boundary: a two-line shell prompt's `╭`/`╰` no longer
|
||||
//! connect, a TUI frame is perforated down both sides. Horizontal continuity
|
||||
//! has the same problem in miniature whenever a fallback face's advance
|
||||
//! disagrees with the cell width.
|
||||
//!
|
||||
//! Drawing the range natively pins every stroke to the cell's real edges, so
|
||||
//! adjacent cells join seamlessly at any line height, any font, any fallback
|
||||
//! chain. This is the same special case every terminal with a line-height
|
||||
//! setting ships (kitty, alacritty, WezTerm, iTerm2), and the same approach the
|
||||
//! Powerline separators in `element.rs` already use — they skip fonts entirely.
|
||||
//!
|
||||
//! [`glyph`] returns the character's ink as rectangles and filled paths in cell
|
||||
//! coordinates; `paint_glyphs` fills them with the cell's foreground. A char
|
||||
//! outside the range returns `None` and falls back to the font.
|
||||
|
||||
use gpui::{Bounds, Pixels, point, px, size};
|
||||
|
||||
/// One paintable piece of a box-drawing glyph.
|
||||
pub(crate) enum Ink {
|
||||
/// A solid rectangle in the cell's foreground color.
|
||||
Rect(Bounds<Pixels>),
|
||||
/// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades,
|
||||
/// which fake their dither by translucency exactly as WezTerm does.
|
||||
Shade(Bounds<Pixels>, f32),
|
||||
/// A filled path — rounded corners and diagonals, the two shapes a
|
||||
/// rectangle can't express.
|
||||
Path(gpui::Path<Pixels>),
|
||||
}
|
||||
|
||||
/// The ink for `c` sized to `bounds`, or `None` for anything that isn't a
|
||||
/// box-drawing/block character (which then renders through the font).
|
||||
///
|
||||
/// `scale` is the window's device scale factor. Every straight stroke is
|
||||
/// snapped to the *device pixel* grid it implies — not for crispness alone,
|
||||
/// but for continuity: a cell boundary at a fractional device pixel gets an
|
||||
/// antialiasing ramp on both sides, and two abutting 50%-coverage edges
|
||||
/// composite to 75% opacity, which perforated every multi-row `│` with a
|
||||
/// lighter band at each row boundary. Snapped edges rasterize with no ramp at
|
||||
/// all, so adjacent cells butt into one continuous solid — the same reason
|
||||
/// kitty's cell-aligned box bitmaps tile seamlessly.
|
||||
pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>, scale: f32) -> Option<Vec<Ink>> {
|
||||
if !('\u{2500}'..='\u{259f}').contains(&c) {
|
||||
return None;
|
||||
@@ -61,7 +21,6 @@ pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>, scale: f32) -> Option<Vec<I
|
||||
.or_else(|| g.blocks(c))
|
||||
}
|
||||
|
||||
/// The weight of one arm (centre → edge) of a box character.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Arm {
|
||||
None,
|
||||
@@ -69,8 +28,6 @@ enum Arm {
|
||||
Heavy,
|
||||
}
|
||||
|
||||
/// Cell geometry in f32, plus the light stroke thickness `t` (see
|
||||
/// [`light_thickness`] for how that one is chosen).
|
||||
struct Cell {
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
@@ -82,24 +39,6 @@ struct Cell {
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
/// The light stroke thickness for a cell `cell_width` wide, in logical pixels.
|
||||
///
|
||||
/// Two rules, in order:
|
||||
///
|
||||
/// 1. Derive from the cell *width* — a pure font-size proxy — never the height:
|
||||
/// the height carries the line-height stretch, and a `─` that fattens when
|
||||
/// the user opens up their line spacing would look broken.
|
||||
/// 2. Then quantise so the result covers a whole number of device pixels.
|
||||
///
|
||||
/// Rule 2 keeps the nominal weight and the painted weight in agreement:
|
||||
/// [`Cell::vstroke`] lays a stroke off in whole device pixels, and everything
|
||||
/// positioned relative to `t` (the arm overshoot, the double-line separation,
|
||||
/// `heavy = 2 × light`) should be reasoning about the same value the rasteriser
|
||||
/// will actually produce.
|
||||
///
|
||||
/// Rounding the logical value *first* is what keeps 1x and 2x byte-identical to
|
||||
/// what this module shipped with — those are the scales it was tuned and
|
||||
/// visually verified at, so the fractional-scale fix must not disturb them.
|
||||
fn light_thickness(cell_width: f32, scale: f32) -> f32 {
|
||||
let logical = (cell_width * 0.15).round().max(1.);
|
||||
(logical * scale).round().max(1.) / scale
|
||||
@@ -124,16 +63,10 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a logical coordinate onto the device pixel grid.
|
||||
fn snap(&self, v: f32) -> f32 {
|
||||
(v * self.scale).round() / self.scale
|
||||
}
|
||||
|
||||
/// A rectangle with every edge snapped to device pixels (see [`glyph`]).
|
||||
/// Snapping the two edges — not origin + size — is what keeps a shared
|
||||
/// cell boundary shared: both cells snap the same coordinate to the same
|
||||
/// pixel line, so consecutive `│` cells tile with zero gap and zero
|
||||
/// overlap whatever the window position.
|
||||
fn rectb(&self, x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
|
||||
let (sx0, sy0) = (self.snap(x), self.snap(y));
|
||||
let (sx1, sy1) = (self.snap(x + w), self.snap(y + h));
|
||||
@@ -144,31 +77,10 @@ impl Cell {
|
||||
Ink::Rect(self.rectb(x, y, w, h))
|
||||
}
|
||||
|
||||
/// A logical thickness as a whole number of device pixels, back in logical
|
||||
/// units. Never zero: a stroke that rounds away is worse than one that is
|
||||
/// a touch too thick.
|
||||
fn stroke_px(&self, w: f32) -> f32 {
|
||||
(w * self.scale).round().max(1.) / self.scale
|
||||
}
|
||||
|
||||
/// A vertical stroke of logical width `w`, centred on `x`, spanning
|
||||
/// `ya..yb`.
|
||||
///
|
||||
/// The two *ends* snap like any other edge, so a stroke that runs to a cell
|
||||
/// boundary still shares that boundary exactly with the cell beyond it —
|
||||
/// the tiling property [`rectb`](Self::rectb) exists for.
|
||||
///
|
||||
/// The *width* is deliberately not a second pair of independent snaps. Two
|
||||
/// edges `w` apart land `w × scale` device pixels apart, and unless that is
|
||||
/// exactly a whole number the two `round`s straddle it — rounding apart in
|
||||
/// some cells and together in others, which made vertical rules alternate
|
||||
/// thin/thick across the columns of a TUI table at Windows' default 125% /
|
||||
/// 150% scaling. [`light_thickness`] picks `w` so the product is integral,
|
||||
/// but `f32` cannot always represent it exactly (a `1.5×` scale gives
|
||||
/// `2/1.5 × 1.5 = 2.0000001`), and a coordinate landing on a `.5` tie then
|
||||
/// rounds whichever way the error points. Laying the width off from the
|
||||
/// snapped near edge sidesteps the tie entirely: same weight everywhere,
|
||||
/// by construction rather than by luck.
|
||||
fn vstroke(&self, x: f32, w: f32, ya: f32, yb: f32) -> Ink {
|
||||
let (x0, y0, y1) = (self.snap(x - w / 2.), self.snap(ya), self.snap(yb));
|
||||
Ink::Rect(Bounds::new(
|
||||
@@ -177,8 +89,6 @@ impl Cell {
|
||||
))
|
||||
}
|
||||
|
||||
/// A horizontal stroke of logical width `w`, centred on `y`, spanning
|
||||
/// `xa..xb`. See [`vstroke`](Self::vstroke).
|
||||
fn hstroke(&self, y: f32, w: f32, xa: f32, xb: f32) -> Ink {
|
||||
let (y0, x0, x1) = (self.snap(y - w / 2.), self.snap(xa), self.snap(xb));
|
||||
Ink::Rect(Bounds::new(
|
||||
@@ -187,14 +97,6 @@ impl Cell {
|
||||
))
|
||||
}
|
||||
|
||||
/// The light/heavy arm combinations: one rectangle per arm, each running
|
||||
/// from its cell edge to just past the centre.
|
||||
///
|
||||
/// The overshoot (`m`, half the thickest arm) is what makes a corner: two
|
||||
/// perpendicular strokes that merely *meet* at the centre point leave a
|
||||
/// notch at the outside of the turn. Same-color opaque overlap costs
|
||||
/// nothing, so every arm overshoots by the same amount and any combination
|
||||
/// of weights joins solid.
|
||||
fn arms(&self, u: Arm, d: Arm, l: Arm, r: Arm) -> Vec<Ink> {
|
||||
let w = |a: Arm| match a {
|
||||
Arm::None => 0.,
|
||||
@@ -219,20 +121,9 @@ impl Cell {
|
||||
ink
|
||||
}
|
||||
|
||||
/// The double-line set (U+2550–U+256C), spelled out stroke by stroke.
|
||||
///
|
||||
/// Doubles can't reuse the [`arms`](Self::arms) overshoot trick: their
|
||||
/// junctions are *open* — ╬ is four corner pieces around a hole, ╠'s inner
|
||||
/// stroke breaks where the branch leaves — so each character lists exactly
|
||||
/// the segments the Unicode chart draws, with endpoints snapped half a
|
||||
/// stroke past the line they join so corners close without crossing the
|
||||
/// gap.
|
||||
fn doubles(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let t = self.t;
|
||||
let h = t / 2.;
|
||||
// The parallel strokes sit at centre ± d. At the 1px thickness of
|
||||
// ordinary font sizes this leaves a 3px gap — wide enough to survive
|
||||
// subpixel placement without the two strokes bleeding into one.
|
||||
let d = (t * 1.5).max(2.0);
|
||||
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
|
||||
let (va, vb) = (cx - d, cx + d);
|
||||
@@ -326,17 +217,6 @@ impl Cell {
|
||||
})
|
||||
}
|
||||
|
||||
/// The rounded corners ╭ ╮ ╯ ╰ — two straight stubs to the cell edges plus
|
||||
/// a quarter-circle band between them. `sx`/`sy` name the quadrant the arms
|
||||
/// leave through: ╭ runs down (+1) and right (+1).
|
||||
///
|
||||
/// The band is a fan of small convex quads, one per arc step, NOT a single
|
||||
/// outer-arc/inner-arc outline. That outline is concave, and gpui fills a
|
||||
/// path as a triangle fan from its first vertex — a concave contour gets
|
||||
/// its whole hollow covered, which rendered every corner as a solid
|
||||
/// quarter-disc blob the first time around. Each quad is convex, so each
|
||||
/// fills exactly itself, and at stroke widths of a few pixels twelve steps
|
||||
/// are indistinguishable from a true arc.
|
||||
fn rounded(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (sx, sy): (f32, f32) = match c {
|
||||
'╭' => (1., 1.),
|
||||
@@ -346,16 +226,9 @@ impl Cell {
|
||||
_ => return None,
|
||||
};
|
||||
let h = self.t / 2.;
|
||||
// The largest radius that keeps the arc inside the cell on its short
|
||||
// axis; the straight stubs cover whatever the long axis has left over.
|
||||
let r = ((self.x1 - self.x0).min(self.y1 - self.y0) / 2.).max(h * 2.);
|
||||
let (cx, cy) = (self.cx, self.cy);
|
||||
let mut ink = Vec::new();
|
||||
// Straight stubs from the arc's ends to the cell edges (zero-length
|
||||
// when the radius already spans the half-axis). Each stub reaches one
|
||||
// device pixel *into* the arc band: the stub is pixel-snapped, the arc
|
||||
// isn't, and without the overlap that mismatch reopens a hairline
|
||||
// seam exactly where they hand off.
|
||||
let lap = 1. / self.scale;
|
||||
if sy > 0. {
|
||||
ink.push(self.vstroke(cx, self.t, cy + r - lap, self.y1));
|
||||
@@ -367,31 +240,6 @@ impl Cell {
|
||||
} else {
|
||||
ink.push(self.hstroke(cy, self.t, self.x0, cx - r + lap));
|
||||
}
|
||||
// The arc band, from the vertical stub (θ=0) to the horizontal one
|
||||
// (θ=π/2) around the arc centre one radius into the quadrant.
|
||||
//
|
||||
// How this renders decides whether the corner looks like kitty's or
|
||||
// not, and gpui's pipeline dictates the shape (learned the hard way,
|
||||
// twice):
|
||||
//
|
||||
// * A path contour is filled as a triangle FAN from its start vertex,
|
||||
// and coverage in the intermediate texture only accumulates — there
|
||||
// is no winding cancellation. A whole-band outline is concave, so
|
||||
// its fan covered the hollow and every corner rendered as a solid
|
||||
// quarter-disc blob. Each contour must therefore be *star-shaped
|
||||
// from its start vertex*: 30° slices of a thin band are, a 90° band
|
||||
// is not.
|
||||
// * All contours ride in ONE Path. Paths composite as premultiplied
|
||||
// sprites, so two separately painted segments overlap their
|
||||
// antialiased edges at 75% opacity — the seam at every joint of the
|
||||
// first polyline attempt. Within a single path the 4x-MSAA samples
|
||||
// partition cleanly across shared edges instead.
|
||||
// * The outer edge is a real quadratic (`curve_to`), which the shader
|
||||
// antialiases *analytically* (Loop–Blinn signed distance) — the
|
||||
// smooth continuous ramp kitty gets from supersampling. The inner
|
||||
// edge can't be a curve: with no winding, a concave-side bulge can
|
||||
// only over-cover. It is a fine polyline instead, whose chord error
|
||||
// at 7.5° steps (< 0.1px at cell sizes) hides inside the MSAA.
|
||||
let (ax, ay) = (cx + sx * r, cy + sy * r);
|
||||
let at = |radius: f32, theta: f32| {
|
||||
let (x, y) = (
|
||||
@@ -415,8 +263,6 @@ impl Cell {
|
||||
}
|
||||
None => path.insert(gpui::Path::new(start)),
|
||||
};
|
||||
// Control point at the tangents' intersection: the exact
|
||||
// quadratic through both endpoints for this arc slice.
|
||||
let ctrl = at((r + h) / (step / 2.).cos(), (t0 + t1) / 2.);
|
||||
p.curve_to(at(r + h, t1), ctrl);
|
||||
p.line_to(at(r - h, t1));
|
||||
@@ -430,9 +276,6 @@ impl Cell {
|
||||
Some(ink)
|
||||
}
|
||||
|
||||
/// The dashed lines: n dashes, each 70% of its slot, centred. Deliberately
|
||||
/// *not* edge-to-edge — a dashed line is supposed to read as broken, and
|
||||
/// this matches how the font glyphs space them.
|
||||
fn dashed(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (n, heavy, vertical) = match c {
|
||||
'╌' => (2, false, false),
|
||||
@@ -470,10 +313,6 @@ impl Cell {
|
||||
Some(ink)
|
||||
}
|
||||
|
||||
/// The diagonals ╱ ╲ ╳ as corner-to-corner parallelograms. The offset is
|
||||
/// vertical (not perpendicular) so every vertex stays inside the cell; its
|
||||
/// length is scaled so the *perpendicular* stroke width still comes out at
|
||||
/// the light thickness.
|
||||
fn diagonal(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (w, hgt) = (self.x1 - self.x0, self.y1 - self.y0);
|
||||
let v = self.t * (w * w + hgt * hgt).sqrt() / w;
|
||||
@@ -493,9 +332,6 @@ impl Cell {
|
||||
})
|
||||
}
|
||||
|
||||
/// The block elements U+2580–U+259F: eighths, halves, quadrants, and the
|
||||
/// ░▒▓ shades (a full-cell wash at a quarter / half / three quarters of the
|
||||
/// foreground's alpha).
|
||||
fn blocks(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
|
||||
let (w, hgt) = (x1 - x0, y1 - y0);
|
||||
@@ -506,13 +342,11 @@ impl Cell {
|
||||
let lr = || r(cx, cy, x1 - cx, y1 - cy);
|
||||
Some(match c {
|
||||
'▀' => vec![r(x0, y0, w, hgt / 2.)],
|
||||
// ▁ (1/8) through █ (the full block): lower k eighths.
|
||||
'▁'..='█' => {
|
||||
let k = (c as u32 - 0x2580) as f32;
|
||||
let hh = hgt * k / 8.;
|
||||
vec![r(x0, y1 - hh, w, hh)]
|
||||
}
|
||||
// ▉ (7/8) through ▏ (1/8): left k eighths.
|
||||
'▉'..='▏' => {
|
||||
let k = (0x2590 - c as u32) as f32;
|
||||
vec![r(x0, y0, w * k / 8., hgt)]
|
||||
@@ -538,9 +372,6 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the light/heavy arm combinations: the solid lines, corners, tees and
|
||||
/// crosses of U+2500–U+254B, and the half/mixed lines of U+2574–U+257F. Order
|
||||
/// is (up, down, left, right).
|
||||
fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> {
|
||||
use Arm::{Heavy as H, Light as L, None as N};
|
||||
Some(match c {
|
||||
@@ -632,13 +463,10 @@ fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A cell with the proportions the bug shipped in: a 15px font's ~9px
|
||||
/// advance stretched to a 21px line by `line_height: 1.4`.
|
||||
fn cell() -> Bounds<Pixels> {
|
||||
Bounds::new(point(px(10.), px(20.)), size(px(9.), px(21.)))
|
||||
}
|
||||
|
||||
/// min_x / max_x / min_y / max_y over every rect corner and path vertex.
|
||||
fn extents(ink: &[Ink]) -> (f32, f32, f32, f32) {
|
||||
let (mut nx, mut xx, mut ny, mut xy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
|
||||
let mut visit = |x: f32, y: f32| {
|
||||
@@ -664,10 +492,6 @@ mod tests {
|
||||
(nx, xx, ny, xy)
|
||||
}
|
||||
|
||||
/// Every character in U+2500–U+259F must decode to native ink — one that
|
||||
/// silently falls through to the font reintroduces the row-boundary gap
|
||||
/// for exactly that character, which is worse than uniform behavior in
|
||||
/// either direction.
|
||||
#[test]
|
||||
fn the_whole_range_is_covered() {
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
@@ -679,13 +503,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing may paint outside its own cell: box characters tile, and one
|
||||
/// cell's overshoot is its neighbor's artifact.
|
||||
///
|
||||
/// The tolerance is half a pixel, not exact: a quadratic's *control point*
|
||||
/// sits slightly outside the ink it bounds (tangent-intersection, ~3.5%
|
||||
/// past the arc radius), and `extents` reads raw vertices. The curve
|
||||
/// itself never leaves the cell.
|
||||
#[test]
|
||||
fn ink_stays_inside_the_cell() {
|
||||
let b = cell();
|
||||
@@ -702,10 +519,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression this module exists for: every arm must reach its cell
|
||||
/// edge *exactly*, so vertical runs connect across the line-height gap and
|
||||
/// horizontal runs connect across cells. Checked for the whole arms table
|
||||
/// — including the mixed and half lines — not just `│`.
|
||||
#[test]
|
||||
fn arms_reach_their_edges() {
|
||||
let b = cell();
|
||||
@@ -732,14 +545,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same edge guarantee for the shapes that aren't plain arms: the doubles,
|
||||
/// the rounded corners, and the diagonals all tile too.
|
||||
#[test]
|
||||
fn doubles_rounded_and_diagonals_reach_their_edges() {
|
||||
let b = cell();
|
||||
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
|
||||
// (char, up, down, left, right)
|
||||
let expect = [
|
||||
('═', false, false, true, true),
|
||||
('║', true, true, false, false),
|
||||
@@ -771,8 +581,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ╬ is four corner pieces around an open centre — the one double junction
|
||||
/// where "just extend everything through the middle" would visibly lie.
|
||||
#[test]
|
||||
fn double_cross_keeps_its_open_centre() {
|
||||
let b = cell();
|
||||
@@ -791,8 +599,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks: the full block is the full cell, the halves are exact halves,
|
||||
/// and the shades wash the whole cell at their nominal alpha.
|
||||
#[test]
|
||||
fn blocks_cover_their_nominal_area() {
|
||||
let b = cell();
|
||||
@@ -804,8 +610,6 @@ mod tests {
|
||||
(x0, x0 + w, y0, y0 + h),
|
||||
"█ isn't the full cell"
|
||||
);
|
||||
// Interior edges (the half-cell split) may sit up to half a device
|
||||
// pixel from nominal after snapping; the outer edges stay exact.
|
||||
let (_, _, ny, xy) = extents(&glyph('▀', b, 1.).unwrap());
|
||||
assert_eq!(ny, y0, "▀ doesn't reach the top");
|
||||
assert!((xy - (y0 + h / 2.)).abs() <= 0.5, "▀ isn't the top half");
|
||||
@@ -823,8 +627,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Heavy strokes must actually be heavier than light ones, and a light
|
||||
/// stroke never vanishes (≥ 1px) however small the cell.
|
||||
#[test]
|
||||
fn stroke_weights_are_ordered_and_visible() {
|
||||
let light = {
|
||||
@@ -841,7 +643,6 @@ mod tests {
|
||||
};
|
||||
assert!(light >= 1., "light stroke thinner than a pixel");
|
||||
assert!(heavy > light, "heavy stroke isn't heavier");
|
||||
// A pathologically narrow cell still yields visible ink.
|
||||
let tiny = Bounds::new(point(px(0.), px(0.)), size(px(2.), px(4.)));
|
||||
let Ink::Rect(r) = &glyph('│', tiny, 1.).unwrap()[0] else {
|
||||
panic!()
|
||||
@@ -849,22 +650,16 @@ mod tests {
|
||||
assert!(r.size.width.as_f32() >= 1.);
|
||||
}
|
||||
|
||||
/// The seam regression: with the window at a fractional device-pixel
|
||||
/// offset, every straight stroke must still land on whole device pixels.
|
||||
/// An unsnapped edge rasterizes an antialiasing ramp, and two abutting
|
||||
/// ramps composite to 75% opacity — the perforated `│` runs this module
|
||||
/// was reported for a second time over.
|
||||
#[test]
|
||||
fn straight_strokes_snap_to_device_pixels() {
|
||||
let scale = 2.0;
|
||||
// Deliberately misaligned: fractional origin and cell width.
|
||||
let b = Bounds::new(point(px(10.37), px(20.11)), size(px(9.03), px(21.)));
|
||||
let on_grid = |v: f32| ((v * scale).round() - v * scale).abs() < 1e-3;
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
for i in glyph(c, b, scale).unwrap() {
|
||||
let (Ink::Rect(r) | Ink::Shade(r, _)) = i else {
|
||||
continue; // arcs and diagonals antialias on purpose
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32());
|
||||
let (x2, y2) = (x + r.size.width.as_f32(), y + r.size.height.as_f32());
|
||||
@@ -875,28 +670,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// And two vertically adjacent `│` cells must share their boundary
|
||||
// exactly — same coordinate in, same snapped pixel line out.
|
||||
let below = Bounds::new(point(px(10.37), px(41.11)), size(px(9.03), px(21.)));
|
||||
let bottom = extents(&glyph('│', b, scale).unwrap()).3;
|
||||
let top = extents(&glyph('│', below, scale).unwrap()).2;
|
||||
assert_eq!(bottom, top, "adjacent │ cells no longer tile");
|
||||
}
|
||||
|
||||
/// Every column must draw `│` at the *same* weight, and every row must draw
|
||||
/// `─` at the same weight, at any scale factor — not just the integer ones.
|
||||
///
|
||||
/// Note what the test above does *not* catch: it asserts each edge lands on
|
||||
/// the device grid, which a 1-device-pixel stroke and a 2-device-pixel
|
||||
/// stroke both satisfy. Windows' default 125%/150% display scaling put a
|
||||
/// 1-logical-pixel stroke a non-integer number of device pixels wide, and
|
||||
/// the two independent edge snaps then rounded apart in some columns and
|
||||
/// together in others: vertical rules alternated thin/thick across a TUI
|
||||
/// table, horizontal rules alternated down it. Both 1x and 2x are blind to
|
||||
/// it by construction, so the earlier fixtures could never have failed.
|
||||
#[test]
|
||||
fn stroke_weight_is_uniform_across_cells_at_any_scale() {
|
||||
// Realistic cell metrics: a 13/15/16px font's advance, line_height 1.4.
|
||||
for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] {
|
||||
for scale in [1.0f32, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] {
|
||||
let widths: Vec<f32> = (0..24)
|
||||
@@ -941,9 +722,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantising the thickness in device space must not change what 1x and 2x
|
||||
/// already rendered — those are the two scales the module was tuned and
|
||||
/// visually verified at.
|
||||
#[test]
|
||||
fn integer_scales_keep_their_previous_thickness() {
|
||||
for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] {
|
||||
@@ -954,7 +732,6 @@ mod tests {
|
||||
previous,
|
||||
"cell_width {cw} at scale {scale} changed weight"
|
||||
);
|
||||
// And heavy stays exactly twice light, as `arms` assumes.
|
||||
let b = Bounds::new(point(px(0.), px(0.)), size(px(cw), px(lh)));
|
||||
let Ink::Rect(l) = &glyph('│', b, scale).unwrap()[0] else {
|
||||
panic!()
|
||||
|
||||
+30
-222
@@ -1,37 +1,13 @@
|
||||
//! A small, self-contained command-line editor buffer for the prompt.
|
||||
//!
|
||||
//! Why not reuse `gpui_component::InputState`? Because it claims `tab`, `up`,
|
||||
//! `down`, and other keys in its `"Input"` key context, and gpui dispatches
|
||||
//! keybinding actions *before* `on_key_down` listeners — so an ancestor can't
|
||||
//! intercept those keys to drive Tab completion / history recall. To own every
|
||||
//! key at the prompt (the prerequisite for completion, history, syntax
|
||||
//! highlighting and ghost suggestions) we keep keyboard focus on the terminal and
|
||||
//! run our own line editor here.
|
||||
//!
|
||||
//! The buffer is a `Vec<char>` with a char-index cursor, so cursor arithmetic and
|
||||
//! word motion never split a multi-byte UTF-8 scalar. It is deliberately
|
||||
//! editing-only (no rendering, no key mapping); the view owns those.
|
||||
|
||||
/// An editable single line plus a cursor position (a char index in `0..=len`),
|
||||
/// and an optional selection anchor (the selection spans `anchor..cursor`).
|
||||
#[derive(Default)]
|
||||
pub struct CmdEditor {
|
||||
chars: Vec<char>,
|
||||
cursor: usize,
|
||||
anchor: Option<usize>,
|
||||
/// Undo / redo stacks of `(chars, cursor)` snapshots. Each mutating edit
|
||||
/// records the pre-edit state (deduplicated by content) onto `undo`; undo/redo
|
||||
/// shuttle states between the two.
|
||||
undo: Vec<(Vec<char>, usize)>,
|
||||
redo: Vec<(Vec<char>, usize)>,
|
||||
/// What the last *kill* removed, for [`Self::yank`] to put back — readline's
|
||||
/// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and
|
||||
/// the arrow-key spellings of them) write here; a plain character delete is
|
||||
/// not a kill and leaves it untouched.
|
||||
kill: String,
|
||||
}
|
||||
|
||||
/// Cap on undo history, so a long editing session can't grow it without bound.
|
||||
const UNDO_LIMIT: usize = 200;
|
||||
|
||||
impl CmdEditor {
|
||||
@@ -39,7 +15,6 @@ impl CmdEditor {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The current line as a `String`.
|
||||
pub fn text(&self) -> String {
|
||||
self.chars.iter().collect()
|
||||
}
|
||||
@@ -48,30 +23,20 @@ impl CmdEditor {
|
||||
self.chars.is_empty()
|
||||
}
|
||||
|
||||
/// Number of chars in the line (cursor is in `0..=len`).
|
||||
pub fn len(&self) -> usize {
|
||||
self.chars.len()
|
||||
}
|
||||
|
||||
/// Cursor position as a char index (`0..=len`). Used by tests and the
|
||||
/// upcoming completion increment.
|
||||
#[allow(dead_code)]
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Cursor position as a byte offset into `text()`, for callers that need to
|
||||
/// slice the rendered string (e.g. to split it at the caret).
|
||||
#[allow(dead_code)]
|
||||
pub fn cursor_byte(&self) -> usize {
|
||||
self.chars[..self.cursor].iter().map(|c| c.len_utf8()).sum()
|
||||
}
|
||||
|
||||
// ---- Undo / redo ----
|
||||
|
||||
/// Record the current state onto the undo stack (deduplicated by content) and
|
||||
/// clear redo. Called at the start of every mutating edit; nested calls within
|
||||
/// one edit collapse to a single entry via the content check.
|
||||
fn checkpoint(&mut self) {
|
||||
if self.undo.last().map(|(c, _)| c.as_slice()) != Some(self.chars.as_slice()) {
|
||||
self.undo.push((self.chars.clone(), self.cursor));
|
||||
@@ -83,15 +48,6 @@ impl CmdEditor {
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) {
|
||||
// Skip "phantom" checkpoints whose text already equals the current buffer.
|
||||
// A no-op edit (Backspace at column 0, Ctrl-K at line end, Ctrl-W at
|
||||
// column 0, …) still calls `checkpoint()`, recording the pre-edit state —
|
||||
// which for a no-op is identical to the current one. Undoing that entry
|
||||
// would be a dead keypress that "restores" the same text instead of the
|
||||
// real edit before it. Drop past any such entries to the first checkpoint
|
||||
// that actually changes the text. Comparing text alone is enough: plain
|
||||
// caret motion never checkpoints, so a top entry matching the current text
|
||||
// can only be a no-op's phantom, never a cursor-only undo target.
|
||||
while self
|
||||
.undo
|
||||
.last()
|
||||
@@ -116,8 +72,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a string at the cursor, advancing past it. Replaces the selection
|
||||
/// first if there is one. Used for typed text and IME-committed text alike.
|
||||
pub fn insert_str(&mut self, s: &str) {
|
||||
self.checkpoint();
|
||||
self.delete_selection();
|
||||
@@ -127,10 +81,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a string at the start of the line, leaving the caret (and any
|
||||
/// selection) on the characters they were on — their indices shift by the
|
||||
/// inserted length. Used to adopt gap typeahead, which was typed
|
||||
/// chronologically before the editor's current content.
|
||||
pub fn prepend_str(&mut self, s: &str) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
@@ -144,7 +94,6 @@ impl CmdEditor {
|
||||
self.anchor = self.anchor.map(|a| a + n);
|
||||
}
|
||||
|
||||
/// Delete the char before the cursor (Backspace), or the selection if any.
|
||||
pub fn backspace(&mut self) {
|
||||
self.checkpoint();
|
||||
if self.delete_selection() {
|
||||
@@ -156,7 +105,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the char at the cursor (Delete), or the selection if any.
|
||||
pub fn delete(&mut self) {
|
||||
self.checkpoint();
|
||||
if self.delete_selection() {
|
||||
@@ -167,18 +115,7 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selection ----
|
||||
|
||||
/// The selected range as normalized `(start, end)` char indices, or `None`
|
||||
/// when there's no (non-empty) selection.
|
||||
pub fn selection(&self) -> Option<(usize, usize)> {
|
||||
// Clamp both endpoints to the current length. A delete that shrinks the
|
||||
// buffer without touching the anchor (delete_word_left/right,
|
||||
// delete_to_start/end never clear it) can leave the anchor past the new
|
||||
// end; slicing `chars[a..cursor]` on that stale anchor then panics
|
||||
// (reachable from real input: shift-select, Alt+Delete, then Cmd+C / Cmd+X,
|
||||
// which read `selected_text()`). Clamping is a no-op for every valid state
|
||||
// and collapses a deleted-region selection to `None`.
|
||||
let n = self.chars.len();
|
||||
let a = self.anchor?.min(n);
|
||||
let c = self.cursor.min(n);
|
||||
@@ -189,7 +126,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected text, if any.
|
||||
pub fn selected_text(&self) -> Option<String> {
|
||||
let (s, e) = self.selection()?;
|
||||
Some(self.chars[s..e].iter().collect())
|
||||
@@ -199,15 +135,12 @@ impl CmdEditor {
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
/// Start a selection at the current cursor if none is active (used before an
|
||||
/// extending, shift-modified motion).
|
||||
pub fn begin_selection(&mut self) {
|
||||
if self.anchor.is_none() {
|
||||
self.anchor = Some(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the selection if there is one; returns whether anything was deleted.
|
||||
pub fn delete_selection(&mut self) -> bool {
|
||||
if let Some((s, e)) = self.selection() {
|
||||
self.checkpoint();
|
||||
@@ -221,37 +154,19 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the whole line.
|
||||
pub fn select_all(&mut self) {
|
||||
self.anchor = Some(0);
|
||||
self.cursor = self.chars.len();
|
||||
}
|
||||
|
||||
/// Bounds `(start, end)` of the word containing char index `idx`: the run
|
||||
/// of chars that are neither whitespace nor in `separators` (the
|
||||
/// configured word-separator set, shared with the grid's semantic
|
||||
/// selection). A separator char is its own one-char word, matching the
|
||||
/// grid; on whitespace the run collapses and the leftward walk snaps to
|
||||
/// the previous word's start.
|
||||
///
|
||||
/// `smart` mirrors `Config::smart_select`: with it off this is exactly
|
||||
/// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt
|
||||
/// editor and the grid alike.
|
||||
pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if !smart {
|
||||
return self.plain_word_bounds(idx, separators, smart);
|
||||
}
|
||||
// A bracket or quote selects through its match, same as the grid.
|
||||
// Checked before CJK segmentation so full-width `()`/`“”` pair
|
||||
// instead of being segmented as lone punctuation tokens. Only for
|
||||
// the double-click itself — drags use `plain_word_bounds` so the
|
||||
// selection doesn't lurch when the pointer crosses a quote.
|
||||
if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) {
|
||||
return (s, e + 1);
|
||||
}
|
||||
// CJK prose has no separators between words: segment it with the
|
||||
// platform dictionary instead of selecting the whole unbroken run.
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
&& super::smart_select::is_cjk(c)
|
||||
{
|
||||
@@ -263,11 +178,6 @@ impl CmdEditor {
|
||||
self.plain_word_bounds(idx, separators, smart)
|
||||
}
|
||||
|
||||
/// [`Self::word_bounds`] without the pair/segmentation smarts: the plain
|
||||
/// separator-walk word. Used for word-granular drags, where pair matching
|
||||
/// would make the selection jump around as the pointer crosses a quote.
|
||||
/// `smart` still governs the mixed-script narrowing, so a drag matches
|
||||
/// what the double-click that started it selected.
|
||||
fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
@@ -285,8 +195,6 @@ impl CmdEditor {
|
||||
while e < self.chars.len() && !boundary(self.chars[e]) {
|
||||
e += 1;
|
||||
}
|
||||
// Mixed-script runs (a Latin word glued to CJK text) shrink to the
|
||||
// clicked char's script class — same correction as the grid's.
|
||||
if smart && idx < e {
|
||||
let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1);
|
||||
return (ns, ne + 1);
|
||||
@@ -294,18 +202,12 @@ impl CmdEditor {
|
||||
(s, e)
|
||||
}
|
||||
|
||||
/// Select the word containing char index `idx` (see [`Self::word_bounds`]).
|
||||
pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) {
|
||||
let (s, e) = self.word_bounds(idx, separators, smart);
|
||||
self.anchor = Some(s);
|
||||
self.cursor = e;
|
||||
}
|
||||
|
||||
/// Extend a word-granular drag (double-click then drag) to char index `idx`,
|
||||
/// keeping the whole anchor word `anchor_start..anchor_end` selected. The
|
||||
/// selection grows by whole words: dragging past the anchor word selects
|
||||
/// forward to the far edge of the word under `idx`, dragging before it selects
|
||||
/// backward to that word's near edge. The cursor sits at the moving edge.
|
||||
pub fn extend_word_to(
|
||||
&mut self,
|
||||
anchor_start: usize,
|
||||
@@ -324,8 +226,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to char index `idx` (clamped), extending the selection from
|
||||
/// the existing anchor (starting one at the old cursor if needed). For drags.
|
||||
pub fn extend_to(&mut self, idx: usize) {
|
||||
self.begin_selection();
|
||||
self.cursor = idx.min(self.chars.len());
|
||||
@@ -341,9 +241,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Char index of the start of the logical line containing `idx` — just after
|
||||
/// the preceding `'\n'`, or `0`. A multi-line buffer (from a pasted command)
|
||||
/// keeps its `'\n'`s inline; Home / Ctrl-A act within the current line.
|
||||
pub fn line_start(&self, idx: usize) -> usize {
|
||||
let mut s = idx.min(self.chars.len());
|
||||
while s > 0 && self.chars[s - 1] != '\n' {
|
||||
@@ -352,8 +249,6 @@ impl CmdEditor {
|
||||
s
|
||||
}
|
||||
|
||||
/// Char index of the end of the logical line containing `idx` — the next
|
||||
/// `'\n'`, or the buffer end.
|
||||
pub fn line_end(&self, idx: usize) -> usize {
|
||||
let mut e = idx.min(self.chars.len());
|
||||
while e < self.chars.len() && self.chars[e] != '\n' {
|
||||
@@ -362,26 +257,18 @@ impl CmdEditor {
|
||||
e
|
||||
}
|
||||
|
||||
/// Move to the start of the current logical line (Home / Ctrl-A). On a
|
||||
/// single-line buffer this is column 0, unchanged.
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = self.line_start(self.cursor);
|
||||
}
|
||||
|
||||
/// Place the cursor at char index `idx` (clamped to the line length). Used to
|
||||
/// reposition the caret from a mouse click.
|
||||
pub fn set_cursor(&mut self, idx: usize) {
|
||||
self.cursor = idx.min(self.chars.len());
|
||||
}
|
||||
|
||||
/// Move to the end of the current logical line (End / Ctrl-E). On a
|
||||
/// single-line buffer this is the buffer end, unchanged.
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.line_end(self.cursor);
|
||||
}
|
||||
|
||||
/// Move left to the start of the previous word (skip trailing whitespace, then
|
||||
/// the word). Word = run of non-whitespace.
|
||||
pub fn move_word_left(&mut self) {
|
||||
while self.cursor > 0 && self.chars[self.cursor - 1].is_whitespace() {
|
||||
self.cursor -= 1;
|
||||
@@ -391,7 +278,6 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move right to the end of the next word.
|
||||
pub fn move_word_right(&mut self) {
|
||||
let n = self.chars.len();
|
||||
while self.cursor < n && self.chars[self.cursor].is_whitespace() {
|
||||
@@ -402,29 +288,18 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift the selection anchor to account for the removal of chars `[s, e)`,
|
||||
/// exactly as any editor adjusts marks across an edit: an anchor past the
|
||||
/// hole moves left by its width, one inside collapses to its start. Without
|
||||
/// this, a range delete that doesn't reach the buffer end leaves the anchor
|
||||
/// pointing at *shifted* text — `selection()`'s clamp then reports a
|
||||
/// phantom selection over chars the user never selected (and ⌘C copies it).
|
||||
fn shift_anchor_for_removal(&mut self, s: usize, e: usize) {
|
||||
if let Some(a) = self.anchor {
|
||||
self.anchor = Some(if a <= s { a } else { a.max(e) - (e - s) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `s..e` and stash it as the kill ring's contents. The four chords
|
||||
/// below are readline *kills*, not deletes: what they take is meant to come
|
||||
/// back out under [`Self::yank`].
|
||||
fn kill_range(&mut self, s: usize, e: usize) {
|
||||
self.kill = self.chars[s..e].iter().collect();
|
||||
self.chars.drain(s..e);
|
||||
self.shift_anchor_for_removal(s, e);
|
||||
}
|
||||
|
||||
/// Delete the word after the cursor (Alt+Delete): skip following whitespace,
|
||||
/// then the word.
|
||||
pub fn delete_word_right(&mut self) {
|
||||
self.checkpoint();
|
||||
let n = self.chars.len();
|
||||
@@ -438,7 +313,6 @@ impl CmdEditor {
|
||||
self.kill_range(self.cursor, e);
|
||||
}
|
||||
|
||||
/// Delete the word before the cursor (Ctrl+W / Alt+Backspace).
|
||||
pub fn delete_word_left(&mut self) {
|
||||
self.checkpoint();
|
||||
let end = self.cursor;
|
||||
@@ -446,7 +320,6 @@ impl CmdEditor {
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace).
|
||||
pub fn delete_to_start(&mut self) {
|
||||
self.checkpoint();
|
||||
let end = self.cursor;
|
||||
@@ -454,15 +327,12 @@ impl CmdEditor {
|
||||
self.kill_range(0, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the end of the line (Ctrl+K).
|
||||
pub fn delete_to_end(&mut self) {
|
||||
self.checkpoint();
|
||||
let end = self.chars.len();
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo
|
||||
/// checkpoint included — when nothing has been killed yet.
|
||||
pub fn yank(&mut self) {
|
||||
if self.kill.is_empty() {
|
||||
return;
|
||||
@@ -472,7 +342,6 @@ impl CmdEditor {
|
||||
self.kill = kill;
|
||||
}
|
||||
|
||||
/// Clear the line and reset the cursor and undo history (after submit).
|
||||
pub fn clear(&mut self) {
|
||||
self.chars.clear();
|
||||
self.cursor = 0;
|
||||
@@ -481,8 +350,6 @@ impl CmdEditor {
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
/// Replace the whole line, putting the cursor at the end. Used by history
|
||||
/// recall and completion acceptance.
|
||||
pub fn set(&mut self, text: &str) {
|
||||
self.checkpoint();
|
||||
self.chars = text.chars().collect();
|
||||
@@ -490,9 +357,6 @@ impl CmdEditor {
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
/// Replace the whole line with `text` and place the cursor at char index
|
||||
/// `cursor` (clamped). Used to apply a completion built against a saved
|
||||
/// original line, and to restore that original on cancel.
|
||||
pub fn set_with_cursor(&mut self, text: &str, cursor: usize) {
|
||||
self.checkpoint();
|
||||
self.chars = text.chars().collect();
|
||||
@@ -514,21 +378,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prepend_str_keeps_caret_and_selection_on_their_chars() {
|
||||
// Adopting gap typeahead: the seed was typed chronologically *before*
|
||||
// whatever is already in the editor, so it lands at the start while
|
||||
// the caret (and any selection) stays on the characters it was on.
|
||||
let mut e = ed("etty", 2); // caret between "et" and "ty"
|
||||
let mut e = ed("etty", 2);
|
||||
e.prepend_str("cd g");
|
||||
assert_eq!(e.text(), "cd getty");
|
||||
assert_eq!(e.cursor(), 6, "caret still between 'et' and 'ty'");
|
||||
// One undo removes the adopted seed again.
|
||||
e.undo();
|
||||
assert_eq!(e.text(), "etty");
|
||||
|
||||
let mut e = ed("tty", 3);
|
||||
e.set_cursor(1);
|
||||
e.begin_selection();
|
||||
e.extend_to(3); // selects "ty"
|
||||
e.extend_to(3);
|
||||
e.prepend_str("ge");
|
||||
assert_eq!(e.text(), "getty");
|
||||
assert_eq!(e.selection(), Some((3, 5)), "selection still covers 'ty'");
|
||||
@@ -559,7 +419,6 @@ mod tests {
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("ac", 1));
|
||||
e.delete();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("a", 1));
|
||||
// Backspace at start is a no-op.
|
||||
let mut s = ed("x", 0);
|
||||
s.backspace();
|
||||
assert_eq!(s.text(), "x");
|
||||
@@ -571,11 +430,11 @@ mod tests {
|
||||
e.move_left();
|
||||
assert_eq!(e.cursor(), 0);
|
||||
e.move_left();
|
||||
assert_eq!(e.cursor(), 0); // clamped
|
||||
assert_eq!(e.cursor(), 0);
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 2);
|
||||
e.move_right();
|
||||
assert_eq!(e.cursor(), 2); // clamped
|
||||
assert_eq!(e.cursor(), 2);
|
||||
e.move_home();
|
||||
assert_eq!(e.cursor(), 0);
|
||||
}
|
||||
@@ -584,17 +443,15 @@ mod tests {
|
||||
fn word_motion_and_delete() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
e.move_word_left();
|
||||
assert_eq!(e.cursor(), 9); // start of "origin"
|
||||
assert_eq!(e.cursor(), 9);
|
||||
e.move_word_left();
|
||||
assert_eq!(e.cursor(), 4); // start of "push"
|
||||
assert_eq!(e.cursor(), 4);
|
||||
let mut d = ed("git push origin", 15);
|
||||
d.delete_word_left();
|
||||
assert_eq!(d.text(), "git push ");
|
||||
assert_eq!(d.cursor(), 9);
|
||||
}
|
||||
|
||||
/// The four readline *kill* chords stash what they removed so ⌃Y can put it
|
||||
/// back; the ring holds the most recent kill only.
|
||||
#[test]
|
||||
fn kills_fill_the_kill_buffer_and_yank_puts_it_back() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
@@ -623,8 +480,6 @@ mod tests {
|
||||
assert_eq!(d.text(), "git push origin");
|
||||
}
|
||||
|
||||
/// A plain character delete is not a kill — readline keeps the two apart,
|
||||
/// so backspacing must not clobber the word ⌃W stashed a moment ago.
|
||||
#[test]
|
||||
fn character_deletes_leave_the_kill_buffer_alone() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
@@ -636,8 +491,6 @@ mod tests {
|
||||
assert_eq!(e.text(), "git pushorigin");
|
||||
}
|
||||
|
||||
/// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they
|
||||
/// were rather than inserting an empty string.
|
||||
#[test]
|
||||
fn yank_without_a_kill_does_nothing() {
|
||||
let mut e = ed("hello", 3);
|
||||
@@ -658,11 +511,11 @@ mod tests {
|
||||
#[test]
|
||||
fn multibyte_byte_offset() {
|
||||
let mut e = CmdEditor::new();
|
||||
e.insert_str("你好"); // 2 chars, 6 bytes
|
||||
e.insert_str("你好");
|
||||
assert_eq!(e.cursor(), 2);
|
||||
assert_eq!(e.cursor_byte(), 6);
|
||||
e.move_left();
|
||||
assert_eq!(e.cursor_byte(), 3); // after first char (3 bytes)
|
||||
assert_eq!(e.cursor_byte(), 3);
|
||||
e.backspace();
|
||||
assert_eq!(e.text(), "好");
|
||||
}
|
||||
@@ -680,14 +533,14 @@ mod tests {
|
||||
e.set_cursor(2);
|
||||
assert_eq!(e.cursor(), 2);
|
||||
e.set_cursor(99);
|
||||
assert_eq!(e.cursor(), 5); // clamped to len
|
||||
assert_eq!(e.cursor(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_basics_and_delete() {
|
||||
let mut e = ed("hello world", 0);
|
||||
e.begin_selection();
|
||||
e.set_cursor(5); // select "hello"
|
||||
e.set_cursor(5);
|
||||
assert_eq!(e.selection(), Some((0, 5)));
|
||||
assert_eq!(e.selected_text().as_deref(), Some("hello"));
|
||||
assert!(e.delete_selection());
|
||||
@@ -699,19 +552,18 @@ mod tests {
|
||||
fn typing_replaces_selection() {
|
||||
let mut e = ed("abc def", 0);
|
||||
e.begin_selection();
|
||||
e.set_cursor(3); // select "abc"
|
||||
e.set_cursor(3);
|
||||
e.insert_str("XY");
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("XY def", 2));
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
/// The default word-separator set (mirrors `Config::word_separators`).
|
||||
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
|
||||
|
||||
#[test]
|
||||
fn select_word_and_all() {
|
||||
let mut e = ed("git push origin", 6);
|
||||
e.select_word_at(6, SEPS, true); // cursor on "push"
|
||||
e.select_word_at(6, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push"));
|
||||
e.select_all();
|
||||
assert_eq!(e.selection(), Some((0, 15)));
|
||||
@@ -719,15 +571,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn select_word_stops_at_separators() {
|
||||
// Quotes and commas bound a word; a separator char is its own word.
|
||||
let mut e = ed("echo 'a,b'", 0);
|
||||
e.select_word_at(6, SEPS, true); // on "a"
|
||||
e.select_word_at(6, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("a"));
|
||||
e.select_word_at(7, SEPS, true); // on the comma itself
|
||||
e.select_word_at(7, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some(","));
|
||||
e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close
|
||||
e.select_word_at(5, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("'a,b'"));
|
||||
// `/ . - _ =` are not separators: a path stays one word.
|
||||
let mut e = ed("cat ./a-b/c_d.txt", 0);
|
||||
e.select_word_at(8, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt"));
|
||||
@@ -738,27 +588,22 @@ mod tests {
|
||||
let mut e = ed("abcdef", 2);
|
||||
e.extend_to(5);
|
||||
assert_eq!(e.selection(), Some((2, 5)));
|
||||
e.extend_to(0); // drag back past the anchor
|
||||
e.extend_to(0);
|
||||
assert_eq!(e.selection(), Some((0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_word_to_grows_by_whole_words_both_directions() {
|
||||
// Double-click "push" (chars 4..8), then drag over later/earlier words.
|
||||
let mut e = ed("git push origin main", 4);
|
||||
e.select_word_at(6, SEPS, true);
|
||||
let (s, a) = e.selection().unwrap(); // (4, 8) == "push"
|
||||
let (s, a) = e.selection().unwrap();
|
||||
assert_eq!((s, a), (4, 8));
|
||||
|
||||
// Drag forward into "origin": selection reaches that word's far edge.
|
||||
e.extend_word_to(s, a, 10, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin"));
|
||||
// Drag on into "main": grows to its end.
|
||||
e.extend_word_to(s, a, 18, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin main"));
|
||||
|
||||
// Drag backward before the anchor word into "git": anchor flips to the
|
||||
// word's far edge, selection covers "git push".
|
||||
e.extend_word_to(s, a, 1, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("git push"));
|
||||
}
|
||||
@@ -776,7 +621,6 @@ mod tests {
|
||||
assert_eq!(e.text(), "a");
|
||||
e.redo();
|
||||
assert_eq!(e.text(), "ab");
|
||||
// A fresh edit clears the redo stack.
|
||||
e.insert_str("X");
|
||||
e.redo();
|
||||
assert_eq!(e.text(), "abX");
|
||||
@@ -784,33 +628,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn no_op_edit_does_not_swallow_the_first_undo() {
|
||||
// A no-op deletion (Backspace with the caret at column 0) used to push a
|
||||
// checkpoint equal to the current buffer, so the next Undo was a dead press
|
||||
// that "restored" the same text instead of undoing the real edit before it.
|
||||
let mut e = ed("x", 0); // buffer "x"; one real edit sits on the undo stack
|
||||
e.backspace(); // no-op: nothing before the caret
|
||||
e.undo(); // must undo the real insert ("x" -> ""), not the phantom no-op
|
||||
let mut e = ed("x", 0);
|
||||
e.backspace();
|
||||
e.undo();
|
||||
assert_eq!(e.text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_edit_between_real_edits_is_not_a_dead_undo_step() {
|
||||
// Same defect via a different no-op path (Ctrl-K at end of line) sitting
|
||||
// between two real edits: one Undo must still step back over a real edit.
|
||||
let mut e = CmdEditor::new();
|
||||
e.insert_str("a");
|
||||
e.insert_str("b"); // buffer "ab", caret at end
|
||||
e.delete_to_end(); // no-op: the caret is already at the end
|
||||
e.insert_str("b");
|
||||
e.delete_to_end();
|
||||
e.undo();
|
||||
assert_eq!(e.text(), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_the_pre_edit_cursor_position() {
|
||||
// A mid-line edit then Undo puts the caret back where the edit began,
|
||||
// not at the end of the line.
|
||||
let mut e = ed("git push", 3);
|
||||
e.insert_str("XY"); // "gitXY push", caret 5
|
||||
e.insert_str("XY");
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("gitXY push", 5));
|
||||
e.undo();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("git push", 3));
|
||||
@@ -820,19 +657,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn select_word_at_snaps_left_from_whitespace_and_clamps() {
|
||||
// A double-click on the gap right after a word snaps left and selects
|
||||
// that word — the same left-scan that makes a double-click at the end
|
||||
// of the line select the last word.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(2, SEPS, true); // the space between the words
|
||||
e.select_word_at(2, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("ab"));
|
||||
// Index at/past the end selects the trailing word, clamped.
|
||||
e.select_word_at(99, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("cd"));
|
||||
// On a gap wider than one cell there is no adjacent word to the left of
|
||||
// the clicked cell: the empty range collapses to no selection.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace
|
||||
e.select_word_at(3, SEPS, true);
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
@@ -842,7 +673,6 @@ mod tests {
|
||||
e.clear();
|
||||
assert!(e.is_empty());
|
||||
assert_eq!(e.cursor(), 0);
|
||||
// Undo after clear (post-submit) must not resurrect the shipped line.
|
||||
e.undo();
|
||||
assert!(e.is_empty());
|
||||
}
|
||||
@@ -857,29 +687,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn forward_word_delete_with_selection_leaves_no_out_of_range_slice() {
|
||||
// Shift-select " cd" leftward (anchor=5, cursor=2), then Alt+Delete
|
||||
// (delete_word_right) drains exactly that region, shrinking "ab cd" -> "ab"
|
||||
// but historically leaving the anchor at 5. selected_text() (Cmd+C / Cmd+X)
|
||||
// then sliced chars[2..5] on a length-2 Vec and panicked, crashing the app.
|
||||
let mut e = ed("ab cd", 5);
|
||||
e.extend_to(2);
|
||||
assert_eq!(e.selection(), Some((2, 5)));
|
||||
e.delete_word_right();
|
||||
assert_eq!(e.text(), "ab");
|
||||
// RED before the fix: selection() returns Some((2, 5)) and selected_text()
|
||||
// panics slicing out of range. GREEN: the stale anchor is clamped away.
|
||||
assert_eq!(e.selection(), None);
|
||||
assert_eq!(e.selected_text(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_buffer_word_delete_shifts_the_anchor_instead_of_faking_a_selection() {
|
||||
// Regression: shift-select "def" leftward in "abc def x" (anchor=7,
|
||||
// cursor=4), then Alt+Delete removes exactly that word *mid-buffer*.
|
||||
// Clamping alone left anchor=7 → clamped to 6 → a phantom (4,6)
|
||||
// selection over " x", text the user never selected (and ⌘C copied).
|
||||
// Shifting the anchor across the removed range collapses it onto the
|
||||
// cursor: no selection survives the deletion of its own text.
|
||||
let mut e = ed("abc def x", 7);
|
||||
e.extend_to(4);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("def"));
|
||||
@@ -891,22 +709,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deletions_before_a_selection_keep_it_on_the_same_text() {
|
||||
// A range delete strictly before the selection shifts it left as a
|
||||
// block, so it keeps covering the same characters.
|
||||
let mut e = ed("one two THREE", 13);
|
||||
e.extend_to(8); // select "THREE" (anchor=13, cursor=8)
|
||||
e.extend_to(8);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("THREE"));
|
||||
e.set_cursor(8); // collapse cursor at the selection start… keep anchor
|
||||
e.delete_to_start(); // Ctrl+U wipes "one two " before it
|
||||
e.set_cursor(8);
|
||||
e.delete_to_start();
|
||||
assert_eq!(e.text(), "THREE");
|
||||
assert_eq!(e.selected_text().as_deref(), Some("THREE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_word_delete_preserves_a_selection_it_did_not_touch() {
|
||||
// Rightward selection "ab" (anchor=0, cursor=2); Alt+Delete removes the
|
||||
// *following* word (" cd"), which doesn't overlap the selection, so the
|
||||
// still-valid "ab" selection must survive (clamping is a no-op here).
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.extend_to(2);
|
||||
assert_eq!(e.selection(), Some((0, 2)));
|
||||
@@ -917,24 +730,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn home_end_are_logical_line_relative_in_a_multiline_buffer() {
|
||||
// A pasted multi-line command keeps its '\n's inline; Home/End act within
|
||||
// the line the caret sits on, not the whole buffer.
|
||||
let mut e = ed("one\ntwo\nthree", 5); // caret in "two" (after 't', 'w')
|
||||
let mut e = ed("one\ntwo\nthree", 5);
|
||||
e.move_home();
|
||||
assert_eq!(e.cursor(), 4, "start of the 'two' line");
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 7, "end of the 'two' line (before the '\\n')");
|
||||
// First line: Home is column 0, End is just before the first '\n'.
|
||||
e.set_cursor(1);
|
||||
e.move_home();
|
||||
assert_eq!(e.cursor(), 0);
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 3);
|
||||
// Last line has no trailing '\n': End is the buffer end.
|
||||
e.set_cursor(10);
|
||||
e.move_end();
|
||||
assert_eq!(e.cursor(), 13);
|
||||
// A single-line buffer is unaffected: Home/End are the buffer edges.
|
||||
let mut s = ed("git push", 4);
|
||||
s.move_home();
|
||||
assert_eq!(s.cursor(), 0);
|
||||
@@ -948,6 +756,6 @@ mod tests {
|
||||
e.set_with_cursor("git status", 3);
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("git status", 3));
|
||||
e.set_with_cursor("hi", 99);
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2)); // clamped
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-356
@@ -1,33 +1,8 @@
|
||||
//! A small, self-contained completion engine for the command editor — tty7's own
|
||||
//! engine, not the shell's `compsys`.
|
||||
//!
|
||||
//! It offers three sources, each candidate carrying the exact char range it
|
||||
//! replaces:
|
||||
//! - **command** — builtins + `$PATH` executables, in command position;
|
||||
//! - **path** — files / directories, elsewhere (replace just the word);
|
||||
//! - **remote path** — the same, for a pane whose filesystem is on the far
|
||||
//! end of an SSH connection. The listing itself is a network round-trip the
|
||||
//! view owns, so this module only splits the word into a request
|
||||
//! ([`remote_path_request`]) and turns the answer into candidates
|
||||
//! ([`remote_path_candidates`]) — both pure, both unit-tested.
|
||||
//!
|
||||
//! History deliberately does *not* feed the menu:
|
||||
//! whole-line recall belongs to the inline ghost text (frecency-ranked, cwd
|
||||
//! aware — accepted with → / Ctrl+F) and Ctrl+R search. Mixing recalled lines
|
||||
//! into the Tab menu buried the precise completions under near-duplicate path
|
||||
//! variants of past commands.
|
||||
//!
|
||||
//! Pure and side-effect-free apart from reading the filesystem / `$PATH`, so the
|
||||
//! word-parsing and path logic are unit-tested directly.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::signature::{self, Arg, CmdNode, Signature};
|
||||
|
||||
/// A word candidate before it's placed at a range. Signature-derived candidates
|
||||
/// carry a `description` and possibly an `icon` (a raw Fig icon string — emoji or
|
||||
/// `fig://…`); `$PATH` and path candidates carry neither.
|
||||
struct WordCand {
|
||||
text: String,
|
||||
kind: CandidateKind,
|
||||
@@ -36,7 +11,6 @@ struct WordCand {
|
||||
}
|
||||
|
||||
impl WordCand {
|
||||
/// A candidate with no signature metadata — the command and path sources.
|
||||
fn plain(text: String, kind: CandidateKind) -> Self {
|
||||
Self {
|
||||
text,
|
||||
@@ -47,36 +21,22 @@ impl WordCand {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a completion candidate refers to — drives both the trailing `/` for
|
||||
/// directories and the menu's leading icon.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CandidateKind {
|
||||
/// A command name (builtin or `$PATH` executable).
|
||||
Command,
|
||||
/// A directory.
|
||||
Dir,
|
||||
/// A regular file.
|
||||
File,
|
||||
/// A command flag / option (e.g. `--message`), from a command signature.
|
||||
Flag,
|
||||
/// A subcommand or argument value, from a command signature.
|
||||
Value,
|
||||
}
|
||||
|
||||
/// A single completion candidate: the replacement text, its kind, and the char
|
||||
/// range `[start, end)` in the original line that it replaces — just the word
|
||||
/// under the cursor (`word_start..cursor`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub text: String,
|
||||
pub kind: CandidateKind,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
/// A one-line hint shown in a second column — the flag/subcommand's
|
||||
/// description from its command signature; `None` for path/command candidates.
|
||||
pub description: Option<String>,
|
||||
/// Raw Fig icon string (emoji or `fig://…`) for signature candidates; the
|
||||
/// view interprets it, falling back to a per-kind glyph. `None` otherwise.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
@@ -86,53 +46,27 @@ impl Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of completing at a cursor: the word candidates, each with its own
|
||||
/// replacement range, plus any *dynamic* generators the position declares.
|
||||
///
|
||||
/// Generators can't be run here — this module is pure and synchronous, while a
|
||||
/// generator is a child process — so the sync candidates come back immediately
|
||||
/// and each pending script rides along for the view to execute on a background
|
||||
/// thread and [`CompletionSession::merge`] into the live menu. A position that
|
||||
/// declares generators is a completion even when `candidates` is empty (an SSH
|
||||
/// host list, a git branch list) — returning `Some` here is what stops the caller
|
||||
/// from falling back to filesystem paths, the bug behind `ssh <Tab>` listing the
|
||||
/// cwd (#51).
|
||||
#[derive(Debug)]
|
||||
pub struct Completion {
|
||||
pub candidates: Vec<Candidate>,
|
||||
pub pending: Vec<PendingGenerator>,
|
||||
}
|
||||
|
||||
/// A dynamic generator awaiting execution: the shell `script` (the spec's token
|
||||
/// list joined with single spaces, ready for `/bin/sh -c`). The view runs it off
|
||||
/// the main thread and merges its stdout-derived candidates into the open menu.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingGenerator {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Common shell builtins / keywords, offered in command position. Not exhaustive,
|
||||
/// but covers what `$PATH` scanning misses (builtins aren't files).
|
||||
const BUILTINS: &[&str] = &[
|
||||
"cd", "echo", "exit", "export", "pwd", "alias", "unalias", "source", "set", "unset", "history",
|
||||
"jobs", "fg", "bg", "kill", "which", "type", "read", "local", "return", "eval", "exec", "test",
|
||||
"true", "false", "printf", "let", "declare", "typeset", "shift", "trap", "wait", "umask",
|
||||
];
|
||||
|
||||
/// Cap on candidates returned, so a bare prefix that matches thousands of files
|
||||
/// (or `$PATH` entries) can't blow up the UI or the cycle.
|
||||
const MAX_CANDIDATES: usize = 400;
|
||||
|
||||
/// Commands whose arguments are directories, never files. They have no Fig
|
||||
/// signature (shell builtins), so the generic path fallback handles them —
|
||||
/// which must not offer files (`cd tar` completing to `tar.exe` is never
|
||||
/// right).
|
||||
const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"];
|
||||
|
||||
/// The command name the cursor's word is an argument of: the first token of
|
||||
/// the current simple command (after the last shell separator), reduced to its
|
||||
/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no
|
||||
/// command token before the word.
|
||||
fn current_command(chars: &[char], word_start: usize) -> Option<String> {
|
||||
let prefix: String = chars[..word_start].iter().collect();
|
||||
let seg_start = prefix
|
||||
@@ -147,18 +81,10 @@ fn current_command(chars: &[char], word_start: usize) -> Option<String> {
|
||||
(!base.is_empty()).then(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Compute completions for `line` at char position `cursor`, resolving relative
|
||||
/// paths against `cwd`: command names in command position, filesystem paths
|
||||
/// elsewhere. Returns `None` when there's nothing to offer.
|
||||
///
|
||||
/// `cwd` is `None` when the pane has no directory on *this* machine — a remote
|
||||
/// pane. Command completion still runs; everything that would touch the local
|
||||
/// filesystem is skipped rather than answered from the wrong machine.
|
||||
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let cursor = cursor.min(chars.len());
|
||||
|
||||
// The word under completion is the run of non-whitespace ending at the cursor.
|
||||
let mut word_start = cursor;
|
||||
while word_start > 0 && !chars[word_start - 1].is_whitespace() {
|
||||
word_start -= 1;
|
||||
@@ -169,30 +95,10 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Complet
|
||||
let (word_cands, pending) = if is_command && !word.contains('/') {
|
||||
(complete_command(&word), Vec::new())
|
||||
} else {
|
||||
// In argument position, prefer a per-command signature (flags,
|
||||
// subcommands, typed args) when the command has one; otherwise fall
|
||||
// back to filesystem paths. A signature slot that declares suggestions
|
||||
// or generators owns the position: it returns `Some` (possibly with no
|
||||
// sync candidates but pending scripts) rather than ceding to paths.
|
||||
//
|
||||
// A missing `cwd` means a remote pane, and it disables only the parts
|
||||
// that read *this* machine: paths and generators (see
|
||||
// [`complete_signature`]). The rest of a signature is static text —
|
||||
// `git push`, `--verbose` — and is just as true on the remote, so it is
|
||||
// still offered. Withholding it too would make every Tab in a remote
|
||||
// pane a no-match, and a no-match hands the line to the shell, which
|
||||
// costs the user the inline editor for that prompt.
|
||||
match complete_signature(&chars, word_start, &word, cwd) {
|
||||
Some(sig) => (sig.cands, sig.pending),
|
||||
None => match cwd {
|
||||
// No signature and no local filesystem to fall back on. Offering
|
||||
// this machine's names would insert them into a remote command
|
||||
// line where they do not exist; returning nothing instead lets
|
||||
// the caller hand the Tab to the remote's own completion, which
|
||||
// can actually see that filesystem.
|
||||
None => (Vec::new(), Vec::new()),
|
||||
// No signature: generic paths, narrowed to directories when
|
||||
// the command only takes those (`cd`, `pushd`, …).
|
||||
Some(cwd) => {
|
||||
let dirs_only = current_command(&chars, word_start)
|
||||
.is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str()));
|
||||
@@ -223,9 +129,6 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Complet
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-name completion: builtins plus `$PATH` executables starting with
|
||||
/// `word`. An empty word returns nothing (we don't dump every command on a bare
|
||||
/// Tab in command position). Ordered by closeness.
|
||||
fn complete_command(word: &str) -> Vec<WordCand> {
|
||||
if word.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -259,9 +162,6 @@ fn complete_command(word: &str) -> Vec<WordCand> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Order strings by closeness to what the user typed: since every candidate
|
||||
/// shares the typed prefix, the edit distance is just the length still to fill
|
||||
/// in — so shorter completions come first, ties broken alphabetically.
|
||||
fn sort_by_closeness(items: &mut [String]) {
|
||||
items.sort_by(|a, b| {
|
||||
a.chars()
|
||||
@@ -271,10 +171,6 @@ fn sort_by_closeness(items: &mut [String]) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Order candidates in place by closeness — shorter completions first, ties
|
||||
/// alphabetical — the same ordering path and signature completion use, applied
|
||||
/// across the merged set so asynchronously-arriving generator results settle
|
||||
/// into the menu's existing sort rather than piling up at the end.
|
||||
fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
|
||||
cands.sort_by(|a, b| {
|
||||
a.text
|
||||
@@ -285,56 +181,22 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a path-position Tab in a remote pane needs listed on the *far side*,
|
||||
/// produced by [`remote_path_request`] and consumed by
|
||||
/// [`remote_path_candidates`] once the listing comes back.
|
||||
///
|
||||
/// Split in two because the listing is a network round-trip: nothing here
|
||||
/// touches a filesystem, so both halves stay pure and testable while the view
|
||||
/// owns the async middle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePathRequest {
|
||||
/// Absolute directory to list on the remote.
|
||||
pub dir: String,
|
||||
/// What an entry's name must start with to be offered.
|
||||
pub prefix: String,
|
||||
/// The typed text up to and including the last `/`, re-prepended to every
|
||||
/// candidate so the path the user typed is preserved (as [`complete_path`]
|
||||
/// does locally).
|
||||
pub dir_part: String,
|
||||
/// Char range in the line the candidates replace.
|
||||
pub word_start: usize,
|
||||
pub cursor: usize,
|
||||
/// Drop file entries — the command only takes directories.
|
||||
pub dirs_only: bool,
|
||||
}
|
||||
|
||||
/// One entry of a remote directory listing, reduced to what completion cares
|
||||
/// about. Keeps this module free of the daemon's SFTP protocol types; the view
|
||||
/// converts (and is where "a symlink to a directory counts as a directory"
|
||||
/// gets decided, since only the protocol knows the link target).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// The remote directory a path-position Tab wants listed, or `None` when the
|
||||
/// caret isn't somewhere a remote path listing could help.
|
||||
///
|
||||
/// `remote_cwd` is the pane's cwd *in the remote's namespace* — the caller must
|
||||
/// have established that the pane really is remote. Declines:
|
||||
/// - **command position** (a bare first word): those complete from `$PATH`,
|
||||
/// and this machine's `$PATH` is the wrong answer for a remote anyway —
|
||||
/// that's [`complete_command`]'s call, not a filesystem question.
|
||||
/// - **`~`-prefixed words**: expanding one needs the remote's `$HOME`, which
|
||||
/// no OSC reports. Declining hands the Tab to the remote shell, which can
|
||||
/// expand it.
|
||||
/// - a **relative `remote_cwd`**: nothing to resolve against.
|
||||
///
|
||||
/// Separators are `/` only — deliberately not [`std::path::is_separator`],
|
||||
/// which also accepts `\` on Windows. A Windows host talking to a POSIX remote
|
||||
/// must not treat a backslash in the *remote's* path as a separator.
|
||||
pub fn remote_path_request(
|
||||
line: &str,
|
||||
cursor: usize,
|
||||
@@ -363,10 +225,6 @@ pub fn remote_path_request(
|
||||
Some(i) => (&word[..=i], &word[i + 1..]),
|
||||
None => ("", word.as_str()),
|
||||
};
|
||||
// An absolute `dir_part` stands alone; anything else resolves against the
|
||||
// remote cwd. `.`/`..` inside the path are left for the far side to
|
||||
// resolve — an SFTP server handles them, and we have no remote filesystem
|
||||
// to normalize against here.
|
||||
let dir = if dir_part.starts_with('/') {
|
||||
dir_part.to_string()
|
||||
} else if dir_part.is_empty() {
|
||||
@@ -386,13 +244,6 @@ pub fn remote_path_request(
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn a remote directory listing into candidates for `req`. Mirrors
|
||||
/// [`complete_path`]'s rules exactly — hidden entries only when the prefix asks
|
||||
/// for them, `dirs_only` filtering, closeness ordering, the same cap — so a
|
||||
/// remote Tab behaves like a local one.
|
||||
///
|
||||
/// `.` and `..` are dropped: a local `read_dir` never yields them, and offering
|
||||
/// them here would make the two panes feel different.
|
||||
pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry]) -> Vec<Candidate> {
|
||||
let mut out: Vec<Candidate> = Vec::new();
|
||||
for entry in entries {
|
||||
@@ -429,16 +280,7 @@ pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry])
|
||||
out
|
||||
}
|
||||
|
||||
/// Filesystem path completion. Splits `word` into the directory part (kept
|
||||
/// verbatim in each candidate so the typed path prefix is preserved) and the
|
||||
/// final-segment prefix to match in that directory. Ordered by closeness.
|
||||
/// `dirs_only` drops file entries — for commands / argument slots that only
|
||||
/// accept directories.
|
||||
fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
|
||||
// Split on the last path separator. `is_separator` is `/` on Unix and both
|
||||
// `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly
|
||||
// under the (future) Windows line editor; separators are ASCII so the byte
|
||||
// slice boundaries are valid.
|
||||
let (dir_part, prefix) = match word.rfind(std::path::is_separator) {
|
||||
Some(i) => (&word[..=i], &word[i + 1..]),
|
||||
None => ("", word),
|
||||
@@ -451,16 +293,12 @@ fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
|
||||
let mut out: Vec<WordCand> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// Hidden entries only when the prefix explicitly starts with a dot.
|
||||
if name.starts_with('.') && !prefix.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if !name.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
// Follow symlinks when classifying: a symlink to a directory must count
|
||||
// as one (it both takes the trailing `/` and survives a dirs-only
|
||||
// filter — `cd` into a linked dir is routine).
|
||||
let is_dir = entry
|
||||
.file_type()
|
||||
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir()));
|
||||
@@ -487,41 +325,17 @@ fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The sync half of a signature-driven completion: candidates ready now, plus
|
||||
/// the dynamic generators whose output the view will merge in later. Returned as
|
||||
/// one unit so the caller can tell "this slot is a completion (don't fall back to
|
||||
/// paths)" from "no signature here" via the `Option` around it.
|
||||
struct SigResult {
|
||||
cands: Vec<WordCand>,
|
||||
pending: Vec<PendingGenerator>,
|
||||
}
|
||||
|
||||
/// Signature-driven completion in argument position. Tokenizes the text before
|
||||
/// the word into an argv, and — if the current command has a signature — offers
|
||||
/// flags, subcommands, or typed-argument suggestions for the cursor's position,
|
||||
/// alongside any dynamic generators that position declares.
|
||||
///
|
||||
/// Returns `None` (so the caller falls back to path completion) when the command
|
||||
/// has no signature, or when the position yields nothing useful and isn't a flag,
|
||||
/// value, suggestion, or generator slot (so a bare argument still lists files).
|
||||
/// A slot with generators returns `Some` even with zero sync candidates — its
|
||||
/// results are still inbound, and falling back to paths there is exactly #51.
|
||||
///
|
||||
/// `cwd` is `None` for a remote pane, which suppresses the two things that would
|
||||
/// answer with *this* machine's state: path completion, and generators. The
|
||||
/// generator exclusion matters more than it looks — a generator is a local
|
||||
/// `/bin/sh -c` (see [`super::generator`]), so `git checkout <Tab>` against a
|
||||
/// remote would offer the branches of whatever repo the *local* cwd happens to
|
||||
/// sit in. Wrong filenames are obvious when they fail; wrong branch names look
|
||||
/// plausible and land in a real command.
|
||||
fn complete_signature(
|
||||
chars: &[char],
|
||||
word_start: usize,
|
||||
word: &str,
|
||||
cwd: Option<&Path>,
|
||||
) -> Option<SigResult> {
|
||||
// Only the current simple command matters: start after the last shell
|
||||
// separator so `foo | git <tab>` completes `git`, not `foo`.
|
||||
let prefix: String = chars[..word_start].iter().collect();
|
||||
let seg_start = prefix
|
||||
.rfind(['|', '&', ';', '\n', '('])
|
||||
@@ -533,8 +347,6 @@ fn complete_signature(
|
||||
|
||||
let (node, pending_value) = walk_signature(&sig, &tokens[1..]);
|
||||
|
||||
// Flag position: options of the current node whose spelling extends `word`.
|
||||
// Flags never carry generators.
|
||||
if word.starts_with('-') {
|
||||
let mut out = Vec::new();
|
||||
for opt in node.options() {
|
||||
@@ -558,7 +370,6 @@ fn complete_signature(
|
||||
});
|
||||
}
|
||||
|
||||
// Value position: the previous token was an option taking an argument.
|
||||
if let Some(arg) = pending_value {
|
||||
let mut out = Vec::new();
|
||||
push_arg_suggestions(&mut out, arg, word);
|
||||
@@ -571,9 +382,6 @@ fn complete_signature(
|
||||
Some(_) => collect_generators(arg),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// A slot that declares suggestions or generators owns the position even
|
||||
// when nothing matches yet; only a truly featureless value slot cedes to
|
||||
// path completion.
|
||||
if out.is_empty() && pending.is_empty() && arg.suggestions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -583,7 +391,6 @@ fn complete_signature(
|
||||
});
|
||||
}
|
||||
|
||||
// Fresh token: subcommands of the current node plus its first positional arg.
|
||||
let mut out = Vec::new();
|
||||
for sub in node.subcommands() {
|
||||
if sub.hidden {
|
||||
@@ -612,8 +419,6 @@ fn complete_signature(
|
||||
if cwd.is_some() {
|
||||
pending = collect_generators(arg);
|
||||
}
|
||||
// Suggestions/generators mean this positional owns the slot: don't cede
|
||||
// to paths just because the sync list came back empty.
|
||||
claims_slot = !arg.suggestions.is_empty() || !pending.is_empty();
|
||||
}
|
||||
if out.is_empty() && !claims_slot {
|
||||
@@ -626,10 +431,6 @@ fn complete_signature(
|
||||
}
|
||||
}
|
||||
|
||||
/// Join each of an argument's dynamic generators into a runnable `/bin/sh -c`
|
||||
/// command string. The converter word-split original string scripts, so joining
|
||||
/// with single spaces and letting the shell re-parse restores pipes, quoting,
|
||||
/// and `bash -c "…"`-style entries.
|
||||
fn collect_generators(arg: &Arg) -> Vec<PendingGenerator> {
|
||||
arg.generators
|
||||
.iter()
|
||||
@@ -640,17 +441,12 @@ fn collect_generators(arg: &Arg) -> Vec<PendingGenerator> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Walk the argv after the command name, descending into matched subcommands and
|
||||
/// skipping options (and the value tokens of value-taking ones). Returns the
|
||||
/// deepest node reached, and — when the final prior token is a value-taking
|
||||
/// option — the argument the cursor is now positioned to complete.
|
||||
fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Option<&'a Arg>) {
|
||||
let mut node: &dyn CmdNode = sig;
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
let tok = rest[i];
|
||||
if tok.starts_with('-') {
|
||||
// Skip the flag, and its value token when it takes one inline-`=`-free.
|
||||
if node.find_option(tok).is_some_and(|o| o.takes_arg()) && !tok.contains('=') {
|
||||
i += 2;
|
||||
} else {
|
||||
@@ -661,11 +457,9 @@ fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Op
|
||||
if let Some(sub) = node.find_subcommand(tok) {
|
||||
node = sub;
|
||||
}
|
||||
// A non-matching bare token is a positional arg; the node is unchanged.
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Is the cursor sitting on a value-taking option's value?
|
||||
let pending = rest.last().and_then(|last| {
|
||||
(last.starts_with('-') && !last.contains('='))
|
||||
.then(|| node.find_option(last))
|
||||
@@ -676,7 +470,6 @@ fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Op
|
||||
(node, pending)
|
||||
}
|
||||
|
||||
/// Append an argument's static value suggestions matching `word`.
|
||||
fn push_arg_suggestions(out: &mut Vec<WordCand>, arg: &Arg, word: &str) {
|
||||
for sug in &arg.suggestions {
|
||||
for name in &sug.names {
|
||||
@@ -692,8 +485,6 @@ fn push_arg_suggestions(out: &mut Vec<WordCand>, arg: &Arg, word: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dedupe by replacement text and order by closeness (shorter first, then
|
||||
/// alphabetical) — the same ordering path completion uses.
|
||||
fn finish(mut out: Vec<WordCand>) -> Vec<WordCand> {
|
||||
out.sort_by(|a, b| {
|
||||
a.text
|
||||
@@ -706,8 +497,6 @@ fn finish(mut out: Vec<WordCand>) -> Vec<WordCand> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve the directory portion of a path word to an absolute directory to list:
|
||||
/// handles `~` expansion, absolute paths, and paths relative to `cwd`.
|
||||
fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf {
|
||||
if dir_part.is_empty() {
|
||||
return cwd.to_path_buf();
|
||||
@@ -726,39 +515,20 @@ fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf {
|
||||
if p.is_absolute() { p } else { cwd.join(p) }
|
||||
}
|
||||
|
||||
/// The user's home directory: `$HOME` on Unix, falling back to `%USERPROFILE%`
|
||||
/// on Windows (where `HOME` is usually unset).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// One open completion menu: a *picker* over the candidates gathered
|
||||
/// when it opened. Moving the highlight (Tab / ↑ / ↓) never touches the editor
|
||||
/// line — the line changes only when a candidate is accepted (Enter) or when Tab
|
||||
/// fills the candidates' common prefix. Typing re-filters the same candidate set
|
||||
/// via [`CompletionSession::refilter`]; the session ends once the word stops
|
||||
/// extending the one it opened on. Fields are `pub(super)` so the terminal view
|
||||
/// can render the menu.
|
||||
pub(super) struct CompletionSession {
|
||||
/// Char index where the word under completion starts — the fixed left edge
|
||||
/// of the range an accept replaces (the right edge is the live caret).
|
||||
pub(super) word_start: usize,
|
||||
/// The word as typed when the menu opened (before any common-prefix fill).
|
||||
/// Backspacing below it closes the menu.
|
||||
pub(super) open_word: String,
|
||||
/// Every candidate from open time; `filtered` holds indices into this.
|
||||
pub(super) all: Vec<Candidate>,
|
||||
/// Indices into `all` still prefix-matching the live word, in order.
|
||||
pub(super) filtered: Vec<usize>,
|
||||
/// Highlighted row (an index into `filtered`).
|
||||
pub(super) index: Option<usize>,
|
||||
}
|
||||
|
||||
/// A splice to apply to the command editor: replace chars `[start, end)` of
|
||||
/// `orig` with `text`. Used by the view's accept / prefix-fill paths; kept
|
||||
/// separate so the pure string edit is testable without a live editor.
|
||||
pub(super) struct Replacement {
|
||||
pub(super) orig: String,
|
||||
pub(super) start: usize,
|
||||
@@ -767,9 +537,6 @@ pub(super) struct Replacement {
|
||||
}
|
||||
|
||||
impl Replacement {
|
||||
/// Perform the splice: returns the new line and the caret position (just after
|
||||
/// the inserted text). Char-indexed and clamped, so out-of-range candidate
|
||||
/// offsets can never panic.
|
||||
pub(super) fn apply(&self) -> (String, usize) {
|
||||
let mut chars: Vec<char> = self.orig.chars().collect();
|
||||
let start = self.start.min(chars.len());
|
||||
@@ -782,8 +549,6 @@ impl Replacement {
|
||||
}
|
||||
|
||||
impl CompletionSession {
|
||||
/// Open a menu over `all` with the first row highlighted (a default
|
||||
/// preselection, so a bare Enter accepts the top pick).
|
||||
pub(super) fn new(word_start: usize, open_word: String, all: Vec<Candidate>) -> Self {
|
||||
let filtered = (0..all.len()).collect();
|
||||
Self {
|
||||
@@ -795,15 +560,12 @@ impl CompletionSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// The highlighted candidate, if any.
|
||||
pub(super) fn selected(&self) -> Option<&Candidate> {
|
||||
self.index
|
||||
.and_then(|i| self.filtered.get(i))
|
||||
.map(|&i| &self.all[i])
|
||||
}
|
||||
|
||||
/// Move the highlight to the next (`forward`) or previous row, wrapping.
|
||||
/// Selection is visual only — the editor line changes on accept.
|
||||
pub(super) fn select(&mut self, forward: bool) {
|
||||
let n = self.filtered.len();
|
||||
if n == 0 {
|
||||
@@ -817,10 +579,6 @@ impl CompletionSession {
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-filter for the live `word`. Returns `false` when the menu should
|
||||
/// close: the word no longer extends the one it opened on (backspaced past
|
||||
/// it) or nothing matches any more. A highlighted candidate that survives
|
||||
/// the filter keeps its highlight; one filtered away falls back to the top.
|
||||
pub(super) fn refilter(&mut self, word: &str) -> bool {
|
||||
if !word.starts_with(self.open_word.as_str()) {
|
||||
return false;
|
||||
@@ -837,19 +595,6 @@ impl CompletionSession {
|
||||
true
|
||||
}
|
||||
|
||||
/// Merge asynchronously-produced generator candidates into the open menu.
|
||||
///
|
||||
/// Called on the main thread when a background generator finishes: dedupe the
|
||||
/// new candidates by text against everything already gathered, append the
|
||||
/// survivors, re-sort the whole set by closeness, then re-run the prefix
|
||||
/// filter against `live_word` — the word as it stands *now*, which may have
|
||||
/// grown while the generator ran. A highlighted candidate that survives the
|
||||
/// re-filter keeps its highlight (matched by text, since the sort renumbers
|
||||
/// `all`); otherwise the top row takes over.
|
||||
///
|
||||
/// Unlike [`Self::refilter`] this never signals "close": a generator whose
|
||||
/// results don't match the live word (or that returned nothing) just leaves
|
||||
/// the menu as it was — the session lives or dies on the user's own edits.
|
||||
pub(super) fn merge(&mut self, new: Vec<Candidate>, live_word: &str) {
|
||||
let selected_text = self
|
||||
.index
|
||||
@@ -876,8 +621,6 @@ impl CompletionSession {
|
||||
};
|
||||
}
|
||||
|
||||
/// Longest common prefix (in chars) of the filtered candidates — what Tab
|
||||
/// fills before it starts moving the highlight.
|
||||
pub(super) fn common_prefix(&self) -> Option<String> {
|
||||
let mut texts = self.filtered.iter().map(|&i| self.all[i].text.as_str());
|
||||
let mut lcp: Vec<char> = texts.next()?.chars().collect();
|
||||
@@ -911,8 +654,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidate texts `complete` returns for `line` with the cursor at the
|
||||
/// end, or an empty vec when it offers nothing.
|
||||
fn texts(line: &str) -> Vec<String> {
|
||||
complete(line, line.chars().count(), Some(Path::new("/")))
|
||||
.map(|c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
@@ -924,7 +665,6 @@ mod tests {
|
||||
let t = texts("git ");
|
||||
assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}");
|
||||
assert!(t.iter().any(|s| s == "status"));
|
||||
// Descriptions ride along for the menu's second column.
|
||||
let c = complete("git ", 4, Some(Path::new("/"))).unwrap();
|
||||
let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap();
|
||||
assert_eq!(commit.kind, CandidateKind::Value);
|
||||
@@ -951,7 +691,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn signature_resolves_nested_subcommands() {
|
||||
// docker compose was grafted in via loadSpec; its subcommands complete.
|
||||
let t = texts("docker compose ");
|
||||
assert!(
|
||||
t.iter().any(|s| s == "up"),
|
||||
@@ -961,10 +700,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generator_arg_pends_scripts_and_suppresses_path_fallback() {
|
||||
// `git checkout <arg>` declares branch/tag generators (dynamic) with no
|
||||
// `filepaths` template. Pre-#51 the empty-static-match path fell through
|
||||
// to filesystem completion and listed the cwd; now the slot owns the
|
||||
// position — it returns the generator scripts and no path candidates.
|
||||
let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]);
|
||||
let line = "git checkout ";
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path()))
|
||||
@@ -980,7 +715,6 @@ mod tests {
|
||||
"one pending script is the joined git-branch listing: {:?}",
|
||||
c.pending
|
||||
);
|
||||
// Crucially, no filesystem entry from the cwd leaked into the menu.
|
||||
assert!(
|
||||
c.candidates
|
||||
.iter()
|
||||
@@ -992,8 +726,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generator_script_tokens_join_with_single_spaces() {
|
||||
// The converter word-split original string scripts; joining restores a
|
||||
// single `/bin/sh -c` command.
|
||||
let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
|
||||
let branch = c
|
||||
.pending
|
||||
@@ -1008,26 +740,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn merge_dedupes_resorts_and_refilters_to_live_word() {
|
||||
// Open on "f" with one static candidate, then a generator lands two
|
||||
// branches; the merged set is deduped, closeness-sorted, and filtered to
|
||||
// the live word.
|
||||
let mut s = CompletionSession::new(
|
||||
0,
|
||||
"f".into(),
|
||||
vec![cand("feature", CandidateKind::Value, 0, 1)],
|
||||
);
|
||||
let new = vec![
|
||||
cand("feature", CandidateKind::Value, 0, 1), // dup by text — dropped
|
||||
cand("feature", CandidateKind::Value, 0, 1),
|
||||
cand("fix", CandidateKind::Value, 0, 1),
|
||||
cand("main", CandidateKind::Value, 0, 1), // filtered out by live word "f"
|
||||
cand("main", CandidateKind::Value, 0, 1),
|
||||
];
|
||||
s.merge(new, "f");
|
||||
let texts: Vec<&str> = s.filtered.iter().map(|&i| s.all[i].text.as_str()).collect();
|
||||
// "main" gone (doesn't start with "f"); "feature" not duplicated; closeness
|
||||
// puts the shorter "fix" first.
|
||||
assert_eq!(texts, vec!["fix", "feature"]);
|
||||
// The default open-highlight was on "feature"; it survives the merge and
|
||||
// follows the candidate to its new sorted slot rather than snapping to top.
|
||||
assert_eq!(s.selected().unwrap().text, "feature");
|
||||
}
|
||||
|
||||
@@ -1041,17 +766,14 @@ mod tests {
|
||||
cand("branch-b", CandidateKind::Value, 0, 1),
|
||||
],
|
||||
);
|
||||
s.select(true); // highlight "branch-b"
|
||||
s.select(true);
|
||||
assert_eq!(s.selected().unwrap().text, "branch-b");
|
||||
s.merge(vec![cand("bugfix", CandidateKind::Value, 0, 1)], "b");
|
||||
// The highlighted candidate survives the merge/re-sort and keeps focus.
|
||||
assert_eq!(s.selected().unwrap().text, "branch-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_only_commands_complete_only_directories() {
|
||||
// `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the
|
||||
// other dir-only builtins, and for absolute spellings by basename.
|
||||
let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]);
|
||||
let only_dirs = |line: &str| {
|
||||
complete(line, line.chars().count(), Some(dir.as_path()))
|
||||
@@ -1061,11 +783,8 @@ mod tests {
|
||||
assert_eq!(only_dirs("cd tar"), vec!["target"]);
|
||||
assert_eq!(only_dirs("pushd tar"), vec!["target"]);
|
||||
assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]);
|
||||
// Only the current simple command counts: `cd` after a pipe governs.
|
||||
assert_eq!(only_dirs("foo | cd tar"), vec!["target"]);
|
||||
// A bare argument slot narrows too.
|
||||
assert_eq!(only_dirs("cd "), vec!["target"]);
|
||||
// A generic command keeps offering files alongside directories.
|
||||
let both = only_dirs("frobnicate tar");
|
||||
assert!(both.contains(&"tar.gz".to_string()), "{both:?}");
|
||||
assert!(both.contains(&"target".to_string()), "{both:?}");
|
||||
@@ -1073,7 +792,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unknown_command_falls_back_to_paths() {
|
||||
// A command with no signature still path-completes (no panic, no menu here).
|
||||
let dir = temp_tree("fallback", &[("readme.md", false)]);
|
||||
let c = complete(
|
||||
"frobnicate read",
|
||||
@@ -1121,28 +839,25 @@ mod tests {
|
||||
#[test]
|
||||
fn select_moves_the_highlight_and_wraps_without_touching_candidates() {
|
||||
let mut s = session(&["aa", "ab", "ac"]);
|
||||
assert_eq!(s.index, Some(0)); // first row preselected on open
|
||||
assert_eq!(s.index, Some(0));
|
||||
s.select(true);
|
||||
assert_eq!(s.index, Some(1));
|
||||
s.select(true);
|
||||
s.select(true);
|
||||
assert_eq!(s.index, Some(0)); // wraps forward
|
||||
assert_eq!(s.index, Some(0));
|
||||
s.select(false);
|
||||
assert_eq!(s.index, Some(2)); // wraps backward
|
||||
assert_eq!(s.index, Some(2));
|
||||
assert_eq!(s.selected().unwrap().text, "ac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refilter_narrows_keeps_surviving_highlight_and_closes_when_stale() {
|
||||
let mut s = session(&["aa", "ab", "abc"]);
|
||||
s.select(true); // highlight "ab"
|
||||
s.select(true);
|
||||
assert!(s.refilter("ab"));
|
||||
// "aa" filtered out; the highlighted "ab" survives and keeps its highlight.
|
||||
assert_eq!(s.filtered.len(), 2);
|
||||
assert_eq!(s.selected().unwrap().text, "ab");
|
||||
// A word that no longer extends the open word closes the menu…
|
||||
assert!(!s.refilter(""));
|
||||
// …as does one nothing matches.
|
||||
let mut s = session(&["aa", "ab"]);
|
||||
assert!(!s.refilter("az"));
|
||||
}
|
||||
@@ -1150,7 +865,6 @@ mod tests {
|
||||
#[test]
|
||||
fn refilter_falls_back_to_the_top_when_the_highlight_is_filtered_away() {
|
||||
let mut s = session(&["aa", "ab", "abc"]);
|
||||
// Highlight "aa", then type "ab" — "aa" drops out, top row takes over.
|
||||
assert_eq!(s.selected().unwrap().text, "aa");
|
||||
assert!(s.refilter("ab"));
|
||||
assert_eq!(s.selected().unwrap().text, "ab");
|
||||
@@ -1183,7 +897,7 @@ mod tests {
|
||||
let c = complete("ech", 3, Some(Path::new("/"))).unwrap();
|
||||
let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap();
|
||||
assert_eq!(echo.kind, CandidateKind::Command);
|
||||
assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech"
|
||||
assert_eq!((echo.start, echo.end), (0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1195,11 +909,10 @@ mod tests {
|
||||
let line = "cat a";
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
// Closeness order: assets(6) < apply.sh(8) < apple.txt(9).
|
||||
assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]);
|
||||
let assets = c.candidates.iter().find(|c| c.text == "assets").unwrap();
|
||||
assert!(assets.is_dir());
|
||||
assert_eq!((assets.start, assets.end), (4, 5)); // the "a" word
|
||||
assert_eq!((assets.start, assets.end), (4, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1238,66 +951,42 @@ mod tests {
|
||||
assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]);
|
||||
}
|
||||
|
||||
/// A remote pane has no local cwd. Path candidates must come back empty
|
||||
/// rather than from tty7's own directory — inserting a local filename into
|
||||
/// a remote command line names a file that isn't there. Command completion
|
||||
/// is unaffected: it reads `$PATH`, not the cwd.
|
||||
#[test]
|
||||
fn a_remote_pane_completes_commands_but_never_local_paths() {
|
||||
let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]);
|
||||
|
||||
// With a local cwd the file is offered...
|
||||
let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths");
|
||||
assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here")));
|
||||
|
||||
// ...and with none it is not, from the same line.
|
||||
assert!(complete("cat only", 8, None).is_none());
|
||||
// Nor does a bare argument position dump anything.
|
||||
assert!(complete("cat ", 4, None).is_none());
|
||||
|
||||
// Command position still works — that source never touches the cwd.
|
||||
let c = complete("ech", 3, None).expect("command completion needs no cwd");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "echo"));
|
||||
}
|
||||
|
||||
/// The static half of a signature — subcommands, flags — describes the
|
||||
/// *command*, not the machine, so it survives the loss of a local cwd. This
|
||||
/// is what keeps Tab useful in a remote pane: a position with no candidates
|
||||
/// hands the line to the shell (`handoff_tab_to_shell`), which costs the
|
||||
/// user the inline editor until the next prompt, so answering "nothing" for
|
||||
/// every `git <Tab>` was a real regression once remote panes gained an
|
||||
/// editor at all.
|
||||
#[test]
|
||||
fn a_remote_pane_still_gets_a_signatures_static_candidates() {
|
||||
let c = complete("git ", 4, None).expect("subcommands need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "commit"));
|
||||
assert!(c.candidates.iter().any(|c| c.text == "push"));
|
||||
|
||||
// Prefix filtering works the same as it does locally.
|
||||
let c = complete("git ch", 6, None).expect("subcommands need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "checkout"));
|
||||
assert!(!c.candidates.iter().any(|c| c.text == "commit"));
|
||||
|
||||
// Flags too.
|
||||
let c = complete("git commit --", 13, None).expect("flags need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "--message"));
|
||||
}
|
||||
|
||||
/// Generators are local `/bin/sh -c` child processes, so against a remote
|
||||
/// they would answer with this machine's state — `git checkout <Tab>`
|
||||
/// offering the branches of whatever repo tty7's own cwd sits in. Unlike a
|
||||
/// wrong filename, a wrong branch name is plausible enough to be accepted.
|
||||
#[test]
|
||||
fn a_remote_pane_never_runs_a_generator() {
|
||||
// Locally this slot is generator-owned (the branch list).
|
||||
let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
|
||||
assert!(
|
||||
!local.pending.is_empty(),
|
||||
"expected the local branch generator to still be declared"
|
||||
);
|
||||
|
||||
// Remotely the same slot may keep its static candidates, but must not
|
||||
// schedule a single script.
|
||||
if let Some(remote) = complete("git checkout ", 13, None) {
|
||||
assert!(
|
||||
remote.pending.is_empty(),
|
||||
@@ -1307,11 +996,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The word under the caret, split and resolved against the *remote* cwd.
|
||||
/// This is the request the pane's SSH connection is asked to list.
|
||||
#[test]
|
||||
fn remote_path_request_splits_the_word_and_resolves_against_the_remote_cwd() {
|
||||
// Bare word: list the cwd itself, nothing to re-prepend.
|
||||
let r = remote_path_request("cat fi", 6, "/home/me").unwrap();
|
||||
assert_eq!(
|
||||
(r.dir.as_str(), r.prefix.as_str(), r.dir_part.as_str()),
|
||||
@@ -1320,56 +1006,39 @@ mod tests {
|
||||
assert_eq!((r.word_start, r.cursor), (4, 6));
|
||||
assert!(!r.dirs_only);
|
||||
|
||||
// Relative subdirectory: resolved against the cwd, typed text preserved.
|
||||
let r = remote_path_request("cat sub/fi", 10, "/home/me").unwrap();
|
||||
assert_eq!(r.dir, "/home/me/sub/");
|
||||
assert_eq!((r.prefix.as_str(), r.dir_part.as_str()), ("fi", "sub/"));
|
||||
|
||||
// Absolute: stands alone, the cwd is irrelevant.
|
||||
let r = remote_path_request("cat /etc/pa", 11, "/home/me").unwrap();
|
||||
assert_eq!(r.dir, "/etc/");
|
||||
assert_eq!(r.prefix, "pa");
|
||||
|
||||
// A trailing separator on the cwd must not double up.
|
||||
let r = remote_path_request("cat sub/", 8, "/").unwrap();
|
||||
assert_eq!(r.dir, "/sub/");
|
||||
|
||||
// `cd` takes directories only — same rule as the local engine.
|
||||
assert!(
|
||||
remote_path_request("cd pro", 6, "/home/me")
|
||||
.unwrap()
|
||||
.dirs_only
|
||||
);
|
||||
|
||||
// A backslash is a filename character on a POSIX remote, not a
|
||||
// separator — even when tty7 itself runs on Windows.
|
||||
let r = remote_path_request(r"cat a\b", 7, "/home/me").unwrap();
|
||||
assert_eq!((r.dir.as_str(), r.prefix.as_str()), ("/home/me", r"a\b"));
|
||||
}
|
||||
|
||||
/// Positions where a remote listing is the wrong answer: the caller falls
|
||||
/// back to the shell handoff for these rather than guessing.
|
||||
#[test]
|
||||
fn remote_path_request_declines_where_a_listing_cannot_help() {
|
||||
// Command position: `$PATH`, not a directory listing.
|
||||
assert!(remote_path_request("ls", 2, "/home/me").is_none());
|
||||
assert!(remote_path_request("", 0, "/home/me").is_none());
|
||||
// ...unless the "command" is itself a path, which is a real listing.
|
||||
assert!(remote_path_request("./scr", 5, "/home/me").is_some());
|
||||
|
||||
// `~` needs the remote's $HOME, which no OSC reports. The remote shell
|
||||
// can expand it; we can't, so we decline and let it have the Tab.
|
||||
assert!(remote_path_request("cat ~/pro", 9, "/home/me").is_none());
|
||||
|
||||
// No absolute cwd to resolve against (the remote shell hasn't reported
|
||||
// one yet, or reported something unusable).
|
||||
assert!(remote_path_request("cat fi", 6, "").is_none());
|
||||
assert!(remote_path_request("cat fi", 6, "relative/dir").is_none());
|
||||
}
|
||||
|
||||
/// A remote listing becomes candidates under exactly the local rules —
|
||||
/// hidden entries stay hidden, the typed directory prefix is preserved, and
|
||||
/// the ordering is the shared closeness sort.
|
||||
#[test]
|
||||
fn remote_path_candidates_mirror_the_local_path_rules() {
|
||||
let entries = |names: &[(&str, bool)]| -> Vec<RemoteEntry> {
|
||||
@@ -1401,25 +1070,19 @@ mod tests {
|
||||
"prefix-matched, shortest first; `.`/`..`/hidden/non-matching dropped"
|
||||
);
|
||||
|
||||
// The directory kind survives, so the menu can mark it and the insert
|
||||
// can add the trailing separator.
|
||||
let cands = remote_path_candidates(&req, &all);
|
||||
assert!(cands.iter().find(|c| c.text == "src").unwrap().is_dir());
|
||||
assert!(!cands.iter().find(|c| c.text == "s").unwrap().is_dir());
|
||||
|
||||
// The typed directory part is re-prepended to every candidate, and the
|
||||
// replacement range covers the whole word.
|
||||
let req = remote_path_request("cat sub/s", 9, "/home/me").unwrap();
|
||||
let c = &remote_path_candidates(&req, &all)[0];
|
||||
assert_eq!(c.text, "sub/s");
|
||||
assert_eq!((c.start, c.end), (4, 9));
|
||||
|
||||
// A dot prefix opts into hidden entries, as it does locally.
|
||||
let req = remote_path_request("cat .h", 6, "/home/me").unwrap();
|
||||
let got = texts(remote_path_candidates(&req, &all));
|
||||
assert_eq!(got, vec![".hidden"]);
|
||||
|
||||
// `cd` drops the files.
|
||||
let req = remote_path_request("cd s", 4, "/home/me").unwrap();
|
||||
let got = texts(remote_path_candidates(&req, &all));
|
||||
assert_eq!(got, vec!["src"]);
|
||||
@@ -1429,20 +1092,16 @@ mod tests {
|
||||
fn no_candidates_returns_none() {
|
||||
let dir = temp_tree("empty", &[("zzz", false)]);
|
||||
assert!(complete("cat q", 5, Some(dir.as_path())).is_none());
|
||||
// A blank line offers nothing (no dump of every command on bare Tab).
|
||||
assert!(complete("", 0, Some(dir.as_path())).is_none());
|
||||
assert!(complete(" ", 3, Some(dir.as_path())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_line_cursor_completes_only_the_word_before_it() {
|
||||
// Caret sits right after "ap" with more text following; the candidate
|
||||
// replaces only `word_start..cursor`, leaving the tail untouched.
|
||||
let dir = temp_tree("midline", &[("apple.txt", false)]);
|
||||
let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap();
|
||||
let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap();
|
||||
assert_eq!((apple.start, apple.end), (4, 6));
|
||||
// Applying it splices over just that range.
|
||||
let (line, cursor) = Replacement {
|
||||
orig: "cat ap x.log".into(),
|
||||
start: apple.start,
|
||||
@@ -1463,25 +1122,19 @@ mod tests {
|
||||
"xb".to_string(),
|
||||
];
|
||||
sort_by_closeness(&mut items);
|
||||
// Shorter first; equal-length ties broken alphabetically.
|
||||
assert_eq!(items, vec!["xa", "xb", "xyz", "xyzzy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dir_handles_empty_absolute_and_relative() {
|
||||
let cwd = Path::new("/work/proj");
|
||||
// Empty dir part → the cwd itself.
|
||||
assert_eq!(resolve_dir("", cwd), PathBuf::from("/work/proj"));
|
||||
// An absolute dir part is taken verbatim.
|
||||
assert_eq!(resolve_dir("/etc/", cwd), PathBuf::from("/etc/"));
|
||||
// A relative dir part is joined onto the cwd.
|
||||
assert_eq!(resolve_dir("src/", cwd), PathBuf::from("/work/proj/src/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dir_expands_tilde_to_home() {
|
||||
// Read the real home (no env mutation, so parallel tests aren't disturbed);
|
||||
// the `~` branches must resolve against it.
|
||||
if let Some(home) = home_dir() {
|
||||
let cwd = Path::new("/work");
|
||||
assert_eq!(resolve_dir("~", cwd), home);
|
||||
|
||||
+10
-613
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,15 @@
|
||||
//! Optional per-frame paint timing. Disabled unless `TTY7_FPS` is set to a
|
||||
//! non-empty, non-`0` value (e.g. `TTY7_FPS=1 cargo run`).
|
||||
//!
|
||||
//! gpui repaints *on demand* — it only paints when something is marked dirty
|
||||
//! via `cx.notify()`. So this deliberately does NOT report a steady 120fps
|
||||
//! while the terminal is idle; idle frames are zero by design, and that's the
|
||||
//! whole point of the architecture. What it measures is:
|
||||
//! - how fast a single paint is on the CPU side (`paint avg/max`), and
|
||||
//! - the frame rate actually achieved during *continuous* output or
|
||||
//! scrolling (e.g. `yes`, `cat bigfile`), which is where "do we hit the
|
||||
//! display's refresh rate?" is a meaningful question.
|
||||
//!
|
||||
//! Note this is the CPU-side cost of building the frame and enqueuing draw
|
||||
//! commands; it does not include GPU execution. For true end-to-end frame
|
||||
//! rate, pair this with Instruments → Core Animation FPS / Metal System Trace.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Whether timing is on. Read once from `TTY7_FPS` and cached.
|
||||
pub fn enabled() -> bool {
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| flag_enables(std::env::var("TTY7_FPS").ok().as_deref()))
|
||||
}
|
||||
|
||||
/// Whether a `TTY7_FPS` value (or its absence) turns timing on: any non-empty
|
||||
/// value except `0`. Split from `enabled` so the semantics are testable without
|
||||
/// depending on the ambient process environment.
|
||||
fn flag_enables(value: Option<&str>) -> bool {
|
||||
value.is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
/// Length of one aggregation window of wall-clock time *in which painting
|
||||
/// happened* (an idle gap just stretches the reported window, so it reads
|
||||
/// honestly rather than as a low frame rate).
|
||||
const WINDOW: Duration = Duration::from_secs(1);
|
||||
|
||||
struct Meter {
|
||||
@@ -52,9 +29,6 @@ impl Meter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one frame in; when `now` crosses the window boundary, return the
|
||||
/// aggregate report line and start a fresh window anchored at `now`. The
|
||||
/// clock is injected so tests can cross windows without sleeping.
|
||||
fn record(&mut self, now: Instant, paint: Duration) -> Option<String> {
|
||||
self.frames += 1;
|
||||
self.paint_total += paint;
|
||||
@@ -82,15 +56,11 @@ fn meter() -> &'static Mutex<Option<Meter>> {
|
||||
M.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Record one frame's CPU-side paint duration. Emits an aggregate stderr line
|
||||
/// roughly once per `WINDOW` of painting time.
|
||||
pub fn record(paint: Duration) {
|
||||
let now = Instant::now();
|
||||
let mut guard = meter().lock().unwrap();
|
||||
let m = guard.get_or_insert_with(|| Meter::new(now));
|
||||
if let Some(line) = m.record(now, paint) {
|
||||
// Direct to stderr: the app never initialises a `log` backend, so
|
||||
// `log::info!` here would be silently dropped.
|
||||
eprintln!("{line}");
|
||||
}
|
||||
}
|
||||
@@ -135,8 +105,6 @@ mod tests {
|
||||
m.record(start + Duration::from_millis(200), Duration::from_millis(6))
|
||||
.is_none()
|
||||
);
|
||||
// Crossing the window boundary flushes the aggregate: 3 frames over
|
||||
// 1.5s = 2.0 fps, paint avg (2+6+4)/3 = 4ms, max 6ms.
|
||||
let flush_at = start + Duration::from_millis(1500);
|
||||
let line = m
|
||||
.record(flush_at, Duration::from_millis(4))
|
||||
@@ -145,7 +113,6 @@ mod tests {
|
||||
line,
|
||||
"[fps] 2.0 fps over 1.50s (3 frames) | paint avg 4.00ms max 6.00ms"
|
||||
);
|
||||
// The flush starts a fresh window anchored at the flush instant.
|
||||
assert_eq!(m.frames, 0);
|
||||
assert_eq!(m.paint_total, Duration::ZERO);
|
||||
assert_eq!(m.paint_max, Duration::ZERO);
|
||||
@@ -154,8 +121,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn meter_flushes_exactly_on_the_window_boundary() {
|
||||
// `elapsed == WINDOW` counts as crossing (the check is `<`), so a frame
|
||||
// landing exactly on the boundary flushes rather than being held over.
|
||||
let start = Instant::now();
|
||||
let mut m = Meter::new(start);
|
||||
let line = m.record(start + WINDOW, Duration::from_millis(1));
|
||||
|
||||
+2
-61
@@ -1,52 +1,16 @@
|
||||
//! Fuzzy subsequence matching for the Ctrl+R history search.
|
||||
//!
|
||||
//! A small affine-gap aligner in the fzf/skim family: every query character
|
||||
//! must appear in the haystack in order (a subsequence), and the returned score
|
||||
//! rewards runs of consecutive matches and matches at word boundaries while
|
||||
//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`.
|
||||
//! The matched character positions come back too, so the menu can highlight
|
||||
//! exactly which characters matched.
|
||||
//!
|
||||
//! Whitespace in the query splits it into terms that must *all* match
|
||||
//! (anywhere, in any order) — `git push` finds `git push -f origin` but also
|
||||
//! `push-all git-mirrors`. Matching is always case-insensitive, like the
|
||||
//! substring search this replaces.
|
||||
//!
|
||||
//! Kept dependency-free on purpose: command lines are short, so the O(m×n)
|
||||
//! dynamic program is comfortably cheap even against thousands of history
|
||||
//! entries per keystroke.
|
||||
|
||||
/// A successful match: the alignment score (higher is better; only comparable
|
||||
/// between matches of the *same query*) and the matched char indices into the
|
||||
/// haystack, ascending and deduplicated.
|
||||
pub(super) struct FuzzyMatch {
|
||||
pub score: i32,
|
||||
pub positions: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Every matched character is worth this much before bonuses.
|
||||
const SCORE_MATCH: i32 = 16;
|
||||
/// Bonus for a match at a word boundary (start of the line, or right after a
|
||||
/// separator) — `st` should land on the `status` in `git status`.
|
||||
const BONUS_BOUNDARY: i32 = 12;
|
||||
/// Bonus for extending a run of consecutive matches — favours tight matches
|
||||
/// over the same letters scattered across the line. Deliberately worth more
|
||||
/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY +
|
||||
/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the
|
||||
/// two word heads of `a-b`.
|
||||
const BONUS_CONSECUTIVE: i32 = 10;
|
||||
/// Cost of opening a gap between two matched characters…
|
||||
const PENALTY_GAP_START: i32 = -3;
|
||||
/// …and of each further character that gap skips.
|
||||
const PENALTY_GAP_EXTEND: i32 = -1;
|
||||
|
||||
/// "Impossible" sentinel. Kept far from `i32::MIN` so adding penalties/bonuses
|
||||
/// to a sentinel value can never wrap around into a plausible score.
|
||||
const NEG: i32 = i32::MIN / 2;
|
||||
|
||||
/// Match `query` against `line`. Whitespace splits the query into terms which
|
||||
/// must all match; scores add up and positions merge. `None` when the query is
|
||||
/// blank or any term fails to match.
|
||||
pub(super) fn match_line(line: &str, query: &str) -> Option<FuzzyMatch> {
|
||||
let terms: Vec<&str> = query.split_whitespace().collect();
|
||||
if terms.is_empty() {
|
||||
@@ -72,14 +36,10 @@ pub(super) fn match_line(line: &str, query: &str) -> Option<FuzzyMatch> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lowercase a char for comparison (first mapping only — `ß`→`ss` expansions
|
||||
/// don't matter for scoring command lines).
|
||||
fn lc(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
/// The word-boundary bonus a match at a position earns, given the preceding
|
||||
/// character (`None` at the start of the line).
|
||||
fn char_bonus(prev: Option<char>) -> i32 {
|
||||
match prev {
|
||||
None => BONUS_BOUNDARY,
|
||||
@@ -92,12 +52,6 @@ fn char_bonus(prev: Option<char>) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Align one lowercased `term` against the lowercased haystack, returning the
|
||||
/// best score and the matched positions. Classic affine-gap DP:
|
||||
/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable
|
||||
/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a
|
||||
/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the
|
||||
/// chosen predecessor for the backtrack that recovers the positions.
|
||||
fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec<usize>)> {
|
||||
let (m, n) = (term.len(), hay_lc.len());
|
||||
if m == 0 || m > n {
|
||||
@@ -112,8 +66,6 @@ fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec
|
||||
}
|
||||
}
|
||||
for i in 1..m {
|
||||
// Best gapped predecessor for the current j: max over k ≤ j-2 of
|
||||
// `score[i-1][k]` plus the affine penalty for the k→j gap.
|
||||
let mut gap_best = NEG;
|
||||
let mut gap_arg = usize::MAX;
|
||||
for j in 0..n {
|
||||
@@ -156,7 +108,6 @@ fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec
|
||||
}
|
||||
}
|
||||
|
||||
// Best end position for the last term char; ties go to the earliest.
|
||||
let (mut best_j, mut best) = (usize::MAX, NEG);
|
||||
for j in 0..n {
|
||||
if score[(m - 1) * n + j] > best {
|
||||
@@ -191,8 +142,8 @@ mod tests {
|
||||
#[test]
|
||||
fn non_subsequence_is_no_match() {
|
||||
assert!(match_line("git status", "xyz").is_none());
|
||||
assert!(match_line("ls", "lss").is_none()); // longer than the line
|
||||
assert!(match_line("git status", "tg").is_none()); // out of order
|
||||
assert!(match_line("ls", "lss").is_none());
|
||||
assert!(match_line("git status", "tg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -209,45 +160,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn consecutive_run_beats_scattered_letters() {
|
||||
// Both contain g,i,t as a subsequence; only one has them adjacent.
|
||||
assert!(score("git log", "git") > score("going to lunch", "git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_boundary_beats_mid_word() {
|
||||
// `st` at the start of "status" (after a space) vs inside "faster".
|
||||
assert!(score("git status", "st") > score("faster", "st"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_pick_the_best_alignment() {
|
||||
// `gs` should land on the `g` of git and the boundary `s` of status,
|
||||
// not some later `s`.
|
||||
assert_eq!(positions("git status", "gs"), vec![0, 4]);
|
||||
// A consecutive alignment is recovered exactly.
|
||||
assert_eq!(positions("cargo build", "build"), vec![6, 7, 8, 9, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_term_queries_must_all_match_and_merge_positions() {
|
||||
// Terms match independently (order-free) and positions merge sorted.
|
||||
let m = match_line("git push --force origin", "push git").unwrap();
|
||||
assert_eq!(m.positions, vec![0, 1, 2, 4, 5, 6, 7]);
|
||||
// One term failing fails the whole query.
|
||||
assert!(match_line("git push", "git nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_are_penalized_by_length() {
|
||||
// Same letters, tighter gap scores higher.
|
||||
assert!(score("ab", "ab") > score("a-b", "ab"));
|
||||
assert!(score("a-b", "ab") > score("a---------b", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_haystacks_match_by_char() {
|
||||
// Positions are char indices, not bytes: the CJK prefix occupies
|
||||
// char cells 0..2, so `ls` lands at 3..=4.
|
||||
assert_eq!(positions("构建 ls", "ls"), vec![3, 4]);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-241
@@ -1,26 +1,3 @@
|
||||
//! Executing Fig *dynamic generators* — the shell scripts a completion spec
|
||||
//! attaches to an argument so its candidates come from the live system rather
|
||||
//! than a static list (`ssh <Tab>` → your known hosts, `git checkout <Tab>` →
|
||||
//! your branches). tty7 parses these scripts out of the specs but, until now,
|
||||
//! never ran them, so those positions fell through to filesystem paths (#51).
|
||||
//!
|
||||
//! The split: the pure [`completion`](super::completion) engine returns the
|
||||
//! script text, the view spawns [`run`] on a background thread, and the stdout
|
||||
//! is turned into candidates and merged into the already-open menu. Three
|
||||
//! concerns live here:
|
||||
//! - **execution** — [`run`]: `/bin/sh -c <script>` in the session's cwd, hard
|
||||
//! wall-clock timeout, child killed on timeout or drop, stdout capped;
|
||||
//! - **parsing** — [`parse`]: a per-script [`registry`] of parsers (a git
|
||||
//! branch listing needs its `* ` marker stripped and detached-HEAD lines
|
||||
//! dropped) over a newline-splitting default;
|
||||
//! - **caching** — a short TTL cache so reopening the same menu doesn't respawn
|
||||
//! a process for a result we just computed.
|
||||
//!
|
||||
//! Everything is deliberately synchronous and blocking: the caller hands `run`
|
||||
//! to the background executor, so blocking a pool thread on a child process is
|
||||
//! fine and keeps the child's lifetime tied to a single stack frame (hence the
|
||||
//! drop-kill guard rather than async cancellation plumbing).
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
@@ -31,39 +8,20 @@ use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wall-clock ceiling on a generator: past this the child is killed and the
|
||||
/// position yields nothing. Generators are meant to be cheap local queries; a
|
||||
/// slow or hung one must never stall the menu.
|
||||
#[cfg(unix)]
|
||||
const TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
/// Ceiling on captured stdout. A runaway generator can't be allowed to buffer
|
||||
/// unbounded output into the UI; past this we keep draining the pipe (so the
|
||||
/// child doesn't block on a full buffer) but discard the overflow.
|
||||
#[cfg(unix)]
|
||||
const MAX_STDOUT: usize = 256 * 1024;
|
||||
|
||||
/// How long a parsed result stays fresh in the cache. Reopening a menu (Tab,
|
||||
/// close, Tab again) or re-triggering the same generator within this window
|
||||
/// reuses the result instead of respawning the process.
|
||||
const CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A candidate produced by a generator: the replacement text plus an optional
|
||||
/// one-line description for the menu's second column.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Parsed {
|
||||
pub text: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Run `script` (already joined to a `/bin/sh -c` command string) with `cwd` as
|
||||
/// the working directory and return its parsed candidates. A cache hit for the
|
||||
/// same `(script, cwd)` within [`CACHE_TTL`] skips the process entirely.
|
||||
///
|
||||
/// Blocking; meant to be handed to the app's background executor. A non-zero
|
||||
/// exit, a timeout, or a spawn failure all yield an empty vec (at most a
|
||||
/// `log::debug`) — a broken generator degrades to "no dynamic suggestions", never
|
||||
/// an error surfaced to the user.
|
||||
pub fn run(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
if let Some(hit) = cache_get(script, cwd) {
|
||||
return hit;
|
||||
@@ -73,24 +31,12 @@ pub fn run(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Kill-on-drop wrapper: whatever path leaves [`run_uncached`] — normal return,
|
||||
/// timeout, or an unwind — the child is signalled and reaped rather than leaked
|
||||
/// as a zombie holding the pipe open.
|
||||
///
|
||||
/// The kill targets the child's *process group*, not just the child: `sh -c`
|
||||
/// may fork the command rather than exec it (dash does), and killing only the
|
||||
/// shell would leave a grandchild holding the stdout pipe open — the reader
|
||||
/// thread would then block until the grandchild exits on its own, defeating
|
||||
/// the timeout. The child is spawned as its own group leader (see
|
||||
/// [`run_uncached`]), so `killpg(pid)` takes the whole tree down and the pipe
|
||||
/// closes immediately.
|
||||
#[cfg(unix)]
|
||||
struct Reaped(std::process::Child);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Reaped {
|
||||
fn kill_group(&mut self) {
|
||||
// The child was made leader of a group whose pgid == its pid.
|
||||
unsafe { libc::killpg(self.0.id() as libc::pid_t, libc::SIGKILL) };
|
||||
}
|
||||
}
|
||||
@@ -103,10 +49,6 @@ impl Drop for Reaped {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generator scripts are POSIX `sh` + awk pipelines; there is nothing to run
|
||||
/// them with on Windows, so the whole execution path compiles away to "no
|
||||
/// dynamic suggestions" there. (Windows would need its own spec corpus with
|
||||
/// PowerShell scripts — a separate effort, not a porting gap here.)
|
||||
#[cfg(not(unix))]
|
||||
fn run_uncached(_script: &str, _cwd: &Path) -> Vec<Parsed> {
|
||||
Vec::new()
|
||||
@@ -119,8 +61,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.current_dir(cwd)
|
||||
// Own process group (pgid == child pid), so the timeout can kill the
|
||||
// shell *and* anything it forked in one killpg — see [`Reaped`].
|
||||
.process_group(0)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -134,8 +74,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
}
|
||||
};
|
||||
|
||||
// Drain stdout on a helper thread so a chatty generator can't wedge on a full
|
||||
// pipe while we poll for exit, and so the read is bounded to `MAX_STDOUT`.
|
||||
let stdout = child.0.stdout.take();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
@@ -149,8 +87,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
let room = MAX_STDOUT - buf.len();
|
||||
buf.extend_from_slice(&chunk[..n.min(room)]);
|
||||
}
|
||||
// Past the cap we keep reading but discard, so the child
|
||||
// isn't blocked writing into a full pipe.
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
@@ -159,7 +95,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
buf
|
||||
});
|
||||
|
||||
// Poll for exit against the wall clock; kill past the deadline.
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
match child.0.try_wait() {
|
||||
@@ -174,7 +109,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
Err(_) => break None,
|
||||
}
|
||||
};
|
||||
// Killing closes the pipe, so the reader thread always finishes.
|
||||
let buf = reader.join().unwrap_or_default();
|
||||
|
||||
match status {
|
||||
@@ -190,10 +124,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a generator's raw stdout into candidates. A [`registry`] parser keyed by
|
||||
/// the exact joined `script` string wins when present; otherwise the default
|
||||
/// splits on newlines. Kept separate from [`run`] so parsing is unit-testable
|
||||
/// without spawning a process.
|
||||
pub fn parse(script: &str, stdout: &str) -> Vec<Parsed> {
|
||||
match registry(script) {
|
||||
Some(parser) => parser(stdout),
|
||||
@@ -201,9 +131,6 @@ pub fn parse(script: &str, stdout: &str) -> Vec<Parsed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fallback parser: one candidate per non-empty line, trailing whitespace
|
||||
/// trimmed, no description. This is the behavior the converter's docs promise for
|
||||
/// any generator without a bespoke `postProcess` (which we drop at conversion).
|
||||
fn default_parse(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -216,38 +143,13 @@ fn default_parse(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A parser for one generator's output.
|
||||
type Parser = fn(&str) -> Vec<Parsed>;
|
||||
|
||||
/// The bespoke-parser table: exact joined `script` string → parser. These are the
|
||||
/// hand-ports of the Fig specs' JS `postProcess` functions, which the converter
|
||||
/// drops — without them a generator whose stdout isn't already one-clean-token-
|
||||
/// per-line falls to [`default_parse`] and pastes garbage (a JSON blob, a `NAME`
|
||||
/// header, a two-column table) onto the command line.
|
||||
///
|
||||
/// Keyed by the *exact* string produced by joining a spec's `script` token array
|
||||
/// with single spaces, so a spec regeneration that changes a script string
|
||||
/// silently orphans its parser — [`tests::every_registry_key_exists_in_corpus`]
|
||||
/// walks the shipped specs and fails loudly if a key here no longer appears.
|
||||
///
|
||||
/// Three shapes of decision, per the guiding principle "text is exactly the token
|
||||
/// that belongs at this position, description is optional context":
|
||||
/// - clean output → **no entry** (the default is already correct);
|
||||
/// - a cheap line/JSON transform → a `parse_*` port;
|
||||
/// - hopelessly noisy output → [`parse_suppress`] (empty vec — bad candidates
|
||||
/// are worse than none).
|
||||
///
|
||||
/// A linear scan is fine: `parse` runs once per menu-open, and the table is tens
|
||||
/// of entries.
|
||||
#[rustfmt::skip]
|
||||
const REGISTRY: &[(&str, Parser)] = &[
|
||||
// --- ssh / scp / sftp / rsync -------------------------------------------
|
||||
// Both host scripts already print one host per line; the only value we add is
|
||||
// the second-column label. (Shared verbatim across ssh/scp/sftp/rsync.)
|
||||
("awk '/^[|#@]/{next}{n=split($1,a,\",\");for(i=1;i<=n;i++){h=a[i];sub(/^\\[/,\"\",h);sub(/\\]:[0-9]+$/,\"\",h);sub(/\\]$/,\"\",h);print h}}' ~/.ssh/known_hosts 2>/dev/null | sort -u", parse_ssh_host),
|
||||
("cat ~/.ssh/config $(awk 'tolower($1)==\"include\"{for(i=2;i<=NF;i++){p=$i;if(p ~ /^~\\//){sub(/^~/,ENVIRON[\"HOME\"],p)}else if(p !~ /^\\//){p=ENVIRON[\"HOME\"]\"/.ssh/\"p}print p}}' ~/.ssh/config 2>/dev/null) 2>/dev/null | awk 'tolower($1)==\"host\"{for(i=2;i<=NF;i++){if($i !~ /[*?!]/)print $i}}' | sort -u", parse_ssh_host),
|
||||
|
||||
// --- git ----------------------------------------------------------------
|
||||
("git --no-optional-locks branch --no-color --sort=-committerdate", parse_git_branch),
|
||||
("git branch --no-color", parse_git_branch),
|
||||
("git --no-optional-locks branch -a --no-color --sort=-committerdate", parse_git_branch_all),
|
||||
@@ -259,49 +161,25 @@ const REGISTRY: &[(&str, Parser)] = &[
|
||||
("git --no-optional-locks log --oneline", parse_oneline),
|
||||
("git rev-list --all --oneline", parse_oneline),
|
||||
("git config --get-regexp .*", parse_git_config),
|
||||
// `tag --list` and `diff --cached --name-only` are one clean token per line →
|
||||
// no entry (default is correct).
|
||||
|
||||
// --- npm / pnpm / bun / yarn --------------------------------------------
|
||||
// The same `cat package.json` script backs both `run <script>` positions and
|
||||
// several package-name positions (pnpm/yarn), but the registry keys on the
|
||||
// script string alone and can't see the arg context. We parse `.scripts` —
|
||||
// the flagship `npm run <Tab>` case; in a package-name position it's a lossy
|
||||
// but never-garbage answer (a raw-JSON default would be pure garbage there).
|
||||
("bash -c until [[ -f package.json ]] || [[ $PWD = '/' ]]; do cd ..; done; cat package.json", parse_package_scripts),
|
||||
// turbo `run <Tab>`: task names live under `tasks` (v2) or `pipeline` (v1).
|
||||
("bash -c until [[ ( -f turbo.json || $PWD = '/' ) ]]; do cd ..; done; cat turbo.json", parse_turbo_tasks),
|
||||
// yarn/pnpm dependency listings are trees/JSON with legends and headers —
|
||||
// nothing a line parse can salvage.
|
||||
("yarn list --depth=0 --json", parse_suppress),
|
||||
("yarn config list", parse_suppress),
|
||||
("pnpm ls", parse_suppress),
|
||||
|
||||
// --- cargo --------------------------------------------------------------
|
||||
// `cargo metadata` is one giant JSON doc; pull workspace/dep package names.
|
||||
("cargo metadata --format-version 1 --no-deps", parse_cargo_packages),
|
||||
("cargo metadata --format-version 1", parse_cargo_packages),
|
||||
// `read-manifest` feeds a `--features` position: the `.features` map keys.
|
||||
("cargo read-manifest", parse_cargo_features),
|
||||
// `rustc --print target-list` and the `cargo install --list | …` pipe are
|
||||
// already one token per line → no entry.
|
||||
|
||||
// --- rustup -------------------------------------------------------------
|
||||
("rustup toolchain list", parse_rustup_toolchain),
|
||||
("rustup target list", parse_rustup_target),
|
||||
// The GitHub-releases JSON (curl/gh fallback) is an object array we can't turn
|
||||
// into clean version tokens by line-parsing.
|
||||
("bash -c if command -v gh > /dev/null; then gh api -H \"Accept: application/vnd.github+json\" /repos/rust-lang/rust/releases; else curl -sfL -H \"Accept: application/vnd.github+json\" https://api.github.com/repos/rust-lang/rust/releases; fi", parse_suppress),
|
||||
|
||||
// --- gh -----------------------------------------------------------------
|
||||
("gh alias list", parse_colon_kv),
|
||||
("gh pr list --json=number,title,headRefName,state", parse_gh_pr),
|
||||
("gh api graphql --paginate -f query='query($endCursor: String) { viewer { repositories(first: 100, after: $endCursor) { nodes { isPrivate, nameWithOwner, description } pageInfo { hasNextPage endCursor }}}}' --jq .data.viewer.repositories.nodes[]", parse_gh_repos),
|
||||
|
||||
// --- docker / podman ----------------------------------------------------
|
||||
// `--format '{{ json . }}'` prints one JSON object per line; pull the field
|
||||
// that names the object. Absent field → the line is skipped (self-suppressing
|
||||
// if a template's shape ever surprises us).
|
||||
("docker ps --format {{ json . }}", parse_docker_names),
|
||||
("docker ps -a --format {{ json . }}", parse_docker_names),
|
||||
("docker ps --filter status=paused --format {{ json . }}", parse_docker_names),
|
||||
@@ -327,47 +205,30 @@ const REGISTRY: &[(&str, Parser)] = &[
|
||||
("podman images -a --format {{ json . }}", parse_docker_image_json),
|
||||
("podman images --format {{.Repository}} {{.Size}} {{.Tag}} {{.ID}}", parse_docker_image_cols),
|
||||
|
||||
// --- kubectl / k9s ------------------------------------------------------
|
||||
// `get namespaces` prints a `NAME STATUS AGE` table (k9s uses it); take the
|
||||
// first column, drop the header. (`-o name` / `-o custom-columns=:…` variants
|
||||
// are already clean → no entry.)
|
||||
("kubectl get namespaces", parse_kube_table),
|
||||
|
||||
// --- tmux ---------------------------------------------------------------
|
||||
// Every `tmux ls*` line is `<target>: <details>`; the target before the colon
|
||||
// is the token, the rest is context.
|
||||
("tmux ls", parse_colon_kv),
|
||||
("tmux lsb", parse_colon_kv),
|
||||
("tmux lsc", parse_colon_kv),
|
||||
("tmux lsp", parse_colon_kv),
|
||||
("tmux lsw", parse_colon_kv),
|
||||
|
||||
// --- misc package managers (item-8 sweep) -------------------------------
|
||||
("apt list --installed", parse_apt),
|
||||
("apt list --upgradable", parse_apt),
|
||||
("pip list", parse_pip), // both the `pip` and `pip3` specs key this exact string.
|
||||
("pip list", parse_pip),
|
||||
("conda list", parse_conda_pkg),
|
||||
("conda env list", parse_conda_env),
|
||||
("conda config --show", parse_suppress), // YAML-ish key/value + nested lists.
|
||||
("conda config --show", parse_suppress),
|
||||
("terraform workspace list", parse_terraform_workspace),
|
||||
];
|
||||
|
||||
/// Script keys deliberately *not* expected to appear verbatim in the shipped
|
||||
/// corpus — parsers we key for a synthetic or hand-authored command string. The
|
||||
/// corpus-membership test skips these. Empty today: every registry key is drawn
|
||||
/// straight from the corpus, so this exists only to give a regeneration an
|
||||
/// escape hatch instead of a hard failure.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
const SYNTHETIC_KEYS: &[&str] = &[];
|
||||
|
||||
/// Look up the bespoke parser for a script by its exact joined command string.
|
||||
/// A miss falls to [`default_parse`].
|
||||
fn registry(script: &str) -> Option<Parser> {
|
||||
REGISTRY.iter().find(|(k, _)| *k == script).map(|(_, p)| *p)
|
||||
}
|
||||
|
||||
/// Marker-strip for `git branch`-style listings: a `* ` (current) / `+ `
|
||||
/// (worktree) marker or the two-space indent, trailing whitespace trimmed.
|
||||
fn strip_branch_marker(line: &str) -> &str {
|
||||
line.strip_prefix("* ")
|
||||
.or_else(|| line.strip_prefix("+ "))
|
||||
@@ -376,8 +237,6 @@ fn strip_branch_marker(line: &str) -> &str {
|
||||
.trim_end()
|
||||
}
|
||||
|
||||
/// The two SSH host scripts already emit one host per line; label them so the
|
||||
/// menu's second column reads "SSH Host" instead of nothing.
|
||||
fn parse_ssh_host(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -390,8 +249,6 @@ fn parse_ssh_host(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git branch`: strip the marker, drop the `(HEAD detached …)` pseudo-entry
|
||||
/// (not a checkout target), label the rest "branch".
|
||||
fn parse_git_branch(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -408,11 +265,6 @@ fn parse_git_branch(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git branch -a`: local branches plus `remotes/<remote>/<branch>` lines. We
|
||||
/// strip the `remotes/` prefix so a remote entry reads `origin/main` — the form
|
||||
/// `git checkout` accepts (DWIM to a tracking branch, or a valid detached
|
||||
/// checkout) — and drop the `remotes/origin/HEAD -> origin/main` alias line
|
||||
/// (the ` -> ` marks it as a symref, not its own checkout target).
|
||||
fn parse_git_branch_all(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -433,9 +285,6 @@ fn parse_git_branch_all(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git branch -r`: remote-tracking refs (`origin/main`), already without the
|
||||
/// `remotes/` prefix. Just drop the indent and the `origin/HEAD -> origin/main`
|
||||
/// symref alias.
|
||||
fn parse_git_branch_remote(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -452,10 +301,6 @@ fn parse_git_branch_remote(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git status --short`: each line is a two-char `XY` status, a space, then the
|
||||
/// path (from column 3). A rename is `R old -> new`; the *new* path is the one
|
||||
/// that exists on disk, so we take the right side of ` -> `. The status code
|
||||
/// becomes the description.
|
||||
fn parse_git_status(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -480,8 +325,6 @@ fn parse_git_status(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git remote -v`: `<name>\t<url> (fetch|push)`. Every remote appears twice
|
||||
/// (a fetch and a push line); dedupe on the name, keep the URL as description.
|
||||
fn parse_git_remote(stdout: &str) -> Vec<Parsed> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
stdout
|
||||
@@ -501,9 +344,6 @@ fn parse_git_remote(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git config --get-regexp ^alias.`: `alias.<name> <expansion>`. Strip the
|
||||
/// `alias.` prefix to leave the token you'd type after `git`, keep the expansion
|
||||
/// as description.
|
||||
fn parse_git_alias(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -521,8 +361,6 @@ fn parse_git_alias(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git config --get-regexp .*`: `<key> <value>`. The key is the token; the
|
||||
/// value (which may itself contain spaces, or be empty) is the description.
|
||||
fn parse_git_config(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -543,8 +381,6 @@ fn parse_git_config(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git log/rev-list --oneline`: `<short-hash> <subject>`. Hash is the token,
|
||||
/// subject is the description.
|
||||
fn parse_oneline(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -561,9 +397,6 @@ fn parse_oneline(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `<key>: <rest>` line format, shared by everything whose token is the text
|
||||
/// before the first colon: `git stash list` (`stash@{0}: WIP …`), every
|
||||
/// `tmux ls*` (`<target>: <details>`), and `gh alias list` (`co: pr checkout`).
|
||||
fn parse_colon_kv(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -582,8 +415,6 @@ fn parse_colon_kv(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `cat package.json` → the `.scripts` object: keys are `npm run` targets, the
|
||||
/// command line each maps to is the description.
|
||||
fn parse_package_scripts(stdout: &str) -> Vec<Parsed> {
|
||||
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
|
||||
return Vec::new();
|
||||
@@ -600,8 +431,6 @@ fn parse_package_scripts(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `cat turbo.json` → task names: `tasks` (turbo ≥2) or `pipeline` (turbo 1).
|
||||
/// (JSONC comments would fail the strict parse and yield nothing — acceptable.)
|
||||
fn parse_turbo_tasks(stdout: &str) -> Vec<Parsed> {
|
||||
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
|
||||
return Vec::new();
|
||||
@@ -622,8 +451,6 @@ fn parse_turbo_tasks(stdout: &str) -> Vec<Parsed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `cargo metadata` → `.packages[].name`, deduped, version as description. With
|
||||
/// `--no-deps` this is the workspace members; without, every resolved dependency.
|
||||
fn parse_cargo_packages(stdout: &str) -> Vec<Parsed> {
|
||||
let Ok(root) = serde_json::from_str::<Value>(stdout) else {
|
||||
return Vec::new();
|
||||
@@ -647,8 +474,6 @@ fn parse_cargo_packages(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `cargo read-manifest` → the `.features` map keys (feature names for a
|
||||
/// `--features` position).
|
||||
fn parse_cargo_features(stdout: &str) -> Vec<Parsed> {
|
||||
let Ok(root) = serde_json::from_str::<Value>(stdout) else {
|
||||
return Vec::new();
|
||||
@@ -665,8 +490,6 @@ fn parse_cargo_features(stdout: &str) -> Vec<Parsed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `gh pr list --json=…` → a JSON array of PRs. The number is the canonical
|
||||
/// `gh pr <number>` token; the title is context.
|
||||
fn parse_gh_pr(stdout: &str) -> Vec<Parsed> {
|
||||
let Ok(Value::Array(prs)) = serde_json::from_str::<Value>(stdout) else {
|
||||
return Vec::new();
|
||||
@@ -683,8 +506,6 @@ fn parse_gh_pr(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `gh api graphql … --jq …nodes[]` → one repo object per line; the token is
|
||||
/// `nameWithOwner`, the description its blurb.
|
||||
fn parse_gh_repos(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -702,7 +523,6 @@ fn parse_gh_repos(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// First present, non-empty string field among `keys` in a JSON object.
|
||||
fn json_field<'a>(obj: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
|
||||
keys.iter().find_map(|k| match obj.get(*k) {
|
||||
Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()),
|
||||
@@ -710,10 +530,6 @@ fn json_field<'a>(obj: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Opt
|
||||
})
|
||||
}
|
||||
|
||||
/// `docker/podman … --format '{{ json . }}'` for containers, networks, volumes,
|
||||
/// nodes, secrets, services, stacks, plugins, contexts: one JSON object per line
|
||||
/// named by `Names` (ps) or `Name` (everything else). Lines without either field
|
||||
/// are skipped (self-suppressing).
|
||||
fn parse_docker_names(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -730,8 +546,6 @@ fn parse_docker_names(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `docker/podman image ls --format '{{ json . }}'`: `Repository[:Tag]` is the
|
||||
/// token, image `ID` the description. A dangling `<none>` repository is dropped.
|
||||
fn parse_docker_image_json(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -754,9 +568,6 @@ fn parse_docker_image_json(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `docker/podman images --format '{{.Repository}} {{.Size}} {{.Tag}} {{.ID}}'`:
|
||||
/// space-positional, so `Repository[:Tag]` is the token and the `ID` the
|
||||
/// description. A `<none>` repository (dangling image) is dropped.
|
||||
fn parse_docker_image_cols(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -778,8 +589,6 @@ fn parse_docker_image_cols(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `kubectl get namespaces` (used by k9s): a `NAME STATUS AGE` table. Take the
|
||||
/// first column, skip the header row.
|
||||
fn parse_kube_table(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -796,8 +605,6 @@ fn parse_kube_table(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rustup toolchain list`: `<toolchain> (active, default)` — the name is the
|
||||
/// first token, the parenthetical (if any) the description.
|
||||
fn parse_rustup_toolchain(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -819,8 +626,6 @@ fn parse_rustup_toolchain(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rustup target list`: `<triple> (installed)` — the triple is the token; note
|
||||
/// whether it's already installed.
|
||||
fn parse_rustup_target(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -839,8 +644,6 @@ fn parse_rustup_target(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `apt list …`: `<pkg>/<repo>,… <version> <arch> [flags]`, plus a leading
|
||||
/// `Listing…` note. Take the package name before the `/`, version as context.
|
||||
fn parse_apt(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -860,8 +663,6 @@ fn parse_apt(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `pip list`: a `Package Version …` table. Skip the header and its `----`
|
||||
/// underline; first column is the token, version the description.
|
||||
fn parse_pip(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -879,8 +680,6 @@ fn parse_pip(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `conda list`: `# …` comment headers then `<name> <version> <build> <channel>`.
|
||||
/// First column is the token, version the description.
|
||||
fn parse_conda_pkg(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -898,8 +697,6 @@ fn parse_conda_pkg(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `conda env list`: `# …` headers then `<name> [*] <path>` (the `*` marks the
|
||||
/// active env). First column is the env name, its path the description.
|
||||
fn parse_conda_env(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -917,8 +714,6 @@ fn parse_conda_env(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `terraform workspace list`: `* default` / ` prod` — strip the active marker
|
||||
/// and indent.
|
||||
fn parse_terraform_workspace(stdout: &str) -> Vec<Parsed> {
|
||||
stdout
|
||||
.lines()
|
||||
@@ -935,16 +730,10 @@ fn parse_terraform_workspace(stdout: &str) -> Vec<Parsed> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A deliberate no-op for scripts whose real output is hopelessly noisy for
|
||||
/// line-parsing (JSON trees, YAML dumps, legend-prefixed listings): returning
|
||||
/// nothing is better than pasting garbage onto the command line.
|
||||
fn parse_suppress(_stdout: &str) -> Vec<Parsed> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The TTL cache: `(script, cwd)` → its parsed results and when they were
|
||||
/// computed. Keeps reopening a menu from respawning a process for a result we
|
||||
/// just produced; entries are pruned lazily on lookup once past [`CACHE_TTL`].
|
||||
type Cache = Mutex<HashMap<(String, PathBuf), (Instant, Vec<Parsed>)>>;
|
||||
|
||||
fn cache() -> &'static Cache {
|
||||
@@ -980,7 +769,6 @@ mod tests {
|
||||
fn default_parser_splits_lines_trims_and_skips_blanks() {
|
||||
let out = parse("echo whatever", "alpha\nbeta \n\n gamma\n");
|
||||
let texts: Vec<&str> = out.iter().map(|p| p.text.as_str()).collect();
|
||||
// Trailing whitespace trimmed, leading kept, empty lines dropped.
|
||||
assert_eq!(texts, vec!["alpha", "beta", " gamma"]);
|
||||
assert!(out.iter().all(|p| p.description.is_none()));
|
||||
}
|
||||
@@ -1000,34 +788,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The execution tests spawn real `/bin/sh` children, so they are Unix-only —
|
||||
// matching `run_uncached`, which compiles to "no results" everywhere else.
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_captures_stdout_lines() {
|
||||
// A unique cwd so this never collides with a cached entry from a sibling
|
||||
// test; the script itself ignores cwd.
|
||||
let cwd = std::env::temp_dir();
|
||||
let out = run("printf 'a\\nb\\n'", &cwd);
|
||||
let texts: Vec<&str> = out.iter().map(|p| p.text.as_str()).collect();
|
||||
assert_eq!(texts, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
/// `sh -c` may *fork* the command instead of exec'ing it (dash does), so this
|
||||
/// also proves the group-kill takes the grandchild down: were only the shell
|
||||
/// killed, the grandchild's open pipe would hold the reader (and us) for the
|
||||
/// full five seconds.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_times_out_and_kills_the_child() {
|
||||
let cwd = std::env::temp_dir();
|
||||
let start = Instant::now();
|
||||
// The trailing `true` stops the shell exec-optimizing the single command
|
||||
// away, so `sleep` is always a *forked* grandchild.
|
||||
let out = run("sleep 5; true", &cwd);
|
||||
// Timed out → no results, and we returned near the deadline rather than
|
||||
// waiting the full five seconds (the child was killed).
|
||||
assert!(out.is_empty());
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(3),
|
||||
@@ -1043,12 +818,6 @@ mod tests {
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
// --- registry integrity -------------------------------------------------
|
||||
|
||||
/// Walk the shipped specs the same way the enumeration script does and assert
|
||||
/// every registry key (bar explicitly-synthetic ones) still appears verbatim,
|
||||
/// so a spec regeneration that renames a `script` fails here loudly instead of
|
||||
/// silently orphaning a parser.
|
||||
#[test]
|
||||
fn every_registry_key_exists_in_corpus() {
|
||||
use std::collections::HashSet;
|
||||
@@ -1123,9 +892,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-parser ports ---------------------------------------------------
|
||||
|
||||
/// Small helper: `(text, description)` pairs, so assertions read as tables.
|
||||
fn pairs(v: &[Parsed]) -> Vec<(&str, Option<&str>)> {
|
||||
v.iter()
|
||||
.map(|p| (p.text.as_str(), p.description.as_deref()))
|
||||
@@ -1236,18 +1002,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn colon_kv_serves_stash_tmux_and_gh_alias() {
|
||||
// stash: token before the FIRST colon, remainder (itself colon-bearing) is
|
||||
// context.
|
||||
assert_eq!(
|
||||
pairs(&parse_colon_kv("stash@{0}: WIP on main: hello\n")),
|
||||
vec![("stash@{0}", Some("WIP on main: hello"))]
|
||||
);
|
||||
// tmux ls
|
||||
assert_eq!(
|
||||
pairs(&parse_colon_kv("main: 3 windows (created ...)\n")),
|
||||
vec![("main", Some("3 windows (created ...)"))]
|
||||
);
|
||||
// gh alias list
|
||||
assert_eq!(
|
||||
pairs(&parse_colon_kv("co: pr checkout\n")),
|
||||
vec![("co", Some("pr checkout"))]
|
||||
@@ -1262,7 +1024,6 @@ mod tests {
|
||||
let mut got = pairs(&scripts);
|
||||
got.sort();
|
||||
assert_eq!(got, vec![("build", Some("tsc")), ("test", Some("jest"))]);
|
||||
// Not JSON → nothing, rather than pasting the raw bytes.
|
||||
assert!(parse_package_scripts("not json").is_empty());
|
||||
}
|
||||
|
||||
|
||||
+2
-327
@@ -1,190 +1,51 @@
|
||||
//! The full working-tree diff behind the sidebar's `+N −N` counts: `git diff
|
||||
//! HEAD` parsed into files → hunks → lines, for the read-only diff overlay
|
||||
//! (see [`crate::ui::diff_overlay`], which owns how it is opened) that covers
|
||||
//! the terminal.
|
||||
//!
|
||||
//! Same discipline as [`git_status`](crate::terminal::git_status): every
|
||||
//! invocation goes through the shared [`git_status::git`] helper — so it runs
|
||||
//! on the pane's own [`Host`], read-only via `GIT_OPTIONAL_LOCKS=0` — on a
|
||||
//! background executor, and is never trusted to be fast; the UI shows the
|
||||
//! previous snapshot (or a loading state) until a probe lands. Asking the pane's
|
||||
//! host rather than this machine is also what makes the overlay work at all for
|
||||
//! a pane whose repository lives somewhere else.
|
||||
//!
|
||||
//! And never trusted to be *small*, either. `git diff HEAD` is the one git read
|
||||
//! in this app whose output scales with the working tree rather than with what
|
||||
//! the UI can show, so two things bound it: the read is incremental
|
||||
//! ([`Host::git_lines`](crate::ui::host_ops::Host::git_lines)) rather than
|
||||
//! buffered whole, and the parser retains at most [`MAX_LINES_PER_FILE`] per
|
||||
//! file and [`MAX_TOTAL_LINES`] / [`MAX_FILES_WITH_HUNKS`] across the
|
||||
//! repository. The `+`/`−` counts deliberately escape all of it: they are
|
||||
//! compared against `git diff --numstat` to decide whether the overlay is
|
||||
//! stale, so a capped total would disagree forever and re-probe in a loop.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::terminal::git_status;
|
||||
use crate::ui::host_ops::Host;
|
||||
|
||||
/// Cap on parsed diff lines per file. A generated lockfile or vendored blob
|
||||
/// can be tens of thousands of lines; past this the file's hunks stop and the
|
||||
/// overlay shows a "truncated" notice instead of building a giant element
|
||||
/// tree. Generous enough that real hand-written changes never hit it.
|
||||
pub const MAX_LINES_PER_FILE: usize = 2000;
|
||||
|
||||
/// Repo-wide cap on retained diff lines. [`MAX_LINES_PER_FILE`] bounds one
|
||||
/// pathological file; this bounds the *sum*, which is the shape a working tree
|
||||
/// full of agent edits actually takes — two hundred files of three hundred
|
||||
/// lines each never trip the per-file cap yet retain 60k `DiffLine`s, each an
|
||||
/// owned `String`. Past this the parser keeps counting `+`/`−` (the header
|
||||
/// numbers must stay honest — see the note in [`parse_unified`]) but stops
|
||||
/// retaining line text.
|
||||
pub const MAX_TOTAL_LINES: usize = 20_000;
|
||||
|
||||
/// Repo-wide cap on how many files keep their hunks. A branch that renames a
|
||||
/// vendored tree can list thousands of files whose diffs are each tiny; every
|
||||
/// one of them still costs a `Vec<Hunk>`. Files past this keep their header row
|
||||
/// (path, status, counts) and lose only the body.
|
||||
pub const MAX_FILES_WITH_HUNKS: usize = 500;
|
||||
|
||||
/// A file's added+removed size at which the overlay collapses it by default
|
||||
/// (GitHub's "Load diff" treatment) — the user can still expand it by click.
|
||||
pub const AUTO_COLLAPSE_LINES: u32 = 400;
|
||||
|
||||
/// Repo-wide counterpart to [`AUTO_COLLAPSE_LINES`]: once the snapshot's
|
||||
/// *retained* lines exceed this, every file starts collapsed and the overlay
|
||||
/// leads with the oversized-diff summary. Two differences from the per-file
|
||||
/// threshold matter here — this counts context lines too (they are rendered,
|
||||
/// so they are what costs), and it is a sum, so many medium files add up the
|
||||
/// way one big file does.
|
||||
///
|
||||
/// Counting context is also why this sits well above the row count that first
|
||||
/// looks alarming. A hunk carries three lines of context each side by default,
|
||||
/// so an ordinary afternoon — forty files, a handful of small hunks each —
|
||||
/// retains four to six lines for every line it actually changed: at 2000 the
|
||||
/// threshold fired on a tree whose `+N −N` read about 400, which is nobody's
|
||||
/// idea of a diff too big to open. The number to compare against is
|
||||
/// [`MAX_TOTAL_LINES`], the point past which the parser stops retaining at all;
|
||||
/// this is deliberately a large fraction of it, because collapsing everything is
|
||||
/// the heavier intervention of the two and should not arrive first by much.
|
||||
pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000;
|
||||
|
||||
/// The same idea by file count. Set well clear of a busy-but-ordinary tree —
|
||||
/// forty changed files of a few lines each is a normal afternoon and must still
|
||||
/// open expanded — because rows, not cards, are what actually cost: this axis
|
||||
/// only catches the tree so wide that a card per file is itself the problem.
|
||||
pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100;
|
||||
|
||||
/// Hard ceiling on file cards the overlay builds at all. Past this the list is
|
||||
/// cut and a "… and N more" line stands in for the tail, so the element tree
|
||||
/// stays bounded no matter what the working tree looks like. Also bounds the
|
||||
/// untracked section, which is the same one-row-per-path shape.
|
||||
pub const MAX_RENDERED_FILES: usize = 300;
|
||||
|
||||
/// Repo-wide cap on retained untracked paths. `git ls-files --others` answers
|
||||
/// with the whole tree of anything not yet ignored — a fresh clone before
|
||||
/// `node_modules` / `target` / `.venv` reach `.gitignore` reports tens of
|
||||
/// thousands of paths — and every one of them is an owned `String` here and a
|
||||
/// row in the overlay. The count stays exact past the cap
|
||||
/// ([`DiffSnapshot::untracked_total`]); only the retained list is bounded.
|
||||
pub const MAX_UNTRACKED: usize = 500;
|
||||
|
||||
/// Why a file's hunks stop short of its real diff.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Truncation {
|
||||
/// This one file exceeded [`MAX_LINES_PER_FILE`].
|
||||
PerFile,
|
||||
/// The repo-wide budget ([`MAX_TOTAL_LINES`] / [`MAX_FILES_WITH_HUNKS`])
|
||||
/// ran out — the file itself may be small.
|
||||
Budget,
|
||||
}
|
||||
|
||||
/// One parsed `git diff HEAD` for a repo, plus the untracked files `diff`
|
||||
/// itself can't see. This is the overlay's whole model.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct DiffSnapshot {
|
||||
/// The work-tree root the diff was taken in.
|
||||
pub root: PathBuf,
|
||||
/// Branch name (or short sha when detached) — the overlay's title.
|
||||
pub branch: String,
|
||||
/// Changed tracked files, in `git diff` order.
|
||||
pub files: Vec<FileDiff>,
|
||||
/// Untracked (new, un-added) paths, repo-relative. Listed by name only:
|
||||
/// `git diff HEAD` has no blob to diff them against, and agents create
|
||||
/// files constantly — hiding them would make the overlay look like it
|
||||
/// lost work.
|
||||
///
|
||||
/// Capped at [`MAX_UNTRACKED`]; [`untracked_count`](Self::untracked_count)
|
||||
/// is the honest total.
|
||||
pub untracked: Vec<String>,
|
||||
/// How many untracked paths `git ls-files --others` actually reported,
|
||||
/// which is not `untracked.len()` once the cap bites. Same discipline as
|
||||
/// [`totals`](Self::totals): what we *retain* is budgeted, what we *report*
|
||||
/// stays exact, because a count that shrank with the budget would read as
|
||||
/// files having disappeared. Read it through
|
||||
/// [`untracked_count`](Self::untracked_count).
|
||||
pub untracked_total: usize,
|
||||
/// One of the two reads behind this snapshot did not complete, so the
|
||||
/// emptiness below it means "we could not look", not "there is nothing".
|
||||
///
|
||||
/// A failed probe still produces a snapshot — the overlay keeps its branch
|
||||
/// and its shape, and the next refresh fills it in, which is better than
|
||||
/// blanking. But an empty file list renders as *Working tree clean*, and
|
||||
/// that sentence is a claim about the repository: saying it because a read
|
||||
/// timed out tells the reader their changes are gone. This is the bit that
|
||||
/// keeps the two apart.
|
||||
///
|
||||
/// Newly reachable, too. A buffered read either arrived or errored; a
|
||||
/// stream can also be refused ([`MAX_CONCURRENT_GIT_STREAMS`] on one
|
||||
/// connection) or go silent mid-diff, so the empty-because-broken case is
|
||||
/// no longer rare enough to leave conflated with the empty-because-clean
|
||||
/// one.
|
||||
///
|
||||
/// [`MAX_CONCURRENT_GIT_STREAMS`]: tty7_core::daemon::control::MAX_CONCURRENT_GIT_STREAMS
|
||||
pub read_failed: bool,
|
||||
}
|
||||
|
||||
impl DiffSnapshot {
|
||||
/// Total added/removed line counts across all files — the overlay's
|
||||
/// header numbers, matching the sidebar's `+N −N` by construction (both
|
||||
/// sum per-file counts of the same `HEAD` diff).
|
||||
///
|
||||
/// Deliberately *not* affected by any truncation: the parser keeps counting
|
||||
/// past every cap, because this number is compared against the status
|
||||
/// cache's `git diff --numstat` totals to decide whether the overlay is
|
||||
/// stale. A capped total would never match, and the overlay would re-probe
|
||||
/// in a loop.
|
||||
pub fn totals(&self) -> (u32, u32) {
|
||||
self.files
|
||||
.iter()
|
||||
.fold((0, 0), |(a, r), f| (a + f.added, r + f.removed))
|
||||
}
|
||||
|
||||
/// The true number of untracked paths, whether or not the retained list was
|
||||
/// capped. Falls back to the retained length so a snapshot built by hand
|
||||
/// (tests, `..Default::default()`) can't under-report — the fallback is
|
||||
/// never wrong, since `untracked_total` is only ever ≥ `untracked.len()`.
|
||||
pub fn untracked_count(&self) -> usize {
|
||||
self.untracked_total.max(self.untracked.len())
|
||||
}
|
||||
|
||||
/// Every whole-snapshot number the render path needs, in one pass.
|
||||
///
|
||||
/// The overlay asks six questions of a landed snapshot — is it oversized,
|
||||
/// what are the totals, how many lines were retained, did the budget fire,
|
||||
/// did the per-file cap fire, how many untracked — and it asks them while
|
||||
/// building the element tree, so they run on the UI thread on every render.
|
||||
/// `files` is deliberately *not* capped (only hunks are, by
|
||||
/// [`MAX_FILES_WITH_HUNKS`]), so answering them one accessor at a time is
|
||||
/// six walks over a list whose length is the size of the working tree, on
|
||||
/// exactly the tree this whole module exists to keep responsive. Hence one
|
||||
/// walk answering all of them, and no per-question accessors to drift from
|
||||
/// it.
|
||||
///
|
||||
/// Computed rather than cached in the struct on purpose: the snapshot is
|
||||
/// `PartialEq` and built by hand all over the tests with
|
||||
/// `..Default::default()`, and a stored count would silently read as zero
|
||||
/// for every one of them.
|
||||
pub fn stats(&self) -> DiffStats {
|
||||
let mut added = 0u32;
|
||||
let mut removed = 0u32;
|
||||
@@ -206,9 +67,6 @@ impl DiffSnapshot {
|
||||
totals: (added, removed),
|
||||
retained_lines,
|
||||
untracked_count,
|
||||
// Changed files only. Untracked paths are bounded where they are
|
||||
// *rendered* instead — see the field's own note for why collapsing
|
||||
// bodies is the wrong lever for them.
|
||||
oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES
|
||||
|| retained_lines > AUTO_COLLAPSE_TOTAL_LINES,
|
||||
budget_exhausted,
|
||||
@@ -217,46 +75,16 @@ impl DiffSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`DiffSnapshot::stats`] answers in one walk. See that method for
|
||||
/// why the render path wants them together.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub struct DiffStats {
|
||||
/// `(added, removed)` — exact, never affected by truncation. See
|
||||
/// [`DiffSnapshot::totals`].
|
||||
pub totals: (u32, u32),
|
||||
/// Diff lines actually kept, summed over every file. This — not
|
||||
/// [`totals`](Self::totals) — is what the overlay would have to build rows
|
||||
/// for if every file were expanded, so it's what the render-side thresholds
|
||||
/// compare against. Counted one `len()` per hunk, not per line.
|
||||
pub retained_lines: usize,
|
||||
/// True untracked count, cap or no cap. See
|
||||
/// [`DiffSnapshot::untracked_count`].
|
||||
pub untracked_count: usize,
|
||||
/// Too big to open expanded: every file starts collapsed and the overlay
|
||||
/// leads with the "too large to render efficiently" summary. Not a refusal —
|
||||
/// individual files still expand by click, which is the escape hatch the
|
||||
/// summary points at.
|
||||
///
|
||||
/// Changed files and retained lines only. Untracked paths are the same
|
||||
/// one-row-per-entry cost, but this is not the lever that answers them:
|
||||
/// collapsing every file body leaves the untracked section rendering exactly
|
||||
/// as many rows as before, because that section has no bodies to fold. A
|
||||
/// tree with an un-ignored `node_modules` and three edited files would have
|
||||
/// folded away the three cheap things and kept the expensive one — while
|
||||
/// telling the reader their working tree was too large to render. The
|
||||
/// untracked list is bounded where it is actually built, by
|
||||
/// [`MAX_UNTRACKED`] on retention and [`MAX_RENDERED_FILES`] on rows.
|
||||
pub oversized: bool,
|
||||
/// The repo-wide budget dropped hunks that a smaller diff would have kept —
|
||||
/// the overlay says so, so a missing body reads as a cap rather than as tty7
|
||||
/// losing the change.
|
||||
pub budget_exhausted: bool,
|
||||
/// Any file was cut at [`MAX_LINES_PER_FILE`] — the sibling axis, which the
|
||||
/// oversized banner has to name separately because the two compose.
|
||||
pub per_file_truncated: bool,
|
||||
}
|
||||
|
||||
/// How a file changed vs `HEAD` — drives the status glyph in its header row.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum FileStatus {
|
||||
Added,
|
||||
@@ -265,31 +93,20 @@ pub enum FileStatus {
|
||||
Renamed,
|
||||
}
|
||||
|
||||
/// One changed file: its header-row facts plus the parsed hunks.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct FileDiff {
|
||||
/// New path (repo-relative); for a deletion, the old path.
|
||||
pub path: String,
|
||||
/// The pre-rename path, only when `status == Renamed`.
|
||||
pub old_path: Option<String>,
|
||||
pub status: FileStatus,
|
||||
/// Lines added / removed in this file (counted from the parsed hunks).
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
/// Binary file — no hunks, the header row says "binary" instead.
|
||||
pub binary: bool,
|
||||
/// Hunk parsing stopped short of the file's real diff, and why; the overlay
|
||||
/// appends a "truncated" footer under the last hunk. `None` means the body
|
||||
/// is complete.
|
||||
pub truncated: Option<Truncation>,
|
||||
pub hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
/// One `@@` hunk: its header line (kept verbatim, function context and all)
|
||||
/// and the diff lines under it.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Hunk {
|
||||
/// The full `@@ -a,b +c,d @@ …` line as git printed it.
|
||||
pub header: String,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
@@ -301,38 +118,18 @@ pub enum LineKind {
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// One diff line with the gutter numbers it carries: an added line has only a
|
||||
/// new number, a removed line only an old one, context both.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct DiffLine {
|
||||
pub kind: LineKind,
|
||||
pub old_no: Option<u32>,
|
||||
pub new_no: Option<u32>,
|
||||
/// The line's text, without the leading `+`/`-`/space marker.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Probe the full diff snapshot for `cwd` on `host`, or `None` when it isn't
|
||||
/// inside a git work tree. Blocking (three `git` invocations, three round trips
|
||||
/// on a remote host) — call it through `HostOps`, never on the UI thread.
|
||||
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
// No `exists` pre-check: a vanished cwd fails the first invocation with
|
||||
// `NotFound`, which is the same `None` one round trip cheaper.
|
||||
// Doubles as the "is this a repo" gate, same as the status probe.
|
||||
let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
|
||||
let branch = git_status::branch_name(host, cwd)?;
|
||||
// `-M` folds a delete+add pair back into one rename entry; `--no-ext-diff`
|
||||
// keeps a configured external diff tool from replacing the parseable
|
||||
// unified format. A failed diff (e.g. racing a concurrent git write) still
|
||||
// yields a snapshot — an empty file list with the branch — rather than
|
||||
// hiding the overlay; the next refresh fills it in.
|
||||
// Incremental, not buffered: `git diff HEAD` on a big work tree prints tens
|
||||
// of megabytes, and holding all of it before parsing could drop what it
|
||||
// doesn't keep is the cost issue #239 measured. `git_lines` funnels through
|
||||
// the pane's own host either way — it is streaming where the transport can
|
||||
// carry it and buffered where it can't, so the lines seen here are the same
|
||||
// either way.
|
||||
let mut parser = DiffParser::default();
|
||||
let diffed = host.git_lines(
|
||||
cwd,
|
||||
@@ -340,20 +137,9 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
&mut |line| parser.push_line(line),
|
||||
);
|
||||
let files = match diffed {
|
||||
// A failed diff (e.g. racing a concurrent git write) still yields a
|
||||
// snapshot — an empty file list with the branch — rather than hiding
|
||||
// the overlay; the next refresh fills it in. A *partial* read is
|
||||
// discarded rather than shown: the stream is reassembled into lines, so
|
||||
// what a cut one is missing is the tail of the diff, and half a diff
|
||||
// presented as a whole one is worse than none.
|
||||
Ok(Some(0)) => parser.finish(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// `--full-name` pins paths to the repo root regardless of which
|
||||
// subdirectory the pane sits in, matching the diff's path space. Capped for
|
||||
// the same reason the diff is: `--others` walks everything not yet ignored,
|
||||
// so one un-ignored dependency directory answers with tens of thousands of
|
||||
// paths.
|
||||
let mut untracked: Vec<String> = Vec::new();
|
||||
let mut untracked_total = 0usize;
|
||||
let listed = host.git_lines(
|
||||
@@ -367,8 +153,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
},
|
||||
);
|
||||
if !matches!(listed, Ok(Some(0))) {
|
||||
// A failed listing is "we don't know", not "there are none" — same
|
||||
// shape as the diff above.
|
||||
untracked.clear();
|
||||
untracked_total = 0;
|
||||
}
|
||||
@@ -382,14 +166,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `git diff` unified output into per-file structures. Tolerant by
|
||||
/// construction: unrecognized metadata lines between the `diff --git` header
|
||||
/// and the first hunk (modes, index, similarity) are simply skipped, so a git
|
||||
/// version printing extra headers degrades to "fewer facts", never a panic.
|
||||
///
|
||||
/// The whole-string form the tests drive the parser through. [`probe`] feeds
|
||||
/// [`DiffParser`] a line at a time off the host's streaming read instead, so no
|
||||
/// caller in the app ever holds the full diff as one `String`.
|
||||
#[cfg(test)]
|
||||
pub fn parse_unified(out: &str) -> Vec<FileDiff> {
|
||||
let mut parser = DiffParser::default();
|
||||
@@ -399,34 +175,18 @@ pub fn parse_unified(out: &str) -> Vec<FileDiff> {
|
||||
parser.finish()
|
||||
}
|
||||
|
||||
/// The unified-diff parser as an incremental state machine, so `git diff`'s
|
||||
/// output can be consumed a line at a time off a pipe rather than buffered
|
||||
/// whole. Enforces both the per-file and the repo-wide retention budgets; see
|
||||
/// [`MAX_LINES_PER_FILE`] and [`MAX_TOTAL_LINES`].
|
||||
#[derive(Default)]
|
||||
pub struct DiffParser {
|
||||
files: Vec<FileDiff>,
|
||||
/// Line-number counters for the hunk currently being filled.
|
||||
old_no: u32,
|
||||
new_no: u32,
|
||||
/// Lines consumed by the current file's hunks, for the per-file cap.
|
||||
file_lines: usize,
|
||||
/// Lines retained across every file so far, for the repo-wide cap.
|
||||
total_lines: usize,
|
||||
/// Files that actually kept at least one hunk, for the repo-wide file cap.
|
||||
/// Counted at the first `@@`, not at the file header: a pure rename or a
|
||||
/// binary blob has no body and must not spend the budget on nothing.
|
||||
files_with_hunks: usize,
|
||||
/// Whether the last `@@` header opened a body we're still inside. Tracked
|
||||
/// explicitly rather than inferred from "the file has hunks": a file the
|
||||
/// budget truncated never gets a `Hunk` pushed, but its lines still have to
|
||||
/// be *counted*, and a removed line whose own text starts with `--- ` must
|
||||
/// not be mistaken for the file header it looks like.
|
||||
in_hunk: bool,
|
||||
}
|
||||
|
||||
impl DiffParser {
|
||||
/// Feed one line of `git diff` output (no trailing newline).
|
||||
pub fn push_line(&mut self, line: &str) {
|
||||
if let Some(rest) = line.strip_prefix("diff --git ") {
|
||||
let (old_p, new_p) = parse_git_header_paths(rest);
|
||||
@@ -445,9 +205,8 @@ impl DiffParser {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.files.last_mut() else {
|
||||
return; // preamble before any header (shouldn't happen)
|
||||
return;
|
||||
};
|
||||
// ── File-level metadata between the header and the first hunk ──────
|
||||
if line.starts_with("new file mode") {
|
||||
file.status = FileStatus::Added;
|
||||
return;
|
||||
@@ -464,28 +223,17 @@ impl DiffParser {
|
||||
file.binary = true;
|
||||
return;
|
||||
}
|
||||
// `--- a/x` / `+++ b/x` repeat what the header said; `rename to`,
|
||||
// `index`, modes and similarity scores add nothing we render. But only
|
||||
// skip them *outside* hunk bodies — a removed line legitimately starts
|
||||
// with `--- ` inside one.
|
||||
if !self.in_hunk
|
||||
&& (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line))
|
||||
&& !line.starts_with("@@")
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ── Hunks ───────────────────────────────────────────────────────────
|
||||
if line.starts_with("@@") {
|
||||
// A truncated file still enters the body — its lines have to be
|
||||
// counted — it just doesn't get a `Hunk` to keep them in.
|
||||
self.in_hunk = true;
|
||||
if file.truncated.is_some() {
|
||||
return;
|
||||
}
|
||||
// The repo-wide budget is charged here rather than at the file
|
||||
// header, so a file with no body at all (a pure rename, a binary
|
||||
// blob) neither consumes the file budget nor gets flagged as
|
||||
// truncated for a body it never had.
|
||||
let first_hunk = file.hunks.is_empty();
|
||||
if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS)
|
||||
|| self.total_lines >= MAX_TOTAL_LINES
|
||||
@@ -506,19 +254,14 @@ impl DiffParser {
|
||||
return;
|
||||
}
|
||||
if !self.in_hunk {
|
||||
return; // stray content outside any hunk
|
||||
return;
|
||||
}
|
||||
let (kind, text) = match line.as_bytes().first() {
|
||||
Some(b'+') => (LineKind::Added, &line[1..]),
|
||||
Some(b'-') => (LineKind::Removed, &line[1..]),
|
||||
Some(b' ') => (LineKind::Context, &line[1..]),
|
||||
// `\ No newline at end of file` and anything else: not a diff line.
|
||||
_ => return,
|
||||
};
|
||||
// Count added/removed *before* the truncation gate: the caps are about
|
||||
// element volume, but the header numbers must stay honest (they are
|
||||
// compared against `--numstat` to detect staleness), so lines past a cap
|
||||
// still count even though they're never kept.
|
||||
match kind {
|
||||
LineKind::Added => file.added += 1,
|
||||
LineKind::Removed => file.removed += 1,
|
||||
@@ -566,28 +309,16 @@ impl DiffParser {
|
||||
self.total_lines += 1;
|
||||
}
|
||||
|
||||
/// The parsed files. A truncated file still counts +/− for its whole diff
|
||||
/// (the parser keeps counting past every cap), so totals stay consistent
|
||||
/// with `--numstat`.
|
||||
pub fn finish(self) -> Vec<FileDiff> {
|
||||
self.files
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a line can only belong to a hunk body (`+`/`-`/space/`\` lead).
|
||||
fn is_hunk_line(line: &str) -> bool {
|
||||
matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty()
|
||||
}
|
||||
|
||||
/// Split the `a/old b/new` tail of a `diff --git` header into the two paths.
|
||||
///
|
||||
/// Plain names split on the ` b/` separator; paths with spaces work because
|
||||
/// git quotes *those* (`"a/x y" "b/x y"`), handled by the quoted branch. A
|
||||
/// path containing a literal ` b/` unquoted is ambiguous in git's own format —
|
||||
/// we take the last occurrence, matching git's convention of the `b/` side
|
||||
/// naming the current file.
|
||||
fn parse_git_header_paths(rest: &str) -> (String, String) {
|
||||
// Quoted form: "a/path with spaces" "b/path with spaces".
|
||||
if rest.starts_with('"') {
|
||||
let parts: Vec<String> = parse_quoted_pair(rest);
|
||||
if parts.len() == 2 {
|
||||
@@ -599,12 +330,9 @@ fn parse_git_header_paths(rest: &str) -> (String, String) {
|
||||
let new = &rest[idx + 1..];
|
||||
return (strip_prefix_ab(old), strip_prefix_ab(new));
|
||||
}
|
||||
// Unsplittable — show the whole tail rather than nothing.
|
||||
(rest.to_string(), rest.to_string())
|
||||
}
|
||||
|
||||
/// Parse up to two double-quoted strings (git's C-style quoting, minus octal
|
||||
/// escapes — good enough for spaces, the common case).
|
||||
fn parse_quoted_pair(s: &str) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
let mut cur = String::new();
|
||||
@@ -631,7 +359,6 @@ fn parse_quoted_pair(s: &str) -> Vec<String> {
|
||||
parts
|
||||
}
|
||||
|
||||
/// Drop the `a/` / `b/` prefix git puts on header paths.
|
||||
fn strip_prefix_ab(p: &str) -> String {
|
||||
p.strip_prefix("a/")
|
||||
.or_else(|| p.strip_prefix("b/"))
|
||||
@@ -639,7 +366,6 @@ fn strip_prefix_ab(p: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The old/new start line numbers from a `@@ -a,b +c,d @@` header.
|
||||
fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
|
||||
let rest = line.strip_prefix("@@ -")?;
|
||||
let (old_part, rest) = rest.split_once(" +")?;
|
||||
@@ -684,8 +410,6 @@ index 5555555..6666666 100644
|
||||
Binary files a/img.png and b/img.png differ
|
||||
";
|
||||
|
||||
/// The sample covers modify / add / delete / binary; statuses, counts, and
|
||||
/// hunk line numbers all land where the unified format says they should.
|
||||
#[test]
|
||||
fn parses_the_four_file_shapes() {
|
||||
let files = parse_unified(SAMPLE);
|
||||
@@ -699,7 +423,6 @@ Binary files a/img.png and b/img.png differ
|
||||
assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {");
|
||||
let lines = &m.hunks[0].lines;
|
||||
assert_eq!(lines.len(), 5);
|
||||
// Context line carries both numbers, tracking the hunk starts.
|
||||
assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10)));
|
||||
assert_eq!(lines[1].kind, LineKind::Removed);
|
||||
assert_eq!(lines[1].old_no, Some(11));
|
||||
@@ -708,7 +431,6 @@ Binary files a/img.png and b/img.png differ
|
||||
assert_eq!(lines[2].new_no, Some(11));
|
||||
assert_eq!(lines[3].new_no, Some(12));
|
||||
assert_eq!(lines[3].text, "let c = 3;");
|
||||
// Trailing context resumes both counters.
|
||||
assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13)));
|
||||
|
||||
let a = &files[1];
|
||||
@@ -724,7 +446,6 @@ Binary files a/img.png and b/img.png differ
|
||||
assert!(b.hunks.is_empty());
|
||||
}
|
||||
|
||||
/// Renames keep both paths and don't show phantom +/− lines.
|
||||
#[test]
|
||||
fn parses_renames() {
|
||||
let out = "\
|
||||
@@ -741,7 +462,6 @@ rename to new/name.rs
|
||||
assert_eq!((files[0].added, files[0].removed), (0, 0));
|
||||
}
|
||||
|
||||
/// Quoted headers (paths with spaces) resolve to the unquoted paths.
|
||||
#[test]
|
||||
fn parses_quoted_paths() {
|
||||
let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n";
|
||||
@@ -750,7 +470,6 @@ rename to new/name.rs
|
||||
assert_eq!(files[0].old_path, None);
|
||||
}
|
||||
|
||||
/// A `--- ` *content* line inside a hunk is a removed line, not metadata.
|
||||
#[test]
|
||||
fn triple_dash_content_line_is_kept() {
|
||||
let out = "\
|
||||
@@ -766,13 +485,9 @@ index 1111111..2222222 100644
|
||||
let lines = &files[0].hunks[0].lines;
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[1].kind, LineKind::Removed);
|
||||
// Raw `---- a heading rule` = marker `-` + content `--- a heading rule`:
|
||||
// content that *itself* starts with `--- ` must not be eaten as metadata.
|
||||
assert_eq!(lines[1].text, "--- a heading rule");
|
||||
}
|
||||
|
||||
/// Past the per-file cap the hunks stop growing and the file is flagged,
|
||||
/// but the +/− counts keep counting so the header stays honest.
|
||||
#[test]
|
||||
fn caps_lines_per_file_but_keeps_counting() {
|
||||
let mut out = String::from(
|
||||
@@ -788,7 +503,6 @@ index 1111111..2222222 100644
|
||||
assert_eq!(kept, MAX_LINES_PER_FILE);
|
||||
}
|
||||
|
||||
/// `\ No newline at end of file` markers are skipped, not rendered.
|
||||
#[test]
|
||||
fn skips_no_newline_marker() {
|
||||
let out = "\
|
||||
@@ -807,7 +521,6 @@ index 1..2 100644
|
||||
assert_eq!((files[0].added, files[0].removed), (1, 1));
|
||||
}
|
||||
|
||||
/// Totals sum per-file counts.
|
||||
#[test]
|
||||
fn snapshot_totals() {
|
||||
let snap = DiffSnapshot {
|
||||
@@ -817,8 +530,6 @@ index 1..2 100644
|
||||
assert_eq!(snap.totals(), (4, 2));
|
||||
}
|
||||
|
||||
/// Build `files` synthetic modified files of `lines_each` added lines —
|
||||
/// the "many medium files" shape the per-file cap alone can't bound.
|
||||
fn many_files(files: usize, lines_each: usize) -> String {
|
||||
let mut out = String::new();
|
||||
for f in 0..files {
|
||||
@@ -832,13 +543,8 @@ index 1..2 100644
|
||||
out
|
||||
}
|
||||
|
||||
/// The repo-wide budget bounds retained lines even when no single file is
|
||||
/// anywhere near [`MAX_LINES_PER_FILE`] — the case the reporter of #239
|
||||
/// called out as the one the per-file cap misses.
|
||||
#[test]
|
||||
fn repo_wide_budget_caps_retained_lines() {
|
||||
// 300 files × 300 lines = 90k lines, none of which trips the 2000-line
|
||||
// per-file cap.
|
||||
let files = parse_unified(&many_files(300, 300));
|
||||
assert_eq!(files.len(), 300, "every file keeps its header row");
|
||||
let retained: usize = files
|
||||
@@ -857,9 +563,6 @@ index 1..2 100644
|
||||
);
|
||||
}
|
||||
|
||||
/// Budget or no budget, the +/− totals must stay exact: they are compared
|
||||
/// against `git diff --numstat` to decide whether the overlay is stale, and
|
||||
/// a short count would make every comparison disagree and re-probe forever.
|
||||
#[test]
|
||||
fn repo_wide_budget_keeps_totals_exact() {
|
||||
let snap = DiffSnapshot {
|
||||
@@ -870,17 +573,12 @@ index 1..2 100644
|
||||
assert!(snap.stats().budget_exhausted);
|
||||
}
|
||||
|
||||
/// The file cap keeps a rename-the-world diff from allocating a `Vec<Hunk>`
|
||||
/// per file, while every file still lists its path and counts.
|
||||
#[test]
|
||||
fn repo_wide_budget_caps_files_with_hunks() {
|
||||
// One line each: far under the line budget, so only the file cap can
|
||||
// stop this.
|
||||
let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1));
|
||||
assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50);
|
||||
let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count();
|
||||
assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS);
|
||||
// The tail still counts, so totals stay honest.
|
||||
assert_eq!(
|
||||
files.iter().map(|f| f.added).sum::<u32>(),
|
||||
(MAX_FILES_WITH_HUNKS + 50) as u32
|
||||
@@ -888,8 +586,6 @@ index 1..2 100644
|
||||
assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget));
|
||||
}
|
||||
|
||||
/// A small diff is untouched by the budget — the setting-enabled,
|
||||
/// small-working-tree case must behave exactly as before.
|
||||
#[test]
|
||||
fn small_diff_is_not_truncated() {
|
||||
let snap = DiffSnapshot {
|
||||
@@ -901,7 +597,6 @@ index 1..2 100644
|
||||
assert!(!snap.stats().budget_exhausted);
|
||||
}
|
||||
|
||||
/// `oversized` trips on either axis: many files, or many retained lines.
|
||||
#[test]
|
||||
fn oversized_trips_on_files_or_lines() {
|
||||
let by_files = DiffSnapshot {
|
||||
@@ -910,10 +605,6 @@ index 1..2 100644
|
||||
};
|
||||
assert!(by_files.stats().oversized);
|
||||
|
||||
// Few files, but past the line threshold. Spread over enough files to
|
||||
// clear it without any one of them hitting `MAX_LINES_PER_FILE` first —
|
||||
// the per-file cap would otherwise decide this test's outcome instead of
|
||||
// the repo-wide threshold it is about.
|
||||
let per_file = MAX_LINES_PER_FILE / 2;
|
||||
let by_lines = DiffSnapshot {
|
||||
files: parse_unified(&many_files(
|
||||
@@ -927,8 +618,6 @@ index 1..2 100644
|
||||
assert!(by_lines.stats().oversized);
|
||||
}
|
||||
|
||||
/// Even a budget-truncated file keeps a removed line whose *content* starts
|
||||
/// with `--- ` out of the metadata skip — that line still has to count.
|
||||
#[test]
|
||||
fn truncated_file_counts_dash_prefixed_content() {
|
||||
let mut out = many_files(MAX_FILES_WITH_HUNKS, 1);
|
||||
@@ -943,12 +632,8 @@ index 1..2 100644
|
||||
assert_eq!((late.added, late.removed), (0, 1), "but the line counts");
|
||||
}
|
||||
|
||||
/// The untracked cap keeps the retained list bounded while the reported
|
||||
/// count stays exact — the same split the diff side already makes between
|
||||
/// what is retained and what is counted.
|
||||
#[test]
|
||||
fn untracked_is_capped_but_counted() {
|
||||
// What `probe` builds while streaming `ls-files --others`.
|
||||
let mut untracked: Vec<String> = Vec::new();
|
||||
let mut untracked_total = 0usize;
|
||||
for i in 0..(MAX_UNTRACKED * 3) {
|
||||
@@ -970,13 +655,6 @@ index 1..2 100644
|
||||
);
|
||||
}
|
||||
|
||||
/// Measurement harness for issue #239 finding 1 — run with
|
||||
/// `cargo test --release -- --ignored --nocapture bench_stream_vs_buffer`.
|
||||
///
|
||||
/// Measures the shipped code paths against real git output in this
|
||||
/// repository: `git_output` (what `Host::git` uses) versus `git_stream` +
|
||||
/// `LineSplitter` (what `Host::git_lines` uses on a local host), both fed
|
||||
/// to the same [`DiffParser`].
|
||||
#[test]
|
||||
#[ignore = "measurement, not an assertion"]
|
||||
fn bench_stream_vs_buffer() {
|
||||
@@ -984,7 +662,6 @@ index 1..2 100644
|
||||
use tty7_core::core::git::{LineSplitter, git_output, git_stream};
|
||||
|
||||
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
// Real, sizeable git output: patches for the last few hundred commits.
|
||||
let args = ["log", "-p", "-n", "400", "--no-color"];
|
||||
|
||||
let t = Instant::now();
|
||||
@@ -1025,8 +702,6 @@ index 1..2 100644
|
||||
assert_eq!(buffered_files.len(), streamed_files.len());
|
||||
}
|
||||
|
||||
/// Measurement harness for issue #239, not a correctness gate — run with
|
||||
/// `cargo test -- --ignored --nocapture bench_parse_budget`.
|
||||
#[test]
|
||||
#[ignore = "measurement, not an assertion"]
|
||||
fn bench_parse_budget() {
|
||||
|
||||
+1
-165
@@ -1,85 +1,26 @@
|
||||
//! A lightweight git snapshot for a pane's working directory — the current
|
||||
//! branch and the working-tree diff size — rendered as the sidebar row's third
|
||||
//! line (`⎇ feat/x +6 −5`): each session fronted with its branch and change
|
||||
//! count.
|
||||
//!
|
||||
//! Snapshots are shared through [`GitStatusCache`], a process-wide map keyed
|
||||
//! by machine *and* work-tree root: every pane whose cwd resolves into the same
|
||||
//! repo reads the *same* entry, so ten tabs in one repo show one truth,
|
||||
//! refreshed by whichever pane probed last — not ten drifting copies refreshed
|
||||
//! on ten different schedules. Probes stay per-trigger (a pane's cwd change,
|
||||
//! command end, or agent-turn end — see [`crate::terminal::view`]) but are
|
||||
//! deduped in-flight, so simultaneous triggers from panes in the same directory
|
||||
//! cost one `git` invocation, not one per pane.
|
||||
//!
|
||||
//! **Machine is part of every key.** `/home/me/proj` is a real path on this
|
||||
//! laptop and on the box it is SSH'd into, and they are different repositories
|
||||
//! on different branches. A cache keyed by path alone would serve one's branch
|
||||
//! line for the other, so every table here is a [`ByHost`] and every entry
|
||||
//! point takes the [`HostId`] the cwd belongs to.
|
||||
//!
|
||||
//! The probe itself — [`probe`], [`branch_name`], and the [`git`] invocation
|
||||
//! every git read in tty7 funnels through — lives in `tty7-core`, because the
|
||||
//! remote server has to answer the same questions the same way. All three now
|
||||
//! take the [`Host`](crate::ui::host_ops::Host) to ask, which is what lets a
|
||||
//! pane on another machine report its own repository instead of reporting
|
||||
//! nothing. What stays here is the cache, which is a gpui `Global`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe};
|
||||
use crate::ui::host_ops::{ByHost, HostId, InFlight};
|
||||
|
||||
/// The process-wide snapshot store (a gpui [`Global`](gpui::Global)): pane
|
||||
/// cwds grouped by work-tree root, one [`GitStatus`] per root. Views read
|
||||
/// through [`status_for`](Self::status_for); the probe loop in
|
||||
/// [`crate::terminal::view`] brackets each background probe with
|
||||
/// [`begin_probe`](Self::begin_probe) / [`finish_probe`](Self::finish_probe).
|
||||
///
|
||||
/// In-flight dedup is keyed by cwd (the root isn't known until a first probe
|
||||
/// answers), so two panes at the same directory share one probe; panes in
|
||||
/// *different* subdirectories of one repo can still race a redundant probe —
|
||||
/// rare, and both land the same answer.
|
||||
#[derive(Default)]
|
||||
pub struct GitStatusCache {
|
||||
/// cwd → its work-tree root; `None` = probed and found not to be a repo.
|
||||
roots: ByHost<PathBuf, Option<PathBuf>>,
|
||||
/// work-tree root → the repository home it belongs to (see
|
||||
/// [`RepoSnapshot::home`]). Identity for a plain checkout; the main
|
||||
/// root for a linked worktree, so the sidebar groups them together.
|
||||
homes: ByHost<PathBuf, PathBuf>,
|
||||
/// root → the snapshot every pane in that tree shares.
|
||||
status: ByHost<PathBuf, GitStatus>,
|
||||
/// Probes in flight, and which of those were re-triggered while flying —
|
||||
/// so concurrent triggers fold into one invocation and the newest
|
||||
/// trigger's state is still observed. Keyed by `(host, cwd)`: two machines
|
||||
/// at the same path are two independent probes.
|
||||
probes: InFlight<(HostId, PathBuf)>,
|
||||
/// When each cwd's last probe *landed*, for the throttle that opportunistic
|
||||
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
|
||||
last_probe: ByHost<PathBuf, Instant>,
|
||||
}
|
||||
|
||||
impl gpui::Global for GitStatusCache {}
|
||||
|
||||
impl GitStatusCache {
|
||||
/// The snapshot for a pane at `cwd`: resolved through its work-tree root,
|
||||
/// so every pane in the same repo answers identically. `None` before the
|
||||
/// first probe lands or when `cwd` isn't in a repo.
|
||||
pub fn status_for(&self, host: HostId, cwd: &Path) -> Option<GitStatus> {
|
||||
let root = self.roots.get(host, cwd)?.as_ref()?;
|
||||
self.status.get(host, root).cloned()
|
||||
}
|
||||
|
||||
/// What the cache *knows* about the repository `cwd` belongs to,
|
||||
/// three-valued for the sidebar's repo grouping: `None` = no probe has
|
||||
/// answered yet (the caller should keep whatever grouping it had, not
|
||||
/// reshuffle on a guess); `Some(None)` = probed and confirmed outside any
|
||||
/// work tree; `Some(Some(home))` = probed and inside the repo at `home`.
|
||||
/// `home` is the repository home, not the work-tree root — a linked
|
||||
/// worktree answers with the main checkout's root, so every worktree of
|
||||
/// one repo lands in one sidebar group.
|
||||
pub fn known_repo_for(&self, host: HostId, cwd: &Path) -> Option<Option<PathBuf>> {
|
||||
let root = self.roots.get(host, cwd)?;
|
||||
Some(root.as_ref().map(|root| {
|
||||
@@ -90,39 +31,16 @@ impl GitStatusCache {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Claim a probe for `cwd`. `false` means one is already in flight — the
|
||||
/// caller must *not* spawn another; the landed flight will reprobe once
|
||||
/// (the cwd is marked dirty) so this trigger's state still gets observed.
|
||||
pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool {
|
||||
let key = (host, cwd.to_path_buf());
|
||||
if self.probes.begin(key.clone()) {
|
||||
true
|
||||
} else {
|
||||
// Already flying: mark it superseded so the landing asks for one
|
||||
// more run rather than dropping this trigger's state.
|
||||
self.probes.invalidate(&key);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap,
|
||||
/// frequent signal — the window regaining focus, an agent finishing a tool
|
||||
/// call — rather than by a rare edge like a command ending.
|
||||
///
|
||||
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
|
||||
/// queueing: a probe already in flight, or one against a repo probed less
|
||||
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
|
||||
/// rerun). That's the whole point of the two entry points — the rare edges
|
||||
/// must never be missed, while these signals repeat on their own, so a
|
||||
/// count that's a second stale beats a `git` storm across every pane of a
|
||||
/// repo the moment the user alt-tabs back.
|
||||
///
|
||||
/// The throttle counts per *repo*, not per cwd (see
|
||||
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
|
||||
/// rather than waiting for the landing: without that, a dozen panes
|
||||
/// scattered over one repo's subdirectories would all claim in the same
|
||||
/// instant — each of them passing a throttle no probe had answered yet —
|
||||
/// and produce a dozen identical full-repo diffs.
|
||||
pub fn begin_probe_throttled(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
@@ -145,19 +63,6 @@ impl GitStatusCache {
|
||||
true
|
||||
}
|
||||
|
||||
/// What the opportunistic throttle counts against: the work-tree root once
|
||||
/// some probe has answered for `cwd`, and `cwd` itself before that.
|
||||
///
|
||||
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
|
||||
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
|
||||
/// and `repo/docs` are three ways of asking one question, and want one
|
||||
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
|
||||
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
|
||||
/// has to be able to release exactly what was claimed.
|
||||
///
|
||||
/// Before any probe has landed the root is simply unknown, so the first
|
||||
/// sweep over a repo still costs one probe per distinct cwd; every sweep
|
||||
/// after that collapses to one.
|
||||
fn throttle_key<'a>(&'a self, host: HostId, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(host, cwd) {
|
||||
Some(Some(root)) => root,
|
||||
@@ -165,23 +70,13 @@ impl GitStatusCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
|
||||
/// live repo keeps the root's previous counts (a transient `git` error is
|
||||
/// not "the tree went clean"). Returns whether the cwd was re-triggered
|
||||
/// while this probe flew — the caller should start one more probe.
|
||||
pub fn finish_probe(
|
||||
&mut self,
|
||||
host: HostId,
|
||||
cwd: &Path,
|
||||
snapshot: Option<RepoSnapshot>,
|
||||
) -> bool {
|
||||
// `finish` both retires the claim and reports whether it survived: it
|
||||
// answers "still current", so the rerun this function promises is its
|
||||
// negation.
|
||||
let rerun = !self.probes.finish(&(host, cwd.to_path_buf()));
|
||||
// Re-stamp on landing so the gap is measured from fresh counts, and
|
||||
// under the root this probe just resolved — which is how a cwd first
|
||||
// learns to share its repo's clock (at claim time it had none).
|
||||
let key = match &snapshot {
|
||||
Some(snap) => snap.root.clone(),
|
||||
None => self.throttle_key(host, cwd).to_path_buf(),
|
||||
@@ -207,8 +102,6 @@ impl GitStatusCache {
|
||||
self.homes.insert(host, snap.root.clone(), snap.home);
|
||||
self.roots.insert(host, cwd.to_path_buf(), Some(snap.root));
|
||||
}
|
||||
// Not a repo (or the dir vanished). The root's entry stays for
|
||||
// other cwds that still live in it.
|
||||
None => {
|
||||
self.roots.insert(host, cwd.to_path_buf(), None);
|
||||
}
|
||||
@@ -221,8 +114,6 @@ impl GitStatusCache {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// This machine — the host every pre-existing case implicitly used, back
|
||||
/// when there was only one.
|
||||
const L: HostId = HostId::LOCAL;
|
||||
|
||||
fn snap(root: &str, branch: &str, counts: Option<(u32, u32)>) -> RepoSnapshot {
|
||||
@@ -234,7 +125,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot for a linked worktree: its own root, a shared repo home.
|
||||
fn wt_snap(root: &str, home: &str, branch: &str) -> RepoSnapshot {
|
||||
RepoSnapshot {
|
||||
root: PathBuf::from(root),
|
||||
@@ -243,15 +133,12 @@ mod tests {
|
||||
counts: Some((0, 0)),
|
||||
}
|
||||
}
|
||||
/// Two cwds landing in the same work tree share one entry: a probe from
|
||||
/// either updates what both read (the group-by-root contract).
|
||||
#[test]
|
||||
fn cwds_in_one_repo_share_a_snapshot() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (a, b) = (Path::new("/repo/sub/a"), Path::new("/repo"));
|
||||
cache.finish_probe(L, a, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
cache.finish_probe(L, b, Some(snap("/repo", "main", Some((5, 2)))));
|
||||
// A later probe from `a` refreshes the numbers `b` reads too.
|
||||
cache.finish_probe(L, a, Some(snap("/repo", "main", Some((200, 42)))));
|
||||
for cwd in [a, b] {
|
||||
let got = cache.status_for(L, cwd).unwrap();
|
||||
@@ -259,12 +146,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same absolute path on two machines is two repositories. `/src/app`
|
||||
/// exists on this laptop and on the box it is SSH'd into, on different
|
||||
/// branches with different diffs — and before the tables were keyed by
|
||||
/// host, whichever probed last would have overwritten the other's branch
|
||||
/// line. Dedup and the throttle are per host too: a probe flying for one
|
||||
/// machine must not make the other's trigger silently vanish.
|
||||
#[test]
|
||||
fn one_path_on_two_machines_is_two_entries() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -283,10 +164,6 @@ mod tests {
|
||||
assert_eq!((local.branch.as_str(), local.added), ("main", 1));
|
||||
assert_eq!((there.branch.as_str(), there.added), ("feat/x", 30));
|
||||
|
||||
// The repo *home* — what the sidebar groups by — is resolved per host
|
||||
// too. Here the same path is a plain checkout on one machine and a
|
||||
// linked worktree of a different repository on the other; a shared
|
||||
// `homes` table would have handed one machine's answer to the other.
|
||||
cache.finish_probe(
|
||||
remote,
|
||||
cwd,
|
||||
@@ -301,15 +178,12 @@ mod tests {
|
||||
Some(Some(PathBuf::from("/src/main")))
|
||||
);
|
||||
|
||||
// A probe in flight for one host leaves the other free to claim.
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(cache.begin_probe(remote, cwd));
|
||||
assert!(!cache.begin_probe(L, cwd), "same host, already flying");
|
||||
assert!(cache.finish_probe(L, cwd, None), "…so it asks for a rerun");
|
||||
assert!(!cache.finish_probe(remote, cwd, None), "the other did not");
|
||||
|
||||
// …and the throttle clock is the host's own: one machine's fresh probe
|
||||
// does not silence the other's.
|
||||
let gap = Duration::from_secs(60);
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap), "just landed");
|
||||
assert!(
|
||||
@@ -321,8 +195,6 @@ mod tests {
|
||||
assert!(cache.begin_probe_throttled(remote, other, gap));
|
||||
}
|
||||
|
||||
/// A failed `git diff` (counts `None`) keeps the previous numbers rather
|
||||
/// than rendering the tree as suddenly clean; the branch still updates.
|
||||
#[test]
|
||||
fn failed_diff_keeps_previous_counts() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -334,22 +206,17 @@ mod tests {
|
||||
assert_eq!((got.added, got.removed), (200, 42));
|
||||
}
|
||||
|
||||
/// In-flight dedup: a second trigger while a probe flies doesn't claim a
|
||||
/// new one, but marks the cwd dirty so the landing reports "go again".
|
||||
#[test]
|
||||
fn concurrent_triggers_fold_into_one_probe_then_rerun() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let cwd = Path::new("/repo");
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(!cache.begin_probe(L, cwd)); // deduped, marked dirty
|
||||
assert!(!cache.begin_probe(L, cwd));
|
||||
assert!(cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
// The rerun claims cleanly and lands with nothing pending.
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
}
|
||||
|
||||
/// A cwd that leaves the repo (dir deleted / not a work tree) stops
|
||||
/// answering, without disturbing the root entry other cwds still use.
|
||||
#[test]
|
||||
fn non_repo_cwd_clears_only_itself() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -361,10 +228,6 @@ mod tests {
|
||||
assert!(cache.status_for(L, b).is_some());
|
||||
}
|
||||
|
||||
/// The three-valued `known_repo_for` the sidebar's repo grouping reads:
|
||||
/// unprobed → `None`, probed-and-in-a-repo → `Some(Some(home))`,
|
||||
/// probed-and-not-a-repo → `Some(None)`. The three cases are what let a
|
||||
/// sticky group key hold across an in-flight cd instead of flickering.
|
||||
#[test]
|
||||
fn known_repo_for_is_three_valued() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -376,20 +239,14 @@ mod tests {
|
||||
cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0)))));
|
||||
cache.finish_probe(L, plain, None);
|
||||
|
||||
// Inside a work tree: the resolved repo home, wrapped twice.
|
||||
assert_eq!(
|
||||
cache.known_repo_for(L, repo),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
);
|
||||
// Probed and confirmed outside any repo: a definite "not a repo".
|
||||
assert_eq!(cache.known_repo_for(L, plain), Some(None));
|
||||
// Never probed: no answer yet — the caller keeps its sticky key.
|
||||
assert_eq!(cache.known_repo_for(L, unseen), None);
|
||||
}
|
||||
|
||||
/// Linked worktrees of one repository share a *group* (`known_repo_for`
|
||||
/// answers the main root for both) while their *status* stays per work
|
||||
/// tree — different branches never clobber each other.
|
||||
#[test]
|
||||
fn worktrees_share_a_repo_but_not_a_status() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -397,7 +254,6 @@ mod tests {
|
||||
cache.finish_probe(L, main, Some(wt_snap("/repo", "/repo", "main")));
|
||||
cache.finish_probe(L, wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x")));
|
||||
|
||||
// One sidebar group…
|
||||
assert_eq!(
|
||||
cache.known_repo_for(L, main),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
@@ -406,13 +262,9 @@ mod tests {
|
||||
cache.known_repo_for(L, wt),
|
||||
Some(Some(PathBuf::from("/repo")))
|
||||
);
|
||||
// …two independent branch lines.
|
||||
assert_eq!(cache.status_for(L, main).unwrap().branch, "main");
|
||||
assert_eq!(cache.status_for(L, wt).unwrap().branch, "feat/x");
|
||||
}
|
||||
/// The opportunistic path declines where the edge path queues: an in-flight
|
||||
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
|
||||
/// probe that just landed rate-limits the next one.
|
||||
#[test]
|
||||
fn throttled_probes_decline_instead_of_queueing() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -420,24 +272,15 @@ mod tests {
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
assert!(cache.begin_probe_throttled(L, cwd, gap));
|
||||
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
|
||||
// the landing reports "nothing pending" rather than asking for a rerun.
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
|
||||
// Landed just now: still inside the gap, so the next trigger is dropped.
|
||||
assert!(!cache.begin_probe_throttled(L, cwd, gap));
|
||||
// …but a zero gap always lets one through, and edge triggers never
|
||||
// consult the throttle at all.
|
||||
assert!(cache.begin_probe_throttled(L, cwd, Duration::ZERO));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
}
|
||||
|
||||
/// The throttle is per repo, not per cwd: panes sitting in different
|
||||
/// subdirectories ask one question (the counts are repo-wide), so once the
|
||||
/// cache knows where they live, a window activation costs one probe for
|
||||
/// the repo rather than one per pane.
|
||||
#[test]
|
||||
fn throttle_collapses_subdirectories_of_one_repo() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
@@ -448,25 +291,18 @@ mod tests {
|
||||
);
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
// Nothing known yet, so each cwd is its own key and each gets a probe.
|
||||
for cwd in [top, src, docs] {
|
||||
assert!(cache.begin_probe_throttled(L, cwd, gap));
|
||||
assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((3, 1))))));
|
||||
}
|
||||
|
||||
// Now all three resolve to `/repo`, so the next sweep collapses: the
|
||||
// first pane to ask spends the probe and the rest ride on it.
|
||||
assert!(!cache.begin_probe_throttled(L, top, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, src, gap));
|
||||
|
||||
// …and the claim itself is what stops the stampede — with the clock
|
||||
// wound back far enough to let one through, the *others* still decline
|
||||
// while it is in flight, even though nothing has landed yet.
|
||||
assert!(cache.begin_probe_throttled(L, docs, Duration::ZERO));
|
||||
assert!(!cache.begin_probe_throttled(L, top, gap));
|
||||
assert!(!cache.begin_probe_throttled(L, src, gap));
|
||||
|
||||
// A pane elsewhere is untouched by any of it.
|
||||
let other = Path::new("/other");
|
||||
assert!(cache.begin_probe_throttled(L, other, gap));
|
||||
}
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
//! A small shell-command syntax highlighter for the command editor — tty7's own
|
||||
//! highlighter, independent of any zsh highlighting plugin.
|
||||
//!
|
||||
//! It splits a line into contiguous spans whose concatenated text reproduces the
|
||||
//! input exactly (whitespace included), tagging each with a [`TokenKind`] the
|
||||
//! renderer maps to a color. The grammar is deliberately shallow — enough to
|
||||
//! color commands, arguments, flags, paths, quoted strings, operators and
|
||||
//! comments — not a real shell parser.
|
||||
|
||||
/// What a span of the command line represents, for coloring.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenKind {
|
||||
/// A command name: the first word, and the first word after a `|`/`&&`/`;`.
|
||||
Command,
|
||||
/// A plain argument.
|
||||
Arg,
|
||||
/// A `-f` / `--flag` option.
|
||||
Flag,
|
||||
/// A word containing `/` (treated as a path).
|
||||
Path,
|
||||
/// A single- or double-quoted string (quotes included).
|
||||
StringLit,
|
||||
/// A shell operator: `| & ; < >` (and runs like `&&`, `||`, `>>`).
|
||||
Operator,
|
||||
/// A `# …` comment to end of line.
|
||||
Comment,
|
||||
/// Inter-token whitespace (kept so spans tile the whole line).
|
||||
Whitespace,
|
||||
}
|
||||
|
||||
/// A contiguous run of the line with a single [`TokenKind`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub text: String,
|
||||
@@ -39,14 +20,11 @@ fn is_operator(c: char) -> bool {
|
||||
matches!(c, '|' | '&' | ';' | '<' | '>')
|
||||
}
|
||||
|
||||
/// Split `line` into colored spans. Concatenating the spans' `text` yields `line`.
|
||||
pub fn highlight(line: &str) -> Vec<Span> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut spans = Vec::new();
|
||||
let mut i = 0;
|
||||
// The next bare word is a command at the start of the line and right after a
|
||||
// pipe / list operator.
|
||||
let mut expect_command = true;
|
||||
|
||||
while i < n {
|
||||
@@ -65,7 +43,6 @@ pub fn highlight(line: &str) -> Vec<Span> {
|
||||
}
|
||||
|
||||
if c == '#' {
|
||||
// Comment to end of line.
|
||||
spans.push(Span {
|
||||
text: chars[i..].iter().collect(),
|
||||
kind: TokenKind::Comment,
|
||||
@@ -82,7 +59,7 @@ pub fn highlight(line: &str) -> Vec<Span> {
|
||||
text: chars[start..i].iter().collect(),
|
||||
kind: TokenKind::Operator,
|
||||
});
|
||||
expect_command = true; // a command follows the operator
|
||||
expect_command = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -94,7 +71,7 @@ pub fn highlight(line: &str) -> Vec<Span> {
|
||||
i += 1;
|
||||
}
|
||||
if i < n {
|
||||
i += 1; // include the closing quote
|
||||
i += 1;
|
||||
}
|
||||
spans.push(Span {
|
||||
text: chars[start..i].iter().collect(),
|
||||
@@ -104,7 +81,6 @@ pub fn highlight(line: &str) -> Vec<Span> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A bare word: up to the next whitespace / operator / quote / comment.
|
||||
let start = i;
|
||||
while i < n
|
||||
&& !chars[i].is_whitespace()
|
||||
@@ -141,7 +117,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Spans must tile the line exactly.
|
||||
fn assert_tiles(line: &str) {
|
||||
let joined: String = highlight(line).into_iter().map(|s| s.text).collect();
|
||||
assert_eq!(joined, line);
|
||||
@@ -160,10 +135,10 @@ mod tests {
|
||||
#[test]
|
||||
fn command_resets_after_pipe_and_operators() {
|
||||
let k = kinds("cat f | grep x");
|
||||
assert_eq!(k[0].1, TokenKind::Command); // cat
|
||||
assert_eq!(k[2].1, TokenKind::Arg); // f
|
||||
assert_eq!(k[4].1, TokenKind::Operator); // |
|
||||
assert_eq!(k[6].1, TokenKind::Command); // grep (command after pipe)
|
||||
assert_eq!(k[0].1, TokenKind::Command);
|
||||
assert_eq!(k[2].1, TokenKind::Arg);
|
||||
assert_eq!(k[4].1, TokenKind::Operator);
|
||||
assert_eq!(k[6].1, TokenKind::Command);
|
||||
assert_tiles("cat f | grep x");
|
||||
}
|
||||
|
||||
@@ -171,7 +146,7 @@ mod tests {
|
||||
fn paths_and_comments() {
|
||||
let k = kinds("ls src/main.rs # look");
|
||||
assert_eq!(k[0].1, TokenKind::Command);
|
||||
assert_eq!(k[2].1, TokenKind::Path); // src/main.rs
|
||||
assert_eq!(k[2].1, TokenKind::Path);
|
||||
assert!(k.iter().any(|(_, kind)| *kind == TokenKind::Comment));
|
||||
assert_tiles("ls src/main.rs # look");
|
||||
}
|
||||
@@ -193,14 +168,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_position_wins_over_flag_and_path_shapes() {
|
||||
// The first word is always a Command, even when it looks like a flag or
|
||||
// a path — command position takes precedence in the classifier.
|
||||
assert_eq!(kinds("-v")[0], ("-v".into(), TokenKind::Command));
|
||||
assert_eq!(
|
||||
kinds("./run.sh now")[0],
|
||||
("./run.sh".into(), TokenKind::Command)
|
||||
);
|
||||
// Off command position the same shapes classify as Flag / Path.
|
||||
let k = kinds("ls -v ./run.sh");
|
||||
assert_eq!(k[2].1, TokenKind::Flag);
|
||||
assert_eq!(k[4].1, TokenKind::Path);
|
||||
@@ -208,14 +180,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn leading_operator_and_quoted_first_word() {
|
||||
// An operator at the very start still tiles, and the word after it is a
|
||||
// command.
|
||||
let k = kinds("| grep x");
|
||||
assert_eq!(k[0], ("|".into(), TokenKind::Operator));
|
||||
assert_eq!(k[2].1, TokenKind::Command);
|
||||
assert_tiles("| grep x");
|
||||
// A quoted string in command position stays a StringLit (quotes are not
|
||||
// classified as commands), and the argument after it is a plain Arg.
|
||||
let k = kinds("'./a b' c");
|
||||
assert_eq!(k[0], ("'./a b'".into(), TokenKind::StringLit));
|
||||
assert_eq!(k[2].1, TokenKind::Arg);
|
||||
@@ -223,7 +191,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multibyte_text_tiles_exactly() {
|
||||
// Span boundaries are char-based; CJK args must reassemble losslessly.
|
||||
assert_tiles("echo 你好 世界 | grep 好");
|
||||
let k = kinds("echo 你好");
|
||||
assert_eq!(k[2], ("你好".into(), TokenKind::Arg));
|
||||
|
||||
+11
-185
@@ -1,48 +1,14 @@
|
||||
//! Persistent command history, shared across sessions.
|
||||
//!
|
||||
//! Stored as a newline-delimited file at `~/.config/tty7/history` (the same config
|
||||
//! dir as `config.json`), oldest first — simple, greppable, and good enough for
|
||||
//! ↑/↓ recall and Ctrl+R search without pulling in a database. Each terminal loads
|
||||
//! a snapshot on creation and appends as commands are submitted.
|
||||
//!
|
||||
//! Each new line is `<ts>\t<exit>\t<cwd>\t<command>` — when the command ran
|
||||
//! (unix seconds), the exit code of that run (empty while unknown: the record is
|
||||
//! written once the command finishes, but a pane can die before that), the
|
||||
//! working directory it ran in (empty when unusable), then the command itself
|
||||
//! (which may contain further tabs — it's the last field). The cwd feeds the
|
||||
//! frecency ranking; ts and exit feed the Ctrl+R menu's "ran 3h ago" / failure
|
||||
//! badges. Older `<cwd>\t<command>` lines and legacy bare commands still parse
|
||||
//! fine, just without the missing fields.
|
||||
//!
|
||||
//! On load we also seed from the user's real shell histories (`~/.zsh_history`,
|
||||
//! `~/.bash_history`, and `$HISTFILE`), so recall and completion work from the
|
||||
//! very first launch — before tty7 has accumulated a history of its own. Those
|
||||
//! files are read-only inputs; tty7 only ever writes its own file. zsh extended
|
||||
//! and bash `HISTTIMEFORMAT` timestamps are carried over when present.
|
||||
|
||||
use crate::core::config::config_path;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Keep at most this many entries when loading, so the file can't grow without
|
||||
/// bound across months of use (and so a huge shell history can't flood recall).
|
||||
const MAX_ENTRIES: usize = 5000;
|
||||
|
||||
/// Frequency weight in the frecency score: how much a command's repeat count
|
||||
/// matters relative to its recency. Recency contributes a normalized `0..1`
|
||||
/// (oldest..newest); `FREQ_WEIGHT * ln(1 + count)` adds the frequency boost on
|
||||
/// top, so a command run dozens of times outranks a once-typed recent line.
|
||||
const FREQ_WEIGHT: f64 = 0.6;
|
||||
|
||||
/// Bonus added when a command was previously run in the *current* working
|
||||
/// directory. Larger than recency's `0..1` range and on par with a ~7× frequency
|
||||
/// boost, so directory-local commands float up strongly without wholly drowning a
|
||||
/// very frequent global one (`git status`, `ls`, …).
|
||||
const CWD_BONUS: f64 = 1.2;
|
||||
|
||||
/// One history line as parsed from disk, before de-duplication: the command,
|
||||
/// plus whatever metadata its source format carried.
|
||||
struct Raw {
|
||||
cmd: String,
|
||||
cwd: Option<String>,
|
||||
@@ -61,20 +27,12 @@ impl Raw {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-known run metadata for one history line: when it last ran (unix
|
||||
/// seconds) and that run's exit code (`None` when the run never completed
|
||||
/// under tty7's watch — or predates exit tracking).
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub ts: Option<u64>,
|
||||
pub exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Loaded history: the unique command lines (oldest-first, the source for ↑/↓
|
||||
/// recall and Ctrl+R search), plus the extra dimensions ranking and the Ctrl+R
|
||||
/// menu need — per-line run `counts` (frequency), the set of directories each
|
||||
/// line was run in (`cwds`, so we can favour commands used *here*), and the
|
||||
/// last-run `meta` (timestamp + exit code) per line.
|
||||
pub struct History {
|
||||
pub entries: Vec<String>,
|
||||
pub counts: HashMap<String, u32>,
|
||||
@@ -82,15 +40,7 @@ pub struct History {
|
||||
pub meta: HashMap<String, EntryMeta>,
|
||||
}
|
||||
|
||||
/// Load history (oldest first), seeding from the user's shell histories and then
|
||||
/// tty7's own file. Blanks are dropped and duplicates collapsed (keeping the most
|
||||
/// recent occurrence), while occurrence counts, per-directory associations and
|
||||
/// last-run metadata are tallied for ranking and the Ctrl+R menu. Returns empty
|
||||
/// when nothing is readable.
|
||||
pub fn load() -> History {
|
||||
// Shell history first (older, so it sits at a lower completion priority than
|
||||
// commands actually run in tty7), then tty7's own file last (most recent).
|
||||
// Shell-history lines carry no cwd; tty7's own lines do.
|
||||
let mut raw: Vec<Raw> = load_shell_history();
|
||||
if let Some(path) = config_path("history")
|
||||
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||
@@ -100,26 +50,14 @@ pub fn load() -> History {
|
||||
normalize(raw)
|
||||
}
|
||||
|
||||
/// Whether `p` looks like an absolute path, recognizing **both** Unix (`/…`) and
|
||||
/// Windows (`C:\…`, `\\server\…`) forms regardless of the host platform. The
|
||||
/// std `Path::is_absolute` is host-specific (it rejects `/home/me` on Windows and
|
||||
/// `C:\…` on Unix), but a history file could have been written on either OS, and
|
||||
/// the `\t` tag separator can't appear in a path, so this lenient check is safe.
|
||||
fn looks_absolute(p: &str) -> bool {
|
||||
match p.as_bytes() {
|
||||
// Unix absolute, or a Windows rooted / UNC path.
|
||||
[b'/' | b'\\', ..] => true,
|
||||
// Windows drive path: `C:\`, `C:/`, or bare `C:`.
|
||||
[d, b':', ..] => d.is_ascii_alphabetic(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one line of tty7's own history file. Current lines are
|
||||
/// `<ts>\t<exit>\t<cwd>\t<command>` (ts all-digits; exit an integer or empty;
|
||||
/// cwd absolute or empty; the command — the last field — may itself contain
|
||||
/// tabs). Older `<cwd>\t<command>` lines and legacy bare commands still parse,
|
||||
/// carrying only the fields they have.
|
||||
fn parse_own_line(line: &str) -> Raw {
|
||||
let mut f = line.splitn(4, '\t');
|
||||
if let (Some(ts), Some(exit), Some(cwd), Some(cmd)) = (f.next(), f.next(), f.next(), f.next())
|
||||
@@ -148,11 +86,6 @@ fn parse_own_line(line: &str) -> Raw {
|
||||
Raw::bare(line.to_string())
|
||||
}
|
||||
|
||||
/// The frecency score of every entry (frequency × recency, plus a
|
||||
/// current-directory bonus), index-aligned with `entries`. Shared by
|
||||
/// [`rank_by_frecency`] and the Ctrl+R search's relevance blend. `entries` is
|
||||
/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd`
|
||||
/// is the directory to favour (none → no directory bonus).
|
||||
pub fn frecency_scores(
|
||||
entries: &[String],
|
||||
counts: &HashMap<String, u32>,
|
||||
@@ -164,8 +97,6 @@ pub fn frecency_scores(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
// Recency: 0 for the oldest entry, 1 for the newest (position in the
|
||||
// oldest-first list). Frequency: a diminishing-returns boost on count.
|
||||
let recency = if n <= 1 {
|
||||
1.0
|
||||
} else {
|
||||
@@ -173,7 +104,6 @@ pub fn frecency_scores(
|
||||
};
|
||||
let count = f64::from(*counts.get(e).unwrap_or(&1));
|
||||
let mut score = recency + FREQ_WEIGHT * (1.0 + count).ln();
|
||||
// Directory bonus: this command has been run here before.
|
||||
if let Some(cwd) = cwd
|
||||
&& cwds.get(e).is_some_and(|dirs| dirs.contains(cwd))
|
||||
{
|
||||
@@ -184,10 +114,6 @@ pub fn frecency_scores(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Order unique history entries by *frecency*, most relevant first — the
|
||||
/// ranking that drives ghost-text autosuggestion and the completion menu's
|
||||
/// history recalls, so neither surfaces stale junk just because it was typed
|
||||
/// once, recently. See [`frecency_scores`] for the inputs.
|
||||
pub fn rank_by_frecency(
|
||||
entries: &[String],
|
||||
counts: &HashMap<String, u32>,
|
||||
@@ -196,7 +122,6 @@ pub fn rank_by_frecency(
|
||||
) -> Vec<String> {
|
||||
let scores = frecency_scores(entries, counts, cwds, cwd);
|
||||
let mut idx: Vec<usize> = (0..entries.len()).collect();
|
||||
// Higher score first; ties broken toward the more recent entry.
|
||||
idx.sort_by(|&a, &b| {
|
||||
scores[b]
|
||||
.partial_cmp(&scores[a])
|
||||
@@ -206,8 +131,6 @@ pub fn rank_by_frecency(
|
||||
idx.into_iter().map(|i| entries[i].clone()).collect()
|
||||
}
|
||||
|
||||
/// Compact "how long ago" label for the Ctrl+R menu: `now` and `ts` are unix
|
||||
/// seconds. Coarse on purpose — the menu row has room for `3h`, not a date.
|
||||
pub fn format_ago(now: u64, ts: u64) -> String {
|
||||
let s = now.saturating_sub(ts);
|
||||
let (n, unit) = if s < 60 {
|
||||
@@ -228,12 +151,6 @@ pub fn format_ago(now: u64, ts: u64) -> String {
|
||||
format!("{n}{unit}")
|
||||
}
|
||||
|
||||
/// Append one command to the history file (best effort): `ts` is when it ran
|
||||
/// (unix seconds) and `exit` its exit code when the run completed under tty7's
|
||||
/// watch. The cwd is recorded when it's a usable absolute path — one that can't
|
||||
/// confuse the one-line format: no tab (the field separator) and no newline/CR
|
||||
/// (which would split the record across lines). Commands containing a newline
|
||||
/// are skipped, since the format is one-per-line.
|
||||
pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option<i32>) {
|
||||
if cmd.contains('\n') {
|
||||
return;
|
||||
@@ -256,20 +173,10 @@ pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option<i32>) {
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
// One `write_all` of the fully formatted record: `writeln!` on an
|
||||
// unbuffered `File` can issue the text and the trailing newline as
|
||||
// separate writes, and concurrent appenders (several panes, or several
|
||||
// tty7 processes sharing the file) then interleave half-records even
|
||||
// though O_APPEND keeps each individual write atomic.
|
||||
let _ = f.write_all(format!("{line}\n").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop blanks and de-duplicate (keeping the most recent occurrence, so recall
|
||||
/// and completion stay clean when shell history and tty7's own file overlap),
|
||||
/// tallying how many times each line appears, which directories it ran in, and
|
||||
/// its most recent run's metadata, then cap to the most recent `MAX_ENTRIES`.
|
||||
/// Output entries are oldest-first.
|
||||
fn normalize(raw: Vec<Raw>) -> History {
|
||||
let mut counts: HashMap<String, u32> = HashMap::new();
|
||||
let mut cwds: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
@@ -285,8 +192,6 @@ fn normalize(raw: Vec<Raw>) -> History {
|
||||
if let Some(cwd) = r.cwd {
|
||||
cwds.entry(line.to_string()).or_default().insert(cwd);
|
||||
}
|
||||
// Newest-first scan: the first occurrence carrying any run metadata is
|
||||
// the last known run — its ts and exit stay a matched pair.
|
||||
if (r.ts.is_some() || r.exit.is_some()) && !meta.contains_key(line) {
|
||||
meta.insert(
|
||||
line.to_string(),
|
||||
@@ -300,10 +205,9 @@ fn normalize(raw: Vec<Raw>) -> History {
|
||||
out.push(line.to_string());
|
||||
}
|
||||
}
|
||||
out.reverse(); // back to oldest-first
|
||||
out.reverse();
|
||||
if out.len() > MAX_ENTRIES {
|
||||
let cut = out.len() - MAX_ENTRIES;
|
||||
// Drop the over-cap entries from the companion maps too, keeping them bounded.
|
||||
for r in out.drain(0..cut) {
|
||||
counts.remove(&r);
|
||||
cwds.remove(&r);
|
||||
@@ -318,11 +222,6 @@ fn normalize(raw: Vec<Raw>) -> History {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the user's bash/zsh histories (best effort), returning command lines
|
||||
/// oldest-first. Reads the standard `~/.zsh_history` and `~/.bash_history` plus
|
||||
/// `$HISTFILE` if set, and orders the files by modification time so the
|
||||
/// most-recently-used shell's entries end up with the highest completion
|
||||
/// priority.
|
||||
fn load_shell_history() -> Vec<Raw> {
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -339,7 +238,6 @@ fn load_shell_history() -> Vec<Raw> {
|
||||
add(home.join(".zsh_history"));
|
||||
add(home.join(".bash_history"));
|
||||
}
|
||||
// Oldest-modified file first → newest last (highest recall/completion priority).
|
||||
files.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
@@ -349,27 +247,13 @@ fn load_shell_history() -> Vec<Raw> {
|
||||
let mut out = Vec::new();
|
||||
for path in files {
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
// History files can hold non-UTF-8 bytes (zsh metafies some); lossy
|
||||
// decoding keeps the rest usable.
|
||||
parse_shell_history(&String::from_utf8_lossy(&bytes), &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse one shell-history file into command lines, appending to `out`,
|
||||
/// carrying over the timestamps the file records: zsh's extended-format prefix
|
||||
/// (`: <start>:<elapsed>;cmd`) and bash's `HISTTIMEFORMAT` comment (`#<ts>` on
|
||||
/// the line *before* the command).
|
||||
///
|
||||
/// Each physical line becomes its own entry — we deliberately do *not* stitch
|
||||
/// backslash-continued multi-line commands back together. bash stores multi-line
|
||||
/// commands as separate lines anyway, and joining them would (a) embed newlines
|
||||
/// that wreck the single-line completion menu's layout and (b) on bash, wrongly
|
||||
/// swallow the following command. A few stray fragments from a zsh here-doc are a
|
||||
/// fair price for robustness.
|
||||
fn parse_shell_history(content: &str, out: &mut Vec<Raw>) {
|
||||
// A bash timestamp comment stamps the *next* command line.
|
||||
let mut pending_ts: Option<u64> = None;
|
||||
for raw in content.split('\n') {
|
||||
let line = raw.strip_suffix('\r').unwrap_or(raw);
|
||||
@@ -392,8 +276,6 @@ fn parse_shell_history(content: &str, out: &mut Vec<Raw>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bash `HISTTIMEFORMAT` timestamp comment (`#1700000000`), if that's what
|
||||
/// this line is. It carries no command itself — it stamps the following line.
|
||||
fn bash_timestamp(line: &str) -> Option<u64> {
|
||||
let rest = line.strip_prefix('#')?;
|
||||
if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) {
|
||||
@@ -402,16 +284,10 @@ fn bash_timestamp(line: &str) -> Option<u64> {
|
||||
rest.parse().ok()
|
||||
}
|
||||
|
||||
/// The command text at the start of a history line plus the zsh
|
||||
/// extended-history timestamp when the line carries one, or `None` for blank
|
||||
/// lines. Strips the `": <start>:<elapsed>;"` prefix when present.
|
||||
fn start_of_command(line: &str) -> Option<(&str, Option<u64>)> {
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// zsh extended history: ": 1700000000:0;the command". The timestamp field
|
||||
// must hold at least one digit — an empty/colon-only prefix would otherwise
|
||||
// match a *real* command like `: ;echo hi` and wrongly strip its head.
|
||||
if let Some(rest) = line.strip_prefix(": ")
|
||||
&& let Some(semi) = rest.find(';')
|
||||
{
|
||||
@@ -470,7 +346,6 @@ mod tests {
|
||||
[
|
||||
("ls -la".to_string(), Some(1_700_000_000)),
|
||||
("cd ..".to_string(), Some(1_700_000_005)),
|
||||
// No comment directly above → no timestamp bleeds over.
|
||||
("untimed".to_string(), None),
|
||||
]
|
||||
);
|
||||
@@ -478,9 +353,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multiline_commands_are_split_not_joined() {
|
||||
// We never stitch continuation lines together — each physical line is its
|
||||
// own entry, so no entry can carry an embedded newline (which would wreck
|
||||
// the single-line completion menu's layout).
|
||||
let content = ": 1700000000:0;for f in *; do\\\necho $f\\\ndone\n";
|
||||
let got = parse(content);
|
||||
assert_eq!(got, ["for f in *; do\\", "echo $f\\", "done"]);
|
||||
@@ -498,38 +370,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_own_line_reads_all_generations() {
|
||||
// Current format: ts, exit, cwd, command.
|
||||
let r = parse_own_line("1700000000\t0\t/home/me\tgit status");
|
||||
assert_eq!(r.cmd, "git status");
|
||||
assert_eq!(r.cwd.as_deref(), Some("/home/me"));
|
||||
assert_eq!(r.ts, Some(1_700_000_000));
|
||||
assert_eq!(r.exit, Some(0));
|
||||
// Exit unknown (pane died mid-command) and cwd unknown stay empty fields.
|
||||
let r = parse_own_line("1700000000\t\t\tmake");
|
||||
assert_eq!(
|
||||
(r.cmd.as_str(), r.cwd, r.ts, r.exit),
|
||||
("make", None, Some(1_700_000_000), None)
|
||||
);
|
||||
// The command is the last field, so its own tabs survive.
|
||||
let r = parse_own_line("1700000000\t1\t/a\techo\tfoo");
|
||||
assert_eq!(r.cmd, "echo\tfoo");
|
||||
assert_eq!(r.exit, Some(1));
|
||||
// Previous generation: `<cwd>\t<command>`.
|
||||
let r = parse_own_line("/home/me\tgit status");
|
||||
assert_eq!(
|
||||
(r.cmd.as_str(), r.cwd.as_deref(), r.ts),
|
||||
("git status", Some("/home/me"), None)
|
||||
);
|
||||
// Windows absolute cwd is recognized too (cross-platform, host-independent).
|
||||
let r = parse_own_line("C:\\Users\\me\tgit status");
|
||||
assert_eq!(r.cwd.as_deref(), Some("C:\\Users\\me"));
|
||||
// Legacy bare command — no tab, no metadata.
|
||||
let r = parse_own_line("ls -la");
|
||||
assert_eq!(
|
||||
(r.cmd.as_str(), r.cwd, r.ts, r.exit),
|
||||
("ls -la", None, None, None)
|
||||
);
|
||||
// A tab whose pre-part isn't an absolute path is not treated as a cwd.
|
||||
assert_eq!(parse_own_line("echo\tfoo").cmd, "echo\tfoo");
|
||||
}
|
||||
|
||||
@@ -539,11 +404,10 @@ mod tests {
|
||||
pair("ls", None),
|
||||
pair("", None),
|
||||
pair("cd /tmp", None),
|
||||
pair("ls", None), // later duplicate wins its (later) position
|
||||
pair("ls", None),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
assert_eq!(h.entries, ["cd /tmp", "ls"]);
|
||||
// Both occurrences of "ls" are counted, even though it appears once.
|
||||
assert_eq!(h.counts.get("ls"), Some(&2));
|
||||
assert_eq!(h.counts.get("cd /tmp"), Some(&1));
|
||||
}
|
||||
@@ -553,7 +417,7 @@ mod tests {
|
||||
let raw = vec![
|
||||
pair("make", Some("/a")),
|
||||
pair("make", Some("/b")),
|
||||
pair("make", Some("/a")), // same dir again — still just the set {/a, /b}
|
||||
pair("make", Some("/a")),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
let dirs = h.cwds.get("make").unwrap();
|
||||
@@ -573,8 +437,6 @@ mod tests {
|
||||
with_meta("make", 100, Some(2)),
|
||||
pair("ls", None),
|
||||
with_meta("make", 200, Some(0)),
|
||||
// The newest occurrence has no metadata (a shell-history duplicate):
|
||||
// the newest occurrence *with* metadata still wins.
|
||||
pair("make", None),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
@@ -585,14 +447,11 @@ mod tests {
|
||||
exit: Some(0)
|
||||
})
|
||||
);
|
||||
// No metadata anywhere → no entry.
|
||||
assert_eq!(h.meta.get("ls"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_ranks_frequent_over_merely_recent() {
|
||||
// `git status` is old but run many times; `oops typo` is the newest line
|
||||
// but a one-off. Frecency should float the frequent command above it.
|
||||
let entries = vec![
|
||||
"git status".to_string(),
|
||||
"ls".to_string(),
|
||||
@@ -612,19 +471,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn frecency_favours_commands_run_in_the_current_directory() {
|
||||
// Two equally rare, equally old commands; only `cargo build` has been run
|
||||
// in the current directory, so the cwd bonus lifts it above `npm test`.
|
||||
let entries = vec!["npm test".to_string(), "cargo build".to_string()];
|
||||
let counts = HashMap::new(); // both default to count 1
|
||||
let counts = HashMap::new();
|
||||
let mut cwds: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
cwds.entry("cargo build".to_string())
|
||||
.or_default()
|
||||
.insert("/work/proj".to_string());
|
||||
let ranked = rank_by_frecency(&entries, &counts, &cwds, Some("/work/proj"));
|
||||
assert_eq!(ranked[0], "cargo build");
|
||||
// Without the directory context, recency tie-break favours the newer entry.
|
||||
let neutral = rank_by_frecency(&entries, &counts, &cwds, None);
|
||||
assert_eq!(neutral[0], "cargo build"); // newest wins the tie either way
|
||||
assert_eq!(neutral[0], "cargo build");
|
||||
assert_eq!(neutral[1], "npm test");
|
||||
}
|
||||
|
||||
@@ -633,7 +489,6 @@ mod tests {
|
||||
let entries = vec!["a".to_string(), "b".to_string()];
|
||||
let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None);
|
||||
assert_eq!(scores.len(), 2);
|
||||
// Same count, so the newer entry scores strictly higher (recency).
|
||||
assert!(scores[1] > scores[0]);
|
||||
}
|
||||
|
||||
@@ -647,50 +502,40 @@ mod tests {
|
||||
assert_eq!(format_ago(now, now - 20 * 86_400), "2w");
|
||||
assert_eq!(format_ago(now, now - 90 * 86_400), "3mo");
|
||||
assert_eq!(format_ago(now, now - 800 * 86_400), "2y");
|
||||
// A clock that went backwards degrades to "now", never underflows.
|
||||
assert_eq!(format_ago(now, now + 100), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_absolute_recognizes_unix_and_windows_roots() {
|
||||
assert!(looks_absolute("/home/me"));
|
||||
assert!(looks_absolute("\\\\server\\share")); // UNC
|
||||
assert!(looks_absolute("C:\\Users")); // drive + backslash
|
||||
assert!(looks_absolute("D:/data")); // drive + forward slash
|
||||
assert!(looks_absolute("Z:")); // bare drive
|
||||
// Not absolute.
|
||||
assert!(looks_absolute("\\\\server\\share"));
|
||||
assert!(looks_absolute("C:\\Users"));
|
||||
assert!(looks_absolute("D:/data"));
|
||||
assert!(looks_absolute("Z:"));
|
||||
assert!(!looks_absolute("relative/path"));
|
||||
assert!(!looks_absolute("1:no")); // non-alpha "drive"
|
||||
assert!(!looks_absolute("1:no"));
|
||||
assert!(!looks_absolute(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_of_command_strips_prefixes_and_keeps_timestamps() {
|
||||
// zsh extended-history prefix is stripped, its start timestamp kept.
|
||||
assert_eq!(
|
||||
start_of_command(": 1700000000:0;git status"),
|
||||
Some(("git status", Some(1_700_000_000)))
|
||||
);
|
||||
// A colon-prefixed line whose middle isn't numeric is taken verbatim.
|
||||
assert_eq!(
|
||||
start_of_command(": not-a-ts;cmd"),
|
||||
Some((": not-a-ts;cmd", None))
|
||||
);
|
||||
// Regression: an empty or colon-only "timestamp" is not the zsh format —
|
||||
// the line is a real command (`: ;echo hi` runs the colon builtin, then
|
||||
// echo) and must NOT have its head stripped.
|
||||
assert_eq!(start_of_command(": ;echo hi"), Some((": ;echo hi", None)));
|
||||
assert_eq!(start_of_command(": :::;cmd"), Some((": :::;cmd", None)));
|
||||
// Blank → None.
|
||||
assert_eq!(start_of_command(""), None);
|
||||
// Plain command passes through.
|
||||
assert_eq!(start_of_command("ls -la"), Some(("ls -la", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_timestamp_recognizes_only_all_digit_comments() {
|
||||
assert_eq!(bash_timestamp("#1700000000"), Some(1_700_000_000));
|
||||
// A real comment-looking line with non-digits is a command, not a stamp.
|
||||
assert_eq!(bash_timestamp("#notdigits"), None);
|
||||
assert_eq!(bash_timestamp("#"), None);
|
||||
assert_eq!(bash_timestamp("ls"), None);
|
||||
@@ -698,28 +543,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn normalize_dedups_counts_and_caps_entries() {
|
||||
// Duplicates collapse to the most recent position, with a run count tallied.
|
||||
let raw = vec![
|
||||
pair("ls", Some("/a")),
|
||||
pair("git", None),
|
||||
pair("", None), // blank dropped
|
||||
pair("", None),
|
||||
pair("ls", Some("/b")),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
// "ls" moved to the end (most recent) and "git" stayed; blank gone.
|
||||
assert_eq!(h.entries, vec!["git".to_string(), "ls".to_string()]);
|
||||
assert_eq!(h.counts.get("ls"), Some(&2));
|
||||
// Both directories "ls" ran in are recorded.
|
||||
let dirs = h.cwds.get("ls").unwrap();
|
||||
assert!(dirs.contains("/a") && dirs.contains("/b"));
|
||||
|
||||
// The cap keeps only the most recent MAX_ENTRIES unique lines.
|
||||
let big: Vec<Raw> = (0..MAX_ENTRIES + 50)
|
||||
.map(|i| pair(&format!("cmd{i}"), None))
|
||||
.collect();
|
||||
let capped = normalize(big);
|
||||
assert_eq!(capped.entries.len(), MAX_ENTRIES);
|
||||
// The oldest were dropped; the newest survives.
|
||||
assert_eq!(
|
||||
capped.entries.last().unwrap(),
|
||||
&format!("cmd{}", MAX_ENTRIES + 49)
|
||||
@@ -728,13 +568,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn append_then_load_recovers_the_command_and_metadata() {
|
||||
// Pin the config dir so history writes to a temp file, not the real one.
|
||||
crate::core::config::pin_test_config_dir();
|
||||
|
||||
// A command with an embedded newline is rejected (one-per-line format).
|
||||
append("bad\ncmd", None, 1_700_000_000, None);
|
||||
|
||||
// A unique command tagged with cwd/ts/exit round-trips through load().
|
||||
let unique = format!("tty7_cov_marker_{}", std::process::id());
|
||||
append(&unique, Some(Path::new("/tmp")), 1_700_000_123, Some(1));
|
||||
let loaded = load();
|
||||
@@ -761,10 +598,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn concurrent_appends_never_interleave_records() {
|
||||
// Regression: `writeln!` on an unbuffered File could split one record
|
||||
// into two write syscalls (text, then newline), so two panes appending
|
||||
// at once produced fused half-lines ("cmdAcmdB\n\n") that loaded back
|
||||
// as garbage commands. Each record must land as one atomic write.
|
||||
crate::core::config::pin_test_config_dir();
|
||||
|
||||
let tag = format!("tty7_race_{}", std::process::id());
|
||||
@@ -801,10 +634,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn append_rejects_a_cwd_that_would_break_the_line_format() {
|
||||
// Regression: a cwd containing a newline used to be written verbatim into
|
||||
// the record, splitting it — the pre-newline half loaded back as a bogus
|
||||
// command and the real command gained a wrong cwd. Such a cwd is dropped
|
||||
// (empty field) so the record stays one line.
|
||||
crate::core::config::pin_test_config_dir();
|
||||
|
||||
let unique = format!("tty7_nlcwd_marker_{}", std::process::id());
|
||||
@@ -815,11 +644,8 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
let loaded = load();
|
||||
// The command itself survives…
|
||||
assert!(loaded.entries.iter().any(|e| e == &unique));
|
||||
// …with no cwd association (the unusable path was dropped, not split)…
|
||||
assert!(loaded.cwds.get(&unique).is_none_or(|d| d.is_empty()));
|
||||
// …and no half-a-path entry leaked in as a phantom command.
|
||||
assert!(!loaded.entries.iter().any(|e| e == "/tmp/evil"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,21 @@
|
||||
//! Client-side hold for keystrokes typed into the prompt→prompt gap.
|
||||
//!
|
||||
//! While a command runs (`at_prompt` false), typed bytes traditionally go
|
||||
//! straight to the PTY, where the kernel echoes them immediately — leaving
|
||||
//! `ls%`-style debris in the scrollback when the user types ahead of a fast
|
||||
//! command (`cd`, `ls`…). But those bytes can't just be swallowed either: a
|
||||
//! running command may be reading its stdin (a REPL, a password prompt).
|
||||
//!
|
||||
//! The compromise is a short hold: reconstructable gap input (printable text,
|
||||
//! Backspace) is captured client-side for up to the caller's dump window
|
||||
//! (~150 ms). If the editor engages first — the fast-command case — the held
|
||||
//! text is handed to it verbatim and the PTY never sees a byte: no echo, no
|
||||
//! wipe, pristine scrollback. If the window lapses — a long command, or a
|
||||
//! program actually reading stdin — the bytes are released to the PTY exactly
|
||||
//! as typed, and the rest of the gap is raw passthrough so interactive
|
||||
//! programs feel no further delay. Unreconstructable input (arrows, chords,
|
||||
//! Enter, multi-line pastes) releases the hold immediately and passes
|
||||
//! through, preserving byte order.
|
||||
//!
|
||||
//! The struct is pure state — no timers, no PTY. The caller arms a timer when
|
||||
//! a hold window opens (`Verdict::Held(Some(epoch))`) and calls [`GapHold::timeout`]
|
||||
//! when it fires; the epoch makes a late timer firing after engage/release a
|
||||
//! no-op. Two views of the held input are kept: `net`, the backspace-folded
|
||||
//! text the editor (or the typeahead record) adopts, and `bytes`, the raw
|
||||
//! stream a dump writes — zle folds backspaces the same way, so both views
|
||||
//! converge on the same line.
|
||||
|
||||
/// What the hold decided to do with one gap-input event.
|
||||
pub enum Verdict {
|
||||
/// Captured client-side; nothing reaches the PTY for now. `Some(epoch)` on
|
||||
/// the event that opened the window — the caller starts the dump timer
|
||||
/// with it.
|
||||
Held(Option<u64>),
|
||||
/// The gap already went raw (a dump or release happened); the caller
|
||||
/// writes the event to the PTY itself, as before holds existed.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum State {
|
||||
/// No gap input seen since the last engage.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Input is being held, dump timer running.
|
||||
Holding,
|
||||
/// The hold was dumped/released this gap; further input goes raw.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
/// Held gap input. One per pane view; reset by [`GapHold::engage`] whenever
|
||||
/// the line editor takes over.
|
||||
#[derive(Default)]
|
||||
pub struct GapHold {
|
||||
state: State,
|
||||
/// Backspace-folded text, as the editor would end up showing it.
|
||||
net: String,
|
||||
/// The raw byte stream exactly as typed — what a dump writes to the PTY.
|
||||
bytes: Vec<u8>,
|
||||
/// Bumped when a window opens; a dump timer carries its window's epoch so
|
||||
/// firing after engage (or after an earlier dump) is a no-op.
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
@@ -66,14 +24,10 @@ impl GapHold {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Offer printable text (IME commit, single-line paste) to the hold.
|
||||
pub fn hold_text(&mut self, s: &str, bytes: &[u8]) -> Verdict {
|
||||
self.hold(bytes, |net| net.push_str(s))
|
||||
}
|
||||
|
||||
/// Offer a plain Backspace to the hold. Folds the last held char off
|
||||
/// `net`; on an empty hold there is nothing shell-side to erase either
|
||||
/// (nothing was dumped), so the fold simply stays empty.
|
||||
pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict {
|
||||
self.hold(bytes, |net| {
|
||||
net.pop();
|
||||
@@ -96,10 +50,6 @@ impl GapHold {
|
||||
}
|
||||
}
|
||||
|
||||
/// An unreconstructable event (arrow, chord, Enter, multi-line paste) is
|
||||
/// about to be written raw: release whatever is held so it precedes that
|
||||
/// event on the wire, and switch the rest of the gap to passthrough.
|
||||
/// Returns `(folded_text, raw_bytes)` for the caller to write and record.
|
||||
pub fn release(&mut self) -> Option<(String, Vec<u8>)> {
|
||||
let held = matches!(self.state, State::Holding);
|
||||
self.state = State::Passthrough;
|
||||
@@ -111,8 +61,6 @@ impl GapHold {
|
||||
})
|
||||
}
|
||||
|
||||
/// The dump timer for `epoch` fired: if that window is still open, release
|
||||
/// it (the command is taking long / reading stdin — the bytes must flow).
|
||||
pub fn timeout(&mut self, epoch: u64) -> Option<(String, Vec<u8>)> {
|
||||
if matches!(self.state, State::Holding) && epoch == self.epoch {
|
||||
self.release()
|
||||
@@ -121,9 +69,6 @@ impl GapHold {
|
||||
}
|
||||
}
|
||||
|
||||
/// The line editor engaged: whatever is still held goes to it (the PTY
|
||||
/// never saw those bytes, so there is nothing to wipe), and the next gap
|
||||
/// starts from a clean slate.
|
||||
pub fn engage(&mut self) -> Option<String> {
|
||||
self.state = State::Idle;
|
||||
self.bytes.clear();
|
||||
@@ -139,15 +84,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fast_command_gap_replays_into_the_editor_and_never_touches_the_pty() {
|
||||
let mut h = GapHold::new();
|
||||
// The first held key opens the window (the caller arms the timer)...
|
||||
assert!(matches!(h.hold_text("l", b"l"), Verdict::Held(Some(_))));
|
||||
// ...later keys ride the same window.
|
||||
assert!(matches!(h.hold_text("s", b"s"), Verdict::Held(None)));
|
||||
// The command finished inside the window: everything goes to the
|
||||
// editor; the PTY never saw a byte, so nothing echoes, nothing needs
|
||||
// a wipe.
|
||||
assert_eq!(h.engage(), Some("ls".to_string()));
|
||||
// The gap is over; the next one starts from a clean slate.
|
||||
assert_eq!(h.engage(), None);
|
||||
}
|
||||
|
||||
@@ -158,16 +97,9 @@ mod tests {
|
||||
panic!("first key should open a window");
|
||||
};
|
||||
assert!(matches!(h.hold_text("s", b"s"), Verdict::Held(None)));
|
||||
// The window lapsed (long command / stdin reader): the raw bytes are
|
||||
// released for the PTY, with the folded text for the typeahead record.
|
||||
assert_eq!(h.timeout(epoch), Some(("ls".to_string(), b"ls".to_vec())));
|
||||
// The same timer can't fire twice…
|
||||
assert_eq!(h.timeout(epoch), None);
|
||||
// …and the rest of the gap is raw passthrough — no added latency for
|
||||
// whatever is reading stdin now.
|
||||
assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough));
|
||||
// Nothing left for the editor; the next gap opens a fresh window with
|
||||
// a fresh epoch.
|
||||
assert_eq!(h.engage(), None);
|
||||
let Verdict::Held(Some(e2)) = h.hold_text("a", b"a") else {
|
||||
panic!("fresh gap should hold again");
|
||||
@@ -182,8 +114,6 @@ mod tests {
|
||||
panic!("first key should open a window");
|
||||
};
|
||||
assert_eq!(h.engage(), Some("l".to_string()));
|
||||
// The timer fires late, after the editor already adopted the text —
|
||||
// dumping now would type a stray "l" at the prompt.
|
||||
assert_eq!(h.timeout(epoch), None);
|
||||
}
|
||||
|
||||
@@ -191,13 +121,9 @@ mod tests {
|
||||
fn unreconstructable_input_releases_the_hold_in_typed_order() {
|
||||
let mut h = GapHold::new();
|
||||
h.hold_text("ls", b"ls");
|
||||
// An arrow / chord / Enter can't be replayed into the editor: what's
|
||||
// held is released first (the caller writes it, then the event's own
|
||||
// bytes — FIFO preserved), and the gap goes raw.
|
||||
assert_eq!(h.release(), Some(("ls".to_string(), b"ls".to_vec())));
|
||||
assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough));
|
||||
|
||||
// With nothing held, release still switches to passthrough, silently.
|
||||
let mut h = GapHold::new();
|
||||
assert_eq!(h.release(), None);
|
||||
assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough));
|
||||
@@ -205,15 +131,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn backspace_folds_for_the_editor_but_dumps_verbatim() {
|
||||
// Editor path: the fold applies, exactly like zle would.
|
||||
let mut h = GapHold::new();
|
||||
h.hold_text("lss", b"lss");
|
||||
assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(None)));
|
||||
assert_eq!(h.engage(), Some("ls".to_string()));
|
||||
|
||||
// Dump path: the PTY gets the stream exactly as typed (text + 0x7f);
|
||||
// the record seed uses the folded text — zle folds the same way, so
|
||||
// both views converge on the same line.
|
||||
let mut h = GapHold::new();
|
||||
let Verdict::Held(Some(e)) = h.hold_text("lss", b"lss") else {
|
||||
panic!("first key should open a window");
|
||||
@@ -221,9 +143,6 @@ mod tests {
|
||||
h.hold_backspace(b"\x7f");
|
||||
assert_eq!(h.timeout(e), Some(("ls".to_string(), b"lss\x7f".to_vec())));
|
||||
|
||||
// A backspace with nothing held folds to nothing, and there is
|
||||
// nothing shell-side to erase either (nothing was dumped): it simply
|
||||
// vanishes instead of reaching the PTY.
|
||||
let mut h = GapHold::new();
|
||||
assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(Some(_))));
|
||||
assert_eq!(h.engage(), None);
|
||||
|
||||
+3
-325
@@ -1,32 +1,14 @@
|
||||
//! Keyboard input for the terminal view: translating GPUI keystrokes into the
|
||||
//! byte sequences a PTY expects, and bridging the platform IME (NSTextInputClient
|
||||
//! on macOS) so CJK and dead-key input composes and commits into the terminal.
|
||||
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use gpui::{App, Bounds, InputHandler, Pixels, UTF16Selection, Window};
|
||||
|
||||
use super::view::TerminalView;
|
||||
// Only the macOS Option/Meta split reads config from this file; elsewhere the import
|
||||
// would be dead.
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::core::config::Config;
|
||||
|
||||
/// The Kitty keyboard-protocol progressive-enhancement flags currently active in
|
||||
/// the terminal, distilled from `TermMode`. We read them straight off the client's
|
||||
/// local `Term` (which the reader thread advances over *all* child output, so its
|
||||
/// mode bits already reflect every `CSI = flags u` push/pop the app sent — the fork
|
||||
/// runs that state machine for us). Only the bits the encoder actually consults are
|
||||
/// kept, so the struct stays small and `Copy`.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct KittyFlags {
|
||||
/// `DISAMBIGUATE_ESC_CODES` (level 1): escape otherwise-ambiguous keys
|
||||
/// (Tab vs Ctrl+I, Esc, Ctrl+letter, …) as `CSI … u`.
|
||||
disambiguate: bool,
|
||||
/// `REPORT_ALL_KEYS_AS_ESC`: encode *every* key as `CSI … u`, including plain
|
||||
/// text keys — not just the ambiguous ones.
|
||||
report_all_keys: bool,
|
||||
/// `REPORT_ASSOCIATED_TEXT`: include the produced text as a third `CSI u`
|
||||
/// field, so full-mode apps still receive the character.
|
||||
report_text: bool,
|
||||
}
|
||||
|
||||
@@ -39,33 +21,11 @@ impl KittyFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any level of the protocol is active (so the encoder should run).
|
||||
pub(super) fn active(self) -> bool {
|
||||
self.disambiguate || self.report_all_keys
|
||||
}
|
||||
}
|
||||
|
||||
/// Reshape a keystroke according to the macOS Option-key policy, before any
|
||||
/// encoding runs. macOS gives Option two jobs that a terminal can't serve at
|
||||
/// once: the OS composes a special character (Option+B types `∫`, delivered in
|
||||
/// `key_char`), while Meta bindings need an ESC-prefixed chord (Option+B →
|
||||
/// `ESC b`, readline's backward-word). `Config::macos_option_as_alt` picks:
|
||||
///
|
||||
/// * **On** — the chord is Meta: `key_char` is replaced with the plain key
|
||||
/// (uppercased under Shift, matching xterm's `metaSendsEscape` output), so
|
||||
/// the legacy encoder's Alt branch emits `ESC` + the base character instead
|
||||
/// of `ESC` + the composed one.
|
||||
/// * **Off** (default) — the chord is text input: the alt bit is dropped so the
|
||||
/// composed character is sent bare. (Without this, the legacy encoder bolts
|
||||
/// an ESC prefix onto the composed char — `ESC ∫` — a sequence that is wrong
|
||||
/// under either reading; and the prompt editor swallows the chord entirely.)
|
||||
///
|
||||
/// Only Option chords that produce a single text key are reshaped: named keys
|
||||
/// (arrows, Enter, …) and Ctrl/Cmd combinations keep their existing encodings
|
||||
/// on both settings. Returns `None` when the keystroke needs no reshaping, so
|
||||
/// callers only clone on the affected chords. Callers gate on macOS — the
|
||||
/// composed-character split doesn't exist elsewhere — but the function itself
|
||||
/// is platform-neutral so it can be tested everywhere.
|
||||
pub(super) fn reshape_option_keystroke(
|
||||
ks: &gpui::Keystroke,
|
||||
option_as_alt: bool,
|
||||
@@ -75,30 +35,23 @@ pub(super) fn reshape_option_keystroke(
|
||||
return None;
|
||||
}
|
||||
if option_as_alt {
|
||||
// Meta semantics: the byte after ESC must be the key itself. Only
|
||||
// single-character keys compose; named keys already encode off `key`.
|
||||
let mut chars = ks.key.chars();
|
||||
let base = chars.next()?;
|
||||
if chars.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
// gpui reports shifted letters as a lowercase key + the shift bit;
|
||||
// Meta follows the shifted character (Option+Shift+B → `ESC B`).
|
||||
let ch = if m.shift {
|
||||
base.to_uppercase().to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
if ks.key_char.as_deref() == Some(ch.as_str()) {
|
||||
return None; // already the base character — nothing to reshape
|
||||
return None;
|
||||
}
|
||||
let mut out = ks.clone();
|
||||
out.key_char = Some(ch);
|
||||
Some(out)
|
||||
} else {
|
||||
// macOS convention: the chord is ordinary text input. A chord that
|
||||
// composed no printable text (named keys, Enter's "\n") stays a real
|
||||
// Alt chord — dropping alt there would break Alt+arrow and friends.
|
||||
let ch = ks.key_char.as_deref()?;
|
||||
if ch.is_empty() || ch.chars().any(|c| c < '\u{20}' || c == '\u{7f}') {
|
||||
return None;
|
||||
@@ -109,37 +62,6 @@ pub(super) fn reshape_option_keystroke(
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a keystroke is ordinary text that macOS should deliver through the
|
||||
/// input context (`insertText:` → `replace_text_in_range` → `commit_text`)
|
||||
/// rather than the raw `key_char` path.
|
||||
///
|
||||
/// gpui derives `key_char` by running the event's *virtual keycode* back through
|
||||
/// the current layout (`chars_for_modified_key` in its macOS backend); it never
|
||||
/// reads the event's Unicode payload. That is fine for a physical keyboard, where
|
||||
/// the keycode is the truth, but wrong for any event whose text lives only in the
|
||||
/// payload — notably remote-control apps, which synthesize keystrokes as
|
||||
/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`.
|
||||
/// Keycode 0 is `a`, so every remotely typed character arrived as `a`.
|
||||
///
|
||||
/// gpui already diverts printable keys to the input context, but only while a
|
||||
/// composing input source is active (`is_ime_input_source_active`), so the bug
|
||||
/// appeared and vanished depending on which input method was selected — and the
|
||||
/// plain ABC layout, the macOS default, always lost the text. Declining the key
|
||||
/// here instead makes the IME the single delivery path for text on macOS: gpui
|
||||
/// falls through to `handleEvent:`, and the Unicode payload survives.
|
||||
///
|
||||
/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below,
|
||||
/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy.
|
||||
///
|
||||
/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as
|
||||
/// `CSI <code>;<mods>[;<text>]u`, and the IME path terminates in
|
||||
/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that
|
||||
/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get
|
||||
/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`]
|
||||
/// declines unmodified text keys there, so the IME route is equivalent.
|
||||
///
|
||||
/// Compiled under `test` on every platform so the routing rule is covered by
|
||||
/// CI everywhere, not just on the macOS runner.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool {
|
||||
if kitty.report_all_keys {
|
||||
@@ -154,41 +76,13 @@ pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool {
|
||||
.is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}'))
|
||||
}
|
||||
|
||||
/// Whether an Option chord must be kept away from the IME so the Meta policy in
|
||||
/// [`reshape_option_keystroke`] can claim it.
|
||||
///
|
||||
/// macOS counts ⌥-chords as printable text — ⌥B composes `∫` — so while a CJK input
|
||||
/// source is active gpui routes them to the IME before the key handler ever runs. The
|
||||
/// IME commits the composed character and swallows the event, and Option-as-Meta
|
||||
/// silently does nothing (#177). This is the predicate
|
||||
/// [`TerminalInputHandler::prefers_ime_for_printable_keys`] answers `false` on.
|
||||
///
|
||||
/// Only ⌥ alone (optionally with Shift) counts: ⌘ chords are app shortcuts and Ctrl
|
||||
/// chords already bypass the IME upstream, and both keep their existing routing.
|
||||
///
|
||||
/// With the setting off the chord is text input and the IME is the right owner — it is
|
||||
/// what makes dead keys (⌥E then E → `é`) compose at all — so this returns `false` and
|
||||
/// nothing changes.
|
||||
///
|
||||
/// Compiled under `test` on every platform so CI covers the rule everywhere, not just
|
||||
/// on the macOS runner.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(super) fn meta_chord_bypasses_ime(ks: &gpui::Keystroke, option_as_alt: bool) -> bool {
|
||||
let m = &ks.modifiers;
|
||||
option_as_alt && m.alt && !m.platform && !m.control
|
||||
}
|
||||
|
||||
/// Translate a GPUI keystroke into the bytes a PTY expects.
|
||||
///
|
||||
/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try
|
||||
/// the `CSI u` encoder first; anything it declines to encode (plain text keys at the
|
||||
/// disambiguate level, keys it doesn't special-case) falls through to the *unchanged*
|
||||
/// legacy path. So with the protocol off — the overwhelmingly common case — the
|
||||
/// output is byte-for-byte identical to before.
|
||||
pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
// Cmd (platform) chords are app-shortcut territory, resolved before we get here;
|
||||
// never Kitty-encode them, so their behavior is unchanged whether or not the
|
||||
// protocol is on.
|
||||
if kitty.active() && !ks.modifiers.platform {
|
||||
if let Some(bytes) = encode_kitty(ks, kitty) {
|
||||
return Some(bytes);
|
||||
@@ -197,17 +91,8 @@ pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, kitty: KittyFlags) -> Opt
|
||||
legacy_keystroke_to_bytes(ks)
|
||||
}
|
||||
|
||||
/// Bytes for a Tab / Shift-Tab press. These keys reach the PTY through the
|
||||
/// `SendTab` / `SendBackTab` actions (not `on_key_down`), so the Kitty encoding
|
||||
/// lives here rather than in [`encode_kitty`]. Mirrors the encoder's rule for the
|
||||
/// legacy control keys: plain unmodified Tab stays legacy `\t` even under
|
||||
/// DISAMBIGUATE (so a shell survives a crashed TUI leaving the mode on); it
|
||||
/// becomes `CSI 9 u` only when Shift makes it ambiguous or REPORT_ALL_KEYS_AS_ESC
|
||||
/// escapes every key. Back-tab keeps its legacy `CSI Z` form when the protocol is
|
||||
/// off.
|
||||
pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec<u8> {
|
||||
if kitty.active() && (shift || kitty.report_all_keys) {
|
||||
// Shift adds the modifier subfield (mods = 1 + shift = 2).
|
||||
if shift {
|
||||
b"\x1b[9;2u".to_vec()
|
||||
} else {
|
||||
@@ -220,22 +105,8 @@ pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kitty keyboard-protocol (`CSI u`) encoder. Covers the DISAMBIGUATE_ESC_CODES
|
||||
/// level well, plus enough of REPORT_ALL_KEYS_AS_ESC / REPORT_ASSOCIATED_TEXT to be
|
||||
/// usable at the full level. Returns `None` for keys it deliberately leaves to the
|
||||
/// legacy path — chiefly plain text keys at the disambiguate level, which must still
|
||||
/// be sent as raw UTF-8.
|
||||
///
|
||||
/// Spec: <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>
|
||||
///
|
||||
/// TODO: REPORT_EVENT_TYPES (press/repeat/release event-type subfield) and
|
||||
/// REPORT_ALTERNATE_KEYS (shifted / base-layout alternate key codes) are not encoded
|
||||
/// yet — we report key *presses* at the primary code only. That's a safe subset:
|
||||
/// apps degrade to press-only behavior rather than misbehaving.
|
||||
fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
let m = &ks.modifiers;
|
||||
// Modifier bitmask per spec: value = 1 + shift(1) + alt(2) + ctrl(4) + super(8).
|
||||
// Super (Cmd) is intentionally excluded — platform chords never reach here.
|
||||
let mut mods = 1u32;
|
||||
if m.shift {
|
||||
mods += 1;
|
||||
@@ -247,18 +118,10 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
mods += 4;
|
||||
}
|
||||
|
||||
// Escape is disambiguated to `CSI 27 u` whenever the protocol is active — that
|
||||
// is the whole point of DISAMBIGUATE_ESC_CODES (tell a plain Esc apart from an
|
||||
// escape-sequence introducer).
|
||||
if ks.key.as_str() == "escape" {
|
||||
return Some(csi_u(27, mods, None));
|
||||
}
|
||||
|
||||
// Enter / Tab / Backspace are the three legacy control keys the spec keeps as
|
||||
// plain `\r` / `\t` / 0x7f under DISAMBIGUATE alone, so a shell stays usable if
|
||||
// a crashed app leaves the mode on (typing `reset⏎` must still send a real CR).
|
||||
// They escalate to `CSI u` only when a modifier makes them ambiguous, or under
|
||||
// REPORT_ALL_KEYS_AS_ESC (which reports *every* key as an escape code).
|
||||
let legacy_ctrl_code = match ks.key.as_str() {
|
||||
"enter" => Some(13u32),
|
||||
"tab" => Some(9),
|
||||
@@ -267,21 +130,15 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
};
|
||||
if let Some(code) = legacy_ctrl_code {
|
||||
if mods == 1 && !kitty.report_all_keys {
|
||||
return None; // unmodified at the disambiguate level → legacy path
|
||||
return None;
|
||||
}
|
||||
return Some(csi_u(code, mods, None));
|
||||
}
|
||||
|
||||
// Functional keys encoded in the legacy CSI layout (letter- or tilde-suffixed).
|
||||
// The Kitty protocol keeps these forms and just adds the modifier subfield.
|
||||
if let Some(seq) = kitty_functional(ks.key.as_str(), mods) {
|
||||
return Some(seq);
|
||||
}
|
||||
|
||||
// Text-producing keys. At the disambiguate level these are only escaped when a
|
||||
// Ctrl/Alt modifier makes them ambiguous (e.g. Ctrl+I vs Tab); otherwise we
|
||||
// return None so the legacy path sends the raw character. With
|
||||
// REPORT_ALL_KEYS_AS_ESC, every text key is escaped.
|
||||
let modified = m.control || m.alt;
|
||||
if modified || kitty.report_all_keys {
|
||||
if let Some(code) = text_key_code(ks) {
|
||||
@@ -293,9 +150,6 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a `CSI <code> ; <mods> [; <text>] u` sequence. The modifier subfield is
|
||||
/// omitted when it's the default (1) and there's no text; when text is present the
|
||||
/// (possibly-default) modifier subfield must be kept so the text lands in field 3.
|
||||
fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> {
|
||||
let mut s = format!("\x1b[{code}");
|
||||
match text {
|
||||
@@ -310,10 +164,6 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> {
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// Kitty encoding for the CSI-layout functional keys (arrows / Home / End as
|
||||
/// `CSI [1;mods] letter`, Insert / Delete / Page keys as `CSI n[;mods] ~`). Returns
|
||||
/// `None` for keys handled elsewhere. With no modifiers these collapse to exactly
|
||||
/// the legacy forms, so unmodified navigation is unchanged.
|
||||
fn kitty_functional(key: &str, mods: u32) -> Option<Vec<u8>> {
|
||||
let letter = match key {
|
||||
"up" => Some('A'),
|
||||
@@ -350,9 +200,6 @@ fn kitty_functional(key: &str, mods: u32) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The primary Kitty key code for a text-producing key: the Unicode codepoint of the
|
||||
/// key's *unshifted* value (lowercased for ASCII letters), per the spec. `None` for
|
||||
/// multi-character named keys (which aren't single text keys).
|
||||
fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> {
|
||||
match ks.key.as_str() {
|
||||
"space" => Some(0x20),
|
||||
@@ -360,24 +207,15 @@ fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> {
|
||||
let mut chars = key.chars();
|
||||
let c = chars.next()?;
|
||||
if chars.next().is_some() {
|
||||
return None; // a multi-char key name, not a single text key
|
||||
return None;
|
||||
}
|
||||
Some(c.to_ascii_lowercase() as u32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The associated text (field 3) for REPORT_ASSOCIATED_TEXT: the codepoints of the
|
||||
/// character(s) the key would produce, or `None` when it produces none (e.g. a
|
||||
/// control chord) so the field is omitted.
|
||||
fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> {
|
||||
let ch = ks.key_char.as_deref()?;
|
||||
// Drop control codes: the Kitty spec requires the associated-text field to
|
||||
// contain no control characters — "code points below U+0020 and codepoints in
|
||||
// the C0 and C1 blocks". That's C0 (< 0x20) plus DEL (0x7f) and the C1 block
|
||||
// (0x80..=0x9f); leaving those in would emit a control codepoint a conformant
|
||||
// receiver must reject. A control chord's "char" carries no meaningful text
|
||||
// anyway, so filtering them just omits the field.
|
||||
let cps: Vec<u32> = ch
|
||||
.chars()
|
||||
.map(|c| c as u32)
|
||||
@@ -386,13 +224,10 @@ fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> {
|
||||
(!cps.is_empty()).then_some(cps)
|
||||
}
|
||||
|
||||
/// The legacy (pre-Kitty) keystroke encoding. Untouched from the original
|
||||
/// `keystroke_to_bytes` body, so behavior with the Kitty protocol off is unchanged.
|
||||
fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
|
||||
// Control combinations → C0 control bytes.
|
||||
if m.control && !m.platform {
|
||||
let b = match key {
|
||||
"space" | "2" => Some(0x00),
|
||||
@@ -428,11 +263,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = b {
|
||||
// Alt (Meta) held with the Ctrl chord prefixes ESC, matching xterm's
|
||||
// metaSendsEscape (default on) and the Alt handling in the special-key
|
||||
// and printable branches below — so `Ctrl+Alt+c` sends `\x1b\x03`, not a
|
||||
// bare `\x03` that's indistinguishable from plain Ctrl+C. Without this,
|
||||
// `M-C-<key>` bindings (Emacs, readline, tmux) silently lose the Meta bit.
|
||||
if m.alt {
|
||||
return Some(vec![0x1b, b]);
|
||||
}
|
||||
@@ -440,7 +270,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Named / special keys.
|
||||
let seq: Option<&[u8]> = match key {
|
||||
"enter" => Some(b"\r"),
|
||||
"tab" => Some(b"\t"),
|
||||
@@ -459,7 +288,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(seq) = seq {
|
||||
// Alt + special key → ESC prefix.
|
||||
if m.alt {
|
||||
let mut v = vec![0x1b];
|
||||
v.extend_from_slice(seq);
|
||||
@@ -468,7 +296,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
return Some(seq.to_vec());
|
||||
}
|
||||
|
||||
// Printable text. Ignore when Cmd is held (app shortcut territory).
|
||||
if m.platform {
|
||||
return None;
|
||||
}
|
||||
@@ -485,16 +312,8 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Bridges the platform IME (NSTextInputClient on macOS) to the terminal.
|
||||
///
|
||||
/// Without this, a CJK input method's composed text is never delivered: pinyin
|
||||
/// keystrokes leak through as raw latin and the committed characters go nowhere.
|
||||
/// `prefers_ime_for_printable_keys` is the crucial bit — it tells GPUI to route
|
||||
/// printable keys to the IME first when a non-ASCII input source is active, so
|
||||
/// composition actually starts.
|
||||
pub struct TerminalInputHandler {
|
||||
view: gpui::Entity<TerminalView>,
|
||||
/// Cursor cell bounds in window coordinates, for placing the candidate window.
|
||||
cursor_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
@@ -596,17 +415,9 @@ impl InputHandler for TerminalInputHandler {
|
||||
}
|
||||
|
||||
fn apple_press_and_hold_enabled(&mut self) -> bool {
|
||||
// A terminal wants auto-repeat, not the accent palette: holding `j` in
|
||||
// vim scrolls, it does not offer `ĵ`. This used to be moot because
|
||||
// `on_key_down` consumed printable keys before gpui consulted it; now
|
||||
// that text defers to the IME (see `defer_to_ime`), gpui reaches its
|
||||
// held-key branch, and answering `false` there makes it repeat the
|
||||
// character instead of handing the key to press-and-hold.
|
||||
false
|
||||
}
|
||||
|
||||
// `keystroke` only feeds the macOS Option/Meta split; elsewhere Alt already carries
|
||||
// Meta and never reaches an IME.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn prefers_ime_for_printable_keys(
|
||||
&mut self,
|
||||
@@ -614,48 +425,16 @@ impl InputHandler for TerminalInputHandler {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
// An Option chord under Option-as-Meta belongs to `reshape_option_keystroke`,
|
||||
// not the IME — see `meta_chord_bypasses_ime`. Answering per keystroke is why
|
||||
// tty7 carries a gpui patch: upstream asks this once per view, with no key in
|
||||
// hand, so it cannot say "IME for text, but not for this chord".
|
||||
#[cfg(target_os = "macos")]
|
||||
if meta_chord_bypasses_ime(keystroke, cx.global::<Config>().macos_option_as_alt) {
|
||||
return false;
|
||||
}
|
||||
// REPORT_ALL_KEYS_AS_ESC wants every key as `CSI <code>;<mods>[;<text>]u`,
|
||||
// which only `keystroke_to_bytes` produces — the IME path commits raw
|
||||
// UTF-8. Keep printable keys on the dispatch path so they get encoded,
|
||||
// matching the same gate in `on_key_down`. CJK composition and "escape
|
||||
// every key" are mutually exclusive by construction; an app that asks
|
||||
// for the latter gets it.
|
||||
if self.view.read(cx).kitty_flags().report_all_keys {
|
||||
return false;
|
||||
}
|
||||
// While a multi-key keybinding is mid-sequence — e.g. the tmux preset's
|
||||
// `ctrl-b` prefix is held pending — the next key belongs to the keymap,
|
||||
// not the IME. macOS otherwise diverts printable keys straight to the IME
|
||||
// when a CJK input source is active (see `query_prefers_ime_for_printable_keys`
|
||||
// in gpui's macOS backend), so `ctrl-b x` would type an `x` and let the
|
||||
// prefix time out instead of completing the sequence. Declining IME here
|
||||
// lets the keystroke reach `dispatch_key` and finish the chord; when no
|
||||
// sequence is pending this is a no-op, so normal CJK composition is
|
||||
// unaffected.
|
||||
if window.has_pending_keystrokes() {
|
||||
return false;
|
||||
}
|
||||
// Route printable keys to the IME so CJK composes. Whether the committed
|
||||
// text lands in the terminal or the search query is decided by focus in
|
||||
// `input_text` — so opening the search bar no longer disables CJK input in
|
||||
// the terminal, and the search field composes too.
|
||||
//
|
||||
// Linux exception: gpui's IBus integration does not reliably commit plain
|
||||
// ASCII back through `replace_text_in_range`, so forcing IME routing here
|
||||
// swallows ordinary letters — the key never reaches the terminal at all
|
||||
// (Enter/Tab/arrows still work because they bypass the IME as non-printable
|
||||
// keys). Until that gpui path handles pass-through ASCII, keep printable
|
||||
// keys on the direct `on_key_down`/`key_char` path on Linux. Trade-off:
|
||||
// CJK composition is disabled on Linux for now (Linux support is still
|
||||
// experimental); ASCII typing is restored.
|
||||
!cfg!(target_os = "linux")
|
||||
}
|
||||
}
|
||||
@@ -668,7 +447,6 @@ mod tests {
|
||||
};
|
||||
use gpui::{Keystroke, Modifiers};
|
||||
|
||||
/// Kitty full mode: every key escaped, with the produced text attached.
|
||||
fn full_mode() -> KittyFlags {
|
||||
KittyFlags {
|
||||
disambiguate: true,
|
||||
@@ -677,7 +455,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Level 1 only — the mode a shell leaves on after a TUI exits.
|
||||
fn disambiguate_only() -> KittyFlags {
|
||||
KittyFlags {
|
||||
disambiguate: true,
|
||||
@@ -686,8 +463,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy call shape used by the pre-existing tests: encode with the Kitty
|
||||
/// protocol off, exercising exactly the byte output shells see by default.
|
||||
fn legacy(ks: &Keystroke) -> Option<Vec<u8>> {
|
||||
keystroke_to_bytes(ks, KittyFlags::default())
|
||||
}
|
||||
@@ -705,20 +480,15 @@ mod tests {
|
||||
let plain = Modifiers::default();
|
||||
let a = ks(plain, "a", Some("a"));
|
||||
|
||||
// Default and disambiguate-only: text belongs to the IME, which is the
|
||||
// only path that carries a synthesized event's real Unicode payload.
|
||||
assert!(defer_to_ime(&a, KittyFlags::default()));
|
||||
assert!(defer_to_ime(&a, disambiguate_only()));
|
||||
|
||||
// Full mode: the IME commits raw UTF-8, so deferring would drop the
|
||||
// `CSI 97;1;97u` the app negotiated for. Stay on the encoder path.
|
||||
assert!(!defer_to_ime(&a, full_mode()));
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&a, full_mode()),
|
||||
Some(b"\x1b[97;1;97u".to_vec()),
|
||||
);
|
||||
|
||||
// Space is text too, and follows the same rule.
|
||||
let space = ks(plain, "space", Some(" "));
|
||||
assert!(defer_to_ime(&space, KittyFlags::default()));
|
||||
assert!(!defer_to_ime(&space, full_mode()));
|
||||
@@ -738,7 +508,6 @@ mod tests {
|
||||
#[test]
|
||||
fn non_text_keys_never_defer_to_the_ime() {
|
||||
let plain = Modifiers::default();
|
||||
// No `key_char` at all — arrows, F-keys, backspace, escape.
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "left", None),
|
||||
KittyFlags::default()
|
||||
@@ -747,7 +516,6 @@ mod tests {
|
||||
&ks(plain, "backspace", None),
|
||||
KittyFlags::default()
|
||||
));
|
||||
// Control chars are filtered even when a `key_char` is present.
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "enter", Some("\n")),
|
||||
KittyFlags::default()
|
||||
@@ -756,7 +524,6 @@ mod tests {
|
||||
&ks(plain, "tab", Some("\t")),
|
||||
KittyFlags::default()
|
||||
));
|
||||
// Chords belong to the encoders, not the IME.
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
@@ -780,9 +547,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keystroke_to_bytes_ctrl_alt_letter_prefixes_meta_escape() {
|
||||
// Ctrl+Alt+letter must carry the Meta ESC prefix (xterm metaSendsEscape),
|
||||
// just like Alt+special-key and Alt+printable do below — otherwise the Alt
|
||||
// bit is silently dropped and `Ctrl+Alt+c` is indistinguishable from Ctrl+C.
|
||||
let ctrl_alt = Modifiers {
|
||||
control: true,
|
||||
alt: true,
|
||||
@@ -791,7 +555,6 @@ mod tests {
|
||||
assert_eq!(legacy(&ks(ctrl_alt, "c", None)), Some(vec![0x1b, 0x03]));
|
||||
assert_eq!(legacy(&ks(ctrl_alt, "a", None)), Some(vec![0x1b, 0x01]));
|
||||
assert_eq!(legacy(&ks(ctrl_alt, "[", None)), Some(vec![0x1b, 0x1b]));
|
||||
// Ctrl alone (no Alt) is unchanged: a bare C0 byte, no ESC prefix.
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
@@ -808,7 +571,6 @@ mod tests {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Alt + a special key is prefixed with ESC.
|
||||
assert_eq!(legacy(&ks(alt, "up", None)), Some(b"\x1b\x1b[A".to_vec()));
|
||||
}
|
||||
|
||||
@@ -816,7 +578,6 @@ mod tests {
|
||||
fn keystroke_to_bytes_emits_printable_text_but_not_under_cmd() {
|
||||
let none = Modifiers::default();
|
||||
assert_eq!(legacy(&ks(none, "a", Some("a"))), Some(b"a".to_vec()));
|
||||
// Cmd-held printable keys are app-shortcut territory -> no PTY bytes.
|
||||
let cmd = Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
@@ -830,21 +591,16 @@ mod tests {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Ctrl+[ / Ctrl+\ / Ctrl+] map to the ESC/FS/GS control bytes.
|
||||
assert_eq!(legacy(&ks(ctrl, "[", None)), Some(vec![0x1b]));
|
||||
assert_eq!(legacy(&ks(ctrl, "\\", None)), Some(vec![0x1c]));
|
||||
assert_eq!(legacy(&ks(ctrl, "]", None)), Some(vec![0x1d]));
|
||||
// Ctrl+2 is another spelling of NUL.
|
||||
assert_eq!(legacy(&ks(ctrl, "2", None)), Some(vec![0x00]));
|
||||
// The full letter range boundaries.
|
||||
assert_eq!(legacy(&ks(ctrl, "h", None)), Some(vec![0x08]));
|
||||
assert_eq!(legacy(&ks(ctrl, "z", None)), Some(vec![0x1a]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_to_bytes_ctrl_plus_cmd_is_not_a_c0_byte() {
|
||||
// Ctrl held together with Cmd (platform) is app territory, not a C0 byte;
|
||||
// it falls through the C0 table and, being non-printable under Cmd, yields None.
|
||||
let ctrl_cmd = Modifiers {
|
||||
control: true,
|
||||
platform: true,
|
||||
@@ -881,30 +637,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keystroke_to_bytes_alt_prefixes_printable_and_ignores_empty_char() {
|
||||
// Alt + a printable char is prefixed with ESC (meta) before the bytes.
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(legacy(&ks(alt, "b", Some("b"))), Some(b"\x1bb".to_vec()));
|
||||
// An empty key_char produces no bytes (nothing to send).
|
||||
let none = Modifiers::default();
|
||||
assert_eq!(legacy(&ks(none, "f7", Some(""))), None);
|
||||
// An unknown key with no char is unmapped.
|
||||
assert_eq!(legacy(&ks(none, "f7", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_to_bytes_emits_multibyte_utf8_char() {
|
||||
let none = Modifiers::default();
|
||||
// A composed character commits its UTF-8 bytes verbatim.
|
||||
assert_eq!(
|
||||
legacy(&ks(none, "é", Some("é"))),
|
||||
Some("é".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
/// A disambiguate-level `KittyFlags` for the encoder tests.
|
||||
fn kitty() -> KittyFlags {
|
||||
KittyFlags {
|
||||
disambiguate: true,
|
||||
@@ -920,8 +671,6 @@ mod tests {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Tab and Ctrl+I stay distinct: plain Tab keeps its legacy `\t` at the
|
||||
// disambiguate level, while Ctrl+I is escaped to CSI 105;5 u.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "tab", None), kitty()),
|
||||
Some(b"\t".to_vec())
|
||||
@@ -930,13 +679,10 @@ mod tests {
|
||||
keystroke_to_bytes(&ks(ctrl, "i", None), kitty()),
|
||||
Some(b"\x1b[105;5u".to_vec())
|
||||
);
|
||||
// Escape IS disambiguated to CSI 27 u at this level...
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "escape", None), kitty()),
|
||||
Some(b"\x1b[27u".to_vec())
|
||||
);
|
||||
// ...but the spec keeps plain Enter / Backspace on their legacy bytes so a
|
||||
// shell stays usable if a crashed app leaves the mode on.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "enter", None), kitty()),
|
||||
Some(b"\r".to_vec())
|
||||
@@ -949,10 +695,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kitty_disambiguate_keeps_plain_enter_tab_backspace_legacy() {
|
||||
// Regression: at the DISAMBIGUATE level, plain (unmodified) Enter / Tab /
|
||||
// Backspace must stay legacy `\r` / `\t` / 0x7f — otherwise `reset⏎` can't
|
||||
// rescue a shell after a crashed TUI leaves the mode set (the exact case the
|
||||
// spec's exception exists for). Before the fix these emitted CSI 13/9/127 u.
|
||||
let none = Modifiers::default();
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "enter", None), kitty()),
|
||||
@@ -967,9 +709,6 @@ mod tests {
|
||||
Some(b"\x7f".to_vec())
|
||||
);
|
||||
|
||||
// A modifier makes them ambiguous, so they DO escalate to CSI u carrying the
|
||||
// modifier subfield: Ctrl+Enter -> CSI 13;5 u, Alt+Backspace -> CSI 127;3 u,
|
||||
// Shift+Enter -> CSI 13;2 u.
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
@@ -998,8 +737,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kitty_report_all_keys_escapes_plain_enter_tab_backspace() {
|
||||
// Under REPORT_ALL_KEYS_AS_ESC every key is an escape code, including the
|
||||
// three legacy control keys even with no modifier.
|
||||
let full = KittyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: true,
|
||||
@@ -1022,16 +759,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tab_bytes_follows_the_disambiguate_rule() {
|
||||
// Tab reaches the PTY via the SendTab action, so its Kitty encoding lives in
|
||||
// `tab_bytes`; it must follow the same rule as the on_key_down encoder.
|
||||
let off = KittyFlags::default();
|
||||
// Protocol off: legacy Tab / back-tab, unchanged.
|
||||
assert_eq!(tab_bytes(false, off), b"\t".to_vec());
|
||||
assert_eq!(tab_bytes(true, off), b"\x1b[Z".to_vec());
|
||||
// Disambiguate: plain Tab stays legacy `\t`; Shift-Tab escalates to CSI 9;2 u.
|
||||
assert_eq!(tab_bytes(false, kitty()), b"\t".to_vec());
|
||||
assert_eq!(tab_bytes(true, kitty()), b"\x1b[9;2u".to_vec());
|
||||
// Report-all: even plain Tab is escaped.
|
||||
let full = KittyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: true,
|
||||
@@ -1043,7 +775,6 @@ mod tests {
|
||||
#[test]
|
||||
fn kitty_defers_plain_text_to_legacy() {
|
||||
let none = Modifiers::default();
|
||||
// A plain letter still sends raw text at the disambiguate level.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "a", Some("a")), kitty()),
|
||||
Some(b"a".to_vec())
|
||||
@@ -1052,9 +783,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kitty_escapes_ctrl_and_alt_text_chords() {
|
||||
// At the disambiguate level, a Ctrl/Alt modifier makes a text key
|
||||
// ambiguous, so it escalates to CSI u with the modifier subfield —
|
||||
// instead of the legacy ESC-prefix / C0 forms.
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
@@ -1063,12 +791,10 @@ mod tests {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Ctrl+Space would be an ambiguous NUL byte → CSI 32;5 u.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(ctrl, "space", None), kitty()),
|
||||
Some(b"\x1b[32;5u".to_vec())
|
||||
);
|
||||
// Alt+b escapes as CSI 98;3 u (not the legacy ESC-prefixed "b").
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(alt, "b", Some("b")), kitty()),
|
||||
Some(b"\x1b[98;3u".to_vec())
|
||||
@@ -1082,7 +808,6 @@ mod tests {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Shift+Up carries the modifier subfield; unmodified Up keeps the bare form.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(shift, "up", None), kitty()),
|
||||
Some(b"\x1b[1;2A".to_vec())
|
||||
@@ -1091,7 +816,6 @@ mod tests {
|
||||
keystroke_to_bytes(&ks(none, "up", None), kitty()),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
// Tilde-form keys likewise: Shift+Delete -> CSI 3;2 ~.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(shift, "delete", None), kitty()),
|
||||
Some(b"\x1b[3;2~".to_vec())
|
||||
@@ -1106,7 +830,6 @@ mod tests {
|
||||
report_text: true,
|
||||
};
|
||||
let none = Modifiers::default();
|
||||
// 'a' -> CSI 97 ; 1 ; 97 u (code ; mods ; text codepoint).
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "a", Some("a")), full),
|
||||
Some(b"\x1b[97;1;97u".to_vec())
|
||||
@@ -1121,10 +844,6 @@ mod tests {
|
||||
report_text: true,
|
||||
};
|
||||
let none = Modifiers::default();
|
||||
// The Kitty spec forbids control codes in the associated-text field (C0,
|
||||
// DEL and the C1 block). A key whose reported char is a lone DEL (U+007F)
|
||||
// or a C1 control (e.g. U+0085) must NOT land that codepoint in field 3;
|
||||
// with no printable text left, the field is omitted entirely -> CSI 97 u.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "a", Some("\u{7f}")), full),
|
||||
Some(b"\x1b[97u".to_vec())
|
||||
@@ -1133,8 +852,6 @@ mod tests {
|
||||
keystroke_to_bytes(&ks(none, "a", Some("\u{85}")), full),
|
||||
Some(b"\x1b[97u".to_vec())
|
||||
);
|
||||
// A printable char mixed with a control keeps only the printable codepoint
|
||||
// in field 3 (the control is dropped, not the whole field): 'a' + DEL -> 97.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "a", Some("a\u{7f}")), full),
|
||||
Some(b"\x1b[97;1;97u".to_vec())
|
||||
@@ -1146,7 +863,6 @@ mod tests {
|
||||
let none = KittyFlags::default();
|
||||
assert!(!none.active());
|
||||
let mods = Modifiers::default();
|
||||
// With the protocol off, output matches the legacy path exactly.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(mods, "tab", None), none),
|
||||
Some(b"\t".to_vec())
|
||||
@@ -1161,15 +877,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode through the Option-key policy the way `on_key_down` does: reshape
|
||||
/// first (macOS semantics), then hand the result to the shared encoder.
|
||||
fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KittyFlags) -> Option<Vec<u8>> {
|
||||
let reshaped = reshape_option_keystroke(ks, option_as_alt);
|
||||
keystroke_to_bytes(reshaped.as_ref().unwrap_or(ks), kitty)
|
||||
}
|
||||
|
||||
/// An Option+B chord as gpui reports it on macOS: base key "b", the alt
|
||||
/// bit, and the OS-composed character in `key_char`.
|
||||
fn option_b() -> Keystroke {
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
@@ -1180,12 +892,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn option_as_alt_on_sends_esc_plus_base_key() {
|
||||
// Meta semantics: ESC + the plain key, not ESC + the composed char.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), true, KittyFlags::default()),
|
||||
Some(b"\x1bb".to_vec())
|
||||
);
|
||||
// Shifted letters follow the shifted character: Option+Shift+B → ESC B.
|
||||
let alt_shift = Modifiers {
|
||||
alt: true,
|
||||
shift: true,
|
||||
@@ -1195,7 +905,6 @@ mod tests {
|
||||
reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KittyFlags::default()),
|
||||
Some(b"\x1bB".to_vec())
|
||||
);
|
||||
// Non-letter keys too: Option+2 composes "™" but Meta sends ESC 2.
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
@@ -1208,29 +917,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn option_as_alt_off_sends_composed_text_bare() {
|
||||
// macOS convention: the chord is text input — the composed character
|
||||
// goes out with NO ESC prefix. (The unreshaped legacy path used to emit
|
||||
// `ESC ∫`, wrong under either reading of the Option key.)
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), false, KittyFlags::default()),
|
||||
Some("∫".as_bytes().to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
/// The routing half of Option-as-Meta (#177): with a CJK input source active,
|
||||
/// macOS hands ⌥-chords to the IME before the key handler runs, because ⌥B
|
||||
/// composes printable text. The IME commits `∫` and eats the event, so the
|
||||
/// reshape above never gets a say — unless the handler declines IME for exactly
|
||||
/// these chords. Everything else keeps composing.
|
||||
#[test]
|
||||
fn meta_chords_skip_the_ime_only_when_option_is_meta() {
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
// The bug: ⌥B with the setting on must reach `on_key_down`, not the IME.
|
||||
assert!(meta_chord_bypasses_ime(&option_b(), true));
|
||||
// Shift rides along — ⌥⇧B is still a Meta chord.
|
||||
let alt_shift = Modifiers {
|
||||
alt: true,
|
||||
shift: true,
|
||||
@@ -1241,19 +940,13 @@ mod tests {
|
||||
true
|
||||
));
|
||||
|
||||
// Setting off: ⌥ is text input, and the IME owns it — this is what makes
|
||||
// dead keys (⌥E then E → `é`) compose.
|
||||
assert!(!meta_chord_bypasses_ime(&option_b(), false));
|
||||
|
||||
// Plain text is never claimed, on either setting: CJK composition is the
|
||||
// whole reason the handler prefers the IME in the first place.
|
||||
assert!(!meta_chord_bypasses_ime(
|
||||
&ks(Modifiers::default(), "n", Some("n")),
|
||||
true
|
||||
));
|
||||
|
||||
// ⌘ chords are app shortcuts and ⌃ chords already bypass the IME upstream;
|
||||
// both keep their existing routing rather than being claimed here.
|
||||
let cmd_alt = Modifiers {
|
||||
alt: true,
|
||||
platform: true,
|
||||
@@ -1267,8 +960,6 @@ mod tests {
|
||||
};
|
||||
assert!(!meta_chord_bypasses_ime(&ks(ctrl_alt, "b", None), true));
|
||||
|
||||
// Named keys carry the alt bit too and take the same route — Alt+Left must
|
||||
// not be diverted into a composition either.
|
||||
assert!(meta_chord_bypasses_ime(&ks(alt, "left", None), true));
|
||||
}
|
||||
|
||||
@@ -1278,8 +969,6 @@ mod tests {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Named keys compose nothing: Alt+Up keeps its ESC-prefixed form on
|
||||
// both settings.
|
||||
for on in [true, false] {
|
||||
assert!(reshape_option_keystroke(&ks(alt, "up", None), on).is_none());
|
||||
assert_eq!(
|
||||
@@ -1287,10 +976,7 @@ mod tests {
|
||||
Some(b"\x1b\x1b[A".to_vec())
|
||||
);
|
||||
}
|
||||
// Enter's key_char is a control char ("\n"), not composed text: the
|
||||
// chord stays a real Alt chord with the setting off.
|
||||
assert!(reshape_option_keystroke(&ks(alt, "enter", Some("\n")), false).is_none());
|
||||
// Ctrl+Alt chords keep the C0 + Meta-ESC encoding on both settings.
|
||||
let ctrl_alt = Modifiers {
|
||||
control: true,
|
||||
alt: true,
|
||||
@@ -1303,24 +989,18 @@ mod tests {
|
||||
Some(vec![0x1b, 0x03])
|
||||
);
|
||||
}
|
||||
// No alt held → nothing to reshape, either setting.
|
||||
assert!(
|
||||
reshape_option_keystroke(&ks(Modifiers::default(), "a", Some("a")), true).is_none()
|
||||
);
|
||||
// A key_char already equal to the base key needs no clone.
|
||||
assert!(reshape_option_keystroke(&ks(alt, "b", Some("b")), true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_reshape_composes_with_the_kitty_encoder() {
|
||||
// Option-as-Meta keeps the alt bit, so a Kitty-aware app still sees the
|
||||
// spec's alt-modified base key.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), true, kitty()),
|
||||
Some(b"\x1b[98;3u".to_vec())
|
||||
);
|
||||
// Option-as-composed drops the alt bit: at the disambiguate level the
|
||||
// chord is plain text, sent raw like any other typed character.
|
||||
assert_eq!(
|
||||
reshaped_bytes(&option_b(), false, kitty()),
|
||||
Some("∫".as_bytes().to_vec())
|
||||
@@ -1329,8 +1009,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kitty_never_encodes_cmd_chords() {
|
||||
// Cmd (platform) chords stay app-shortcut territory even with Kitty on:
|
||||
// the same `None`/legacy result as before.
|
||||
let cmd = Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,57 +1,15 @@
|
||||
//! Command marks: where each shell prompt started in the scrollback, so the
|
||||
//! details panel's Outline can list a pane's commands and scroll back to one.
|
||||
//!
|
||||
//! Fed by the reader thread from OSC 133 (`A` prompt start, `C` command start,
|
||||
//! `D` command done — the same shell-integration marks the daemon sniffs for
|
||||
//! prompt state). The daemon reports only *whether* the shell is at its prompt;
|
||||
//! positions have to come from the client, because only the client holds the
|
||||
//! grid those positions are relative to.
|
||||
//!
|
||||
//! # Why a mark stores its text
|
||||
//!
|
||||
//! A grid row has no stable identity. Alacritty's `Line` is relative to the
|
||||
//! viewport, so anything recorded in those coordinates slides as output arrives.
|
||||
//! Converting to an absolute index from the top of history (`history_size -
|
||||
//! display_offset + line`) is stable — *until the scrollback fills*. After that
|
||||
//! alacritty discards the oldest row per new row, every surviving row's absolute
|
||||
//! index silently decreases, and the amount discarded is not observable from
|
||||
//! outside the emulator: `history_size` is pinned at the limit, and nothing else
|
||||
//! exposes the scroll count. (Counting it exactly would mean wrapping
|
||||
//! `vte::ansi::Handler` to intercept every line-producing sequence — 71 methods,
|
||||
//! all with no-op defaults, so a future `vte` upgrade that adds one would
|
||||
//! silently break rendering. Not worth it for this.)
|
||||
//!
|
||||
//! So the absolute index is treated as a *hint* and the row's text as the
|
||||
//! *truth*: each mark records what its row said when it was made, and a reader
|
||||
//! re-reads the row before trusting the position. A mark whose row no longer
|
||||
//! matches has drifted out from under us and is reported stale rather than
|
||||
//! silently scrolling somewhere wrong. Below the scrollback limit — which is
|
||||
//! where a pane spends most of its life — the hint is exact and the check always
|
||||
//! passes.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Cap on retained marks. Deep scrollback holds far more prompts than a panel
|
||||
/// list is useful at, and the oldest are the likeliest to have drifted anyway.
|
||||
const MAX_MARKS: usize = 500;
|
||||
|
||||
/// One shell prompt, and the command run from it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CommandMark {
|
||||
/// Row index from the top of the scrollback at record time — the position
|
||||
/// hint. See the module docs for when it stops being exact.
|
||||
pub row: i64,
|
||||
/// What the row said when the mark was made, used to detect drift. Empty
|
||||
/// while the prompt has been printed but nothing has been typed yet.
|
||||
pub text: String,
|
||||
/// Exit code from `OSC 133;D`, once the command finishes.
|
||||
pub exit: Option<i32>,
|
||||
/// Whether the command has finished (a `D` mark arrived). Distinct from
|
||||
/// `exit.is_some()`: a `D` without a code still means "done".
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
/// A pane's marks, shared between the reader thread (writer) and the UI (reader).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Marks(Arc<Mutex<Vec<CommandMark>>>);
|
||||
|
||||
@@ -60,14 +18,8 @@ impl Marks {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Begin a mark at `row` (`OSC 133;A` — the shell is about to print a
|
||||
/// prompt). `text` is the row's current content, which is normally empty at
|
||||
/// this point and gets filled in by [`set_text`](Self::set_text) once the
|
||||
/// command has been typed.
|
||||
pub fn begin(&self, row: i64, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
// A prompt redraw (a resize, a `clear`, zle repainting the line) re-emits
|
||||
// `A` on the same row. Update in place rather than stacking duplicates.
|
||||
if marks.last().is_some_and(|m| m.row == row && !m.done) {
|
||||
if let Some(last) = marks.last_mut() {
|
||||
last.text = text;
|
||||
@@ -80,16 +32,12 @@ impl Marks {
|
||||
exit: None,
|
||||
done: false,
|
||||
});
|
||||
// Trim from the front: oldest marks age out of the scrollback first.
|
||||
let overflow = marks.len().saturating_sub(MAX_MARKS);
|
||||
if overflow > 0 {
|
||||
marks.drain(..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the command line to the open mark (`OSC 133;C` — the user hit
|
||||
/// enter, so the prompt row now holds the command). Ignored when no mark is
|
||||
/// open, which is what a `C` without a preceding `A` means.
|
||||
pub fn set_text(&self, text: String) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
@@ -99,7 +47,6 @@ impl Marks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open mark (`OSC 133;D[;exit]`).
|
||||
pub fn finish(&self, exit: Option<i32>) {
|
||||
let Ok(mut marks) = self.0.lock() else { return };
|
||||
if let Some(last) = marks.last_mut() {
|
||||
@@ -108,8 +55,6 @@ impl Marks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for rendering, newest last. Marks that never got a command are
|
||||
/// dropped: a bare prompt the user typed nothing at is not an outline entry.
|
||||
pub fn list(&self) -> Vec<CommandMark> {
|
||||
let Ok(marks) = self.0.lock() else {
|
||||
return Vec::new();
|
||||
@@ -121,7 +66,6 @@ impl Marks {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop everything (the pane was cleared, so every position is meaningless).
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut marks) = self.0.lock() {
|
||||
marks.clear();
|
||||
@@ -129,61 +73,34 @@ impl Marks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the exit code out of an `OSC 133;D` payload: `D`, `D;0`, `D;1`, and
|
||||
/// zsh's `D;aborted` all occur. Anything unparseable is "done, code unknown".
|
||||
pub fn parse_done_exit(payload: &[u8]) -> Option<i32> {
|
||||
let rest = payload.strip_prefix(b"D")?;
|
||||
let rest = rest.strip_prefix(b";")?;
|
||||
std::str::from_utf8(rest).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// What a recognized `OSC 133` mark means for the outline.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MarkEvent {
|
||||
/// `A` — the shell is about to print a prompt.
|
||||
Prompt,
|
||||
/// `C;<cmd>` — the command was submitted and its output starts here. tty7's
|
||||
/// own shell integration always includes the command line, so the outline
|
||||
/// never has to guess it back out of the grid (where it would be tangled up
|
||||
/// with the user's prompt string).
|
||||
Command(String),
|
||||
/// `D[;exit]` — the command finished.
|
||||
Done(Option<i32>),
|
||||
}
|
||||
|
||||
/// Finds `OSC 133` marks in the output stream and reports *where* each one lands
|
||||
/// — the byte offset just past the sequence — so the caller can advance the
|
||||
/// emulator up to exactly that point and read the grid position there.
|
||||
///
|
||||
/// Separate from [`OscTokenizer`](crate::core::osc::OscTokenizer), which reports
|
||||
/// payloads but not offsets. Carries its state across feeds, so a mark split over
|
||||
/// two socket reads is still recognized (and attributed to the batch its
|
||||
/// terminator lands in, which is the correct row either way).
|
||||
#[derive(Default)]
|
||||
pub struct MarkScanner {
|
||||
state: ScanState,
|
||||
/// Payload bytes collected so far, possibly spanning feeds. Bounded: a
|
||||
/// "payload" that runs past any plausible command line is a desync, not a
|
||||
/// mark, so it's abandoned rather than grown without limit.
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
enum ScanState {
|
||||
/// Ordinary output.
|
||||
#[default]
|
||||
Text,
|
||||
/// Saw `ESC`, waiting to see whether `]` follows.
|
||||
Esc,
|
||||
/// Inside an OSC payload, collecting until BEL or ST.
|
||||
Osc,
|
||||
/// Saw `ESC` inside an OSC payload — an ST (`ESC \`) if `\` follows.
|
||||
OscEsc,
|
||||
}
|
||||
|
||||
/// Ceiling on a collected OSC payload. Long enough for any real command line,
|
||||
/// short enough that a stream that never terminates its OSC can't grow a buffer
|
||||
/// unboundedly.
|
||||
const MAX_PAYLOAD: usize = 64 * 1024;
|
||||
|
||||
impl MarkScanner {
|
||||
@@ -191,21 +108,10 @@ impl MarkScanner {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feed one batch. `on_mark(offset, event)` fires for each recognized mark,
|
||||
/// where `offset` is an index into `bytes` just past the mark's terminator.
|
||||
/// Ordinary output is the overwhelming majority of every batch, and the only
|
||||
/// byte that can end it is `ESC` — so that state skips ahead with SIMD
|
||||
/// `memchr` rather than stepping per byte, exactly as
|
||||
/// [`OscTokenizer::feed`](crate::core::osc::OscTokenizer::feed) does. This
|
||||
/// scanner runs over every batch the client receives, alongside three
|
||||
/// tokenizers that already did this; measured on an 8 MB batch of plausible
|
||||
/// output it was the difference between 1.6 GB/s and 8.3 GB/s.
|
||||
pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) {
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if self.state == ScanState::Text {
|
||||
// No ESC in the rest of the batch means nothing here can matter:
|
||||
// the state stays `Text`, which is where the next feed resumes.
|
||||
let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else {
|
||||
return;
|
||||
};
|
||||
@@ -215,14 +121,12 @@ impl MarkScanner {
|
||||
}
|
||||
let b = bytes[i];
|
||||
match self.state {
|
||||
// Handled by the skip-ahead above.
|
||||
ScanState::Text => unreachable!(),
|
||||
ScanState::Esc => {
|
||||
if b == b']' {
|
||||
self.state = ScanState::Osc;
|
||||
self.payload.clear();
|
||||
} else {
|
||||
// Some other escape sequence; `ESC ESC` restarts.
|
||||
self.state = if b == 0x1b {
|
||||
ScanState::Esc
|
||||
} else {
|
||||
@@ -242,8 +146,6 @@ impl MarkScanner {
|
||||
if self.payload.len() < MAX_PAYLOAD {
|
||||
self.payload.push(b);
|
||||
} else {
|
||||
// Runaway payload: give up on this sequence rather
|
||||
// than buffer the rest of the stream into it.
|
||||
self.state = ScanState::Text;
|
||||
self.payload.clear();
|
||||
}
|
||||
@@ -256,10 +158,6 @@ impl MarkScanner {
|
||||
}
|
||||
self.state = ScanState::Text;
|
||||
} else {
|
||||
// Not an ST after all — the ESC was payload. Bounded like
|
||||
// the ordinary payload byte below it: a stream of bare
|
||||
// ESCs inside an unterminated OSC would otherwise grow the
|
||||
// buffer a byte at a time, never reaching the check there.
|
||||
if self.payload.len() < MAX_PAYLOAD {
|
||||
self.payload.push(0x1b);
|
||||
self.state = ScanState::Osc;
|
||||
@@ -274,15 +172,12 @@ impl MarkScanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret the collected payload, clearing it either way.
|
||||
fn take(&mut self) -> Option<MarkEvent> {
|
||||
let payload = std::mem::take(&mut self.payload);
|
||||
let body = payload.strip_prefix(b"133;")?;
|
||||
match body.first()? {
|
||||
b'A' => Some(MarkEvent::Prompt),
|
||||
b'C' => {
|
||||
// `C` alone (no command) still marks output start; the shells
|
||||
// that can't report the line send it bare.
|
||||
let cmd = body
|
||||
.strip_prefix(b"C;")
|
||||
.map(|c| String::from_utf8_lossy(c).into_owned())
|
||||
@@ -290,8 +185,6 @@ impl MarkScanner {
|
||||
Some(MarkEvent::Command(cmd))
|
||||
}
|
||||
b'D' => Some(MarkEvent::Done(parse_done_exit(body))),
|
||||
// `B` (prompt end) and `V` (tty7's edit-mode extension) carry no
|
||||
// position the outline cares about.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -318,8 +211,6 @@ mod tests {
|
||||
let marks = Marks::new();
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("cargo t".into());
|
||||
// zle repaints the prompt on the same row (a resize, a completion menu
|
||||
// closing) and the shell re-emits `A`.
|
||||
marks.begin(10, "cargo test".into());
|
||||
let got = marks.list();
|
||||
assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one");
|
||||
@@ -332,7 +223,6 @@ mod tests {
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("ls".into());
|
||||
marks.finish(Some(0));
|
||||
// Same row is possible after a `clear`.
|
||||
marks.begin(10, String::new());
|
||||
marks.set_text("pwd".into());
|
||||
let got = marks.list();
|
||||
@@ -353,8 +243,6 @@ mod tests {
|
||||
assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest");
|
||||
}
|
||||
|
||||
/// Collect `(offset, event)` pairs from feeding `chunks` in order, so a test
|
||||
/// can assert on a stream split at arbitrary boundaries.
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> {
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut out = Vec::new();
|
||||
@@ -364,24 +252,14 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `Text` state skips to the next `ESC` with `memchr` instead of
|
||||
/// walking byte by byte, which means it — not the loop — decides where
|
||||
/// scanning resumes. Splitting one stream at *every* offset and comparing
|
||||
/// against the unsplit scan pins that: an off-by-one in the resume index,
|
||||
/// or a state that the skip forgets to carry across a feed, shows up as a
|
||||
/// shifted offset or a lost mark at exactly one split point.
|
||||
#[test]
|
||||
fn splitting_anywhere_yields_the_same_marks() {
|
||||
// Deliberately awkward: bare ESCs, an `ESC ESC` restart, a non-OSC
|
||||
// escape, an ST-terminated mark and a BEL-terminated one.
|
||||
let stream: &[u8] =
|
||||
b"out\x1b\x1b[32mmore\x1b]133;C;git status\x07text\x1b]133;D;0\x1b\\tail\x1b";
|
||||
let whole = scan(&[stream]);
|
||||
assert_eq!(whole.len(), 2, "both marks found in one pass");
|
||||
|
||||
for at in 0..=stream.len() {
|
||||
// Offsets are relative to the feed they came from, so rebase the
|
||||
// second half onto the whole stream before comparing.
|
||||
let mut scanner = MarkScanner::new();
|
||||
let mut got = Vec::new();
|
||||
scanner.feed(&stream[..at], |off, ev| got.push((off, ev)));
|
||||
@@ -394,8 +272,6 @@ mod tests {
|
||||
fn reports_marks_just_past_their_terminator() {
|
||||
let got = scan(&[b"ab\x1b]133;A\x07cd"]);
|
||||
assert_eq!(got, vec![(10, MarkEvent::Prompt)]);
|
||||
// The offset must point past the BEL, so advancing `bytes[..offset]`
|
||||
// consumes the whole sequence and nothing of what follows.
|
||||
assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd");
|
||||
}
|
||||
|
||||
@@ -411,8 +287,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accepts_st_terminated_marks() {
|
||||
// `ESC \` instead of BEL — both are legal OSC terminators and the
|
||||
// integrations use ST on some shells.
|
||||
let got = scan(&[b"\x1b]133;D;130\x1b\\"]);
|
||||
assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
//! The `terminal` subsystem, split by concern:
|
||||
//! - [`size`] — `TermSize`, the grid dimensions shared by the remote terminal
|
||||
//! and the view.
|
||||
//! - [`remote`] — the daemon-backed `RemoteTerminal`: owns a socket + a local
|
||||
//! mirror emulator, fed bytes the daemon replays instead of owning a PTY.
|
||||
//! - [`view`] — the GPUI view that hosts a terminal and renders the chrome.
|
||||
//! - [`element`] — the custom element that paints the character grid.
|
||||
//! - [`palette`] — the terminal color scheme.
|
||||
//!
|
||||
//! Shell integration (the rc files that emit OSC 7 / OSC 133) used to live here
|
||||
//! but now sits in `daemon::shell_integration`, beside the PTY-owning `pane`
|
||||
//! that is its only injector — which is what keeps `daemon` from depending back
|
||||
//! on `terminal`.
|
||||
//!
|
||||
//! `TermSize` / `RemoteTerminal` are re-exported here so the rest of the crate
|
||||
//! can refer to `terminal::RemoteTerminal` without reaching into submodules.
|
||||
|
||||
mod boxdraw;
|
||||
mod cmd_editor;
|
||||
mod completion;
|
||||
|
||||
+16
-50
@@ -1,21 +1,6 @@
|
||||
//! tty7 terminal color scheme.
|
||||
//!
|
||||
//! A self-contained, hand-tuned palette (not derived from any other terminal
|
||||
//! theme) covering the ANSI 16 colors for both dark and light backgrounds, the
|
||||
//! 256-color xterm fallback cube, and the text-selection colors. The goal is a
|
||||
//! calm, slightly cool-neutral look where every accent stays legible on its
|
||||
//! intended background and the bright variants are clearly lifted from the
|
||||
//! normal ones without becoming neon.
|
||||
|
||||
use alacritty_terminal::vte::ansi::Rgb;
|
||||
use gpui::Global;
|
||||
|
||||
/// The terminal-facing slice of the active color scheme: the ANSI-16 set and
|
||||
/// the selection surface for the current (preset, mode) — the base the search
|
||||
/// match washes derive from (the selection itself paints as a translucent
|
||||
/// foreground wash; see `element::PaintColors`). Published as a GPUI global by
|
||||
/// the UI layer's `apply_theme` so the renderer always paints the active
|
||||
/// scheme without the terminal layer depending on `ui`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivePalette {
|
||||
pub ansi16: [Rgb; 16],
|
||||
@@ -24,8 +9,6 @@ pub struct ActivePalette {
|
||||
|
||||
impl Global for ActivePalette {}
|
||||
|
||||
/// Convert a GPUI `Hsla` to an alacritty `Rgb` (8-bit per channel, rounded and
|
||||
/// clamped). Shared by the renderer and the OSC color-query replies.
|
||||
pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb {
|
||||
let rgba = gpui::Rgba::from(c);
|
||||
Rgb {
|
||||
@@ -35,37 +18,28 @@ pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dark-theme ANSI 16 set, tuned for the warm "soft charcoal" background
|
||||
/// (~#232220 — see ui/theme.rs). The neutral slots (0/7/8/15) carry the same
|
||||
/// faint warm cast as the shell so grays don't read cool-and-dirty against the
|
||||
/// warm base; the colored accents stay slightly desaturated for long sessions.
|
||||
const DARK_ANSI16: [(u8, u8, u8); 16] = [
|
||||
(0x2c, 0x2a, 0x26), // 0 black (warm, lifted off the bg so it's not invisible)
|
||||
(0xec, 0x6a, 0x78), // 1 red
|
||||
(0x8f, 0xbf, 0x6e), // 2 green
|
||||
(0xe0, 0xb0, 0x72), // 3 yellow
|
||||
(0x6f, 0xa8, 0xe6), // 4 blue
|
||||
(0xc0, 0x8a, 0xdf), // 5 magenta
|
||||
(0x5f, 0xc2, 0xc9), // 6 cyan
|
||||
(0xd2, 0xcf, 0xc8), // 7 white (warm light gray — matches default foreground)
|
||||
(0x6b, 0x66, 0x5d), // 8 bright black (warm comment gray)
|
||||
(0xf5, 0x86, 0x8f), // 9 bright red
|
||||
(0xa8, 0xd9, 0x8a), // 10 bright green
|
||||
(0xef, 0xc7, 0x8a), // 11 bright yellow
|
||||
(0x8f, 0xc0, 0xf5), // 12 bright blue
|
||||
(0xd2, 0xa6, 0xec), // 13 bright magenta
|
||||
(0x84, 0xd6, 0xdc), // 14 bright cyan
|
||||
(0xf6, 0xf3, 0xec), // 15 bright white (warm)
|
||||
(0x2c, 0x2a, 0x26),
|
||||
(0xec, 0x6a, 0x78),
|
||||
(0x8f, 0xbf, 0x6e),
|
||||
(0xe0, 0xb0, 0x72),
|
||||
(0x6f, 0xa8, 0xe6),
|
||||
(0xc0, 0x8a, 0xdf),
|
||||
(0x5f, 0xc2, 0xc9),
|
||||
(0xd2, 0xcf, 0xc8),
|
||||
(0x6b, 0x66, 0x5d),
|
||||
(0xf5, 0x86, 0x8f),
|
||||
(0xa8, 0xd9, 0x8a),
|
||||
(0xef, 0xc7, 0x8a),
|
||||
(0x8f, 0xc0, 0xf5),
|
||||
(0xd2, 0xa6, 0xec),
|
||||
(0x84, 0xd6, 0xdc),
|
||||
(0xf6, 0xf3, 0xec),
|
||||
];
|
||||
|
||||
/// Build the full 256-entry xterm palette (dark-theme ANSI 16 in slots 0-15).
|
||||
///
|
||||
/// Slots 0-15 are a sensible default only: the renderer overwrites them every
|
||||
/// paint with the active preset's ANSI set (see `ui::presets::ActivePalette`).
|
||||
pub fn build() -> [Rgb; 256] {
|
||||
let mut p = [Rgb { r: 0, g: 0, b: 0 }; 256];
|
||||
|
||||
// 0-15: ANSI 16.
|
||||
for (i, (r, g, b)) in DARK_ANSI16.iter().enumerate() {
|
||||
p[i] = Rgb {
|
||||
r: *r,
|
||||
@@ -74,7 +48,6 @@ pub fn build() -> [Rgb; 256] {
|
||||
};
|
||||
}
|
||||
|
||||
// 16-231: 6×6×6 color cube.
|
||||
let steps = [0u8, 95, 135, 175, 215, 255];
|
||||
let mut idx = 16;
|
||||
for r in 0..6 {
|
||||
@@ -90,7 +63,6 @@ pub fn build() -> [Rgb; 256] {
|
||||
}
|
||||
}
|
||||
|
||||
// 232-255: grayscale ramp.
|
||||
for i in 0..24 {
|
||||
let v = 8 + i as u8 * 10;
|
||||
p[232 + i] = Rgb { r: v, g: v, b: v };
|
||||
@@ -105,10 +77,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hsla_to_rgb_round_trips_a_known_color() {
|
||||
// A `#rrggbb` literal → Hsla → Rgb should recover the byte channels.
|
||||
let rgb = hsla_to_rgb(gpui::rgb(0x123456).into());
|
||||
assert_eq!((rgb.r, rgb.g, rgb.b), (0x12, 0x34, 0x56));
|
||||
// Pure black and white clamp cleanly.
|
||||
let black = hsla_to_rgb(gpui::rgb(0x000000).into());
|
||||
assert_eq!((black.r, black.g, black.b), (0, 0, 0));
|
||||
let white = hsla_to_rgb(gpui::rgb(0xffffff).into());
|
||||
@@ -118,17 +88,13 @@ mod tests {
|
||||
#[test]
|
||||
fn build_lays_out_the_256_color_cube_and_ramp() {
|
||||
let p = build();
|
||||
// Slots 0-15 are the dark ANSI set.
|
||||
for (i, (r, g, b)) in DARK_ANSI16.iter().enumerate() {
|
||||
assert_eq!((p[i].r, p[i].g, p[i].b), (*r, *g, *b));
|
||||
}
|
||||
// The 6×6×6 cube runs 16..=231: first is black, last is white.
|
||||
assert_eq!((p[16].r, p[16].g, p[16].b), (0, 0, 0));
|
||||
assert_eq!((p[231].r, p[231].g, p[231].b), (255, 255, 255));
|
||||
// The grayscale ramp is 232..=255, starting at 8 and stepping by 10.
|
||||
assert_eq!(p[232].r, 8);
|
||||
assert_eq!(p[255].r, 8 + 23 * 10);
|
||||
// Ramp entries are true grays.
|
||||
assert_eq!(p[240].r, p[240].g);
|
||||
assert_eq!(p[240].g, p[240].b);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,3 @@
|
||||
//! Which of a workspace's saved panes are still running — asked of **the
|
||||
//! machine that workspace lives on**, cached per machine, probed off the UI
|
||||
//! thread.
|
||||
//!
|
||||
//! # The bug this exists to close
|
||||
//!
|
||||
//! A `pane_id` is minted by one daemon and means nothing to any other. Two
|
||||
//! machines hand out `1`, `2`, `3` in the same order, so a remote workspace's
|
||||
//! saved ids overlap this computer's almost perfectly. Every liveness question
|
||||
//! that skipped the route therefore had *two* wrong answers available: the
|
||||
//! benign one (the remote's ids are absent here, so its sessions read as
|
||||
//! stopped) and the misleading one — the id happens to name a live *local*
|
||||
//! pane, and a workspace on a box that has been off for a week lights up green
|
||||
//! because somebody's shell on this laptop holds the number. The title-bar
|
||||
//! workspace menu was doing exactly the latter.
|
||||
//!
|
||||
//! Routing alone does not fix a *cross-workspace* view. The picker and the
|
||||
//! workspace menu list several workspaces at once, on several machines at once,
|
||||
//! so there is no single route to send: it takes one query per machine, and
|
||||
//! those queries cannot be waited on in turn from `render`.
|
||||
//!
|
||||
//! # Three states, not two
|
||||
//!
|
||||
//! | State | Means | Drawn as |
|
||||
//! |---|---|---|
|
||||
//! | [`Liveness::Alive`] | the machine answered, and it still has one of these panes | green corner dot |
|
||||
//! | [`Liveness::Stopped`] | the machine answered, and none of them are left | no dot |
|
||||
//! | [`Liveness::Unknown`] | we could not ask | muted corner dot |
|
||||
//!
|
||||
//! `Unknown` is the state remote workspaces made necessary. A failed query to
|
||||
//! another machine is not evidence that anything died — the sessions are very
|
||||
//! probably fine and the *link* is what broke — and rendering it as "stopped"
|
||||
//! would tell the user their work is gone every time the network blinks.
|
||||
//!
|
||||
//! **A local `List` failing is not `Unknown`.** It travels a unix socket to a
|
||||
//! daemon whose absence is itself the answer: no daemon, no live panes. So a
|
||||
//! local host with no cached *liveness* answer reads `Stopped`, which is what
|
||||
//! this page has always drawn — the async cache changes remote behaviour and
|
||||
//! leaves local pixels alone.
|
||||
//!
|
||||
//! Not knowing which panes to ask about is a different thing, and it is
|
||||
//! `Unknown` on every machine. The ids live in the machine's tree
|
||||
//! ([`crate::ui::machine_mirror`]), so until that first pull lands there is no
|
||||
//! question to put to the daemon — and "no ids yet" must not be read as "no
|
||||
//! sessions", which is a claim about the user's work founded on our own
|
||||
//! ignorance. Locally the pull lands within a frame or two of launch; where
|
||||
//! there is no control link at all, a muted dot is exactly the truth.
|
||||
//!
|
||||
//! # How it is filled
|
||||
//!
|
||||
//! [`sweep`] is called from the render paths that show liveness. It never
|
||||
//! blocks: it looks at the workspace list, and for each machine whose answer is
|
||||
//! missing or past its TTL it starts one background query. All of them fly at
|
||||
//! once — N machines cost one round trip, not N in a row — and [`InFlight`]
|
||||
//! keeps a frame that re-asks before the answer lands from starting a second.
|
||||
//! Landing goes through `update_global`, so the `observe_global` hook in
|
||||
//! [`crate::ui::app`] repaints whatever is on screen.
|
||||
//!
|
||||
//! A machine this process has no connection to is **not** probed: asking would
|
||||
//! mean dialling SSH, and a liveness dot is not a reason to open a connection
|
||||
//! (or raise a passphrase prompt). It stays `Unknown`, which is the truth.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -70,70 +8,31 @@ use crate::core::session::{WindowView, WorkspaceId, WorkspaceStore};
|
||||
use crate::terminal::{PaneRoute, RemoteTerminal};
|
||||
use crate::ui::host_ops::{HostId, InFlight};
|
||||
|
||||
/// How long this machine's answer stays fresh. The value the home picker's
|
||||
/// blocking cache used before any of this was routed, kept so a local
|
||||
/// workspace's dot updates on exactly the cadence it always did.
|
||||
const LOCAL_TTL: Duration = Duration::from_millis(2_000);
|
||||
|
||||
/// How long another machine's answer stays fresh. Longer than the local one
|
||||
/// because the query is a routed round trip rather than a unix socket, and a
|
||||
/// dot is not worth a heartbeat.
|
||||
const REMOTE_TTL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long to sit on a *failed* answer before asking again.
|
||||
///
|
||||
/// Shorter than the success TTL, which looks backwards until you notice the two
|
||||
/// are decaying different things. A landed answer is a fact, and facts about
|
||||
/// which shells are running go stale slowly. A failure is the absence of a fact:
|
||||
/// it carries nothing, it is the state the user most wants corrected, and the
|
||||
/// thing that would correct it — the link coming back — is exactly what this
|
||||
/// interval decides how fast we notice.
|
||||
const UNREACHABLE_TTL: Duration = Duration::from_secs(6);
|
||||
|
||||
/// How often [`sweep`] is allowed to walk the workspace list.
|
||||
///
|
||||
/// The sweep itself is called from `render`, which on a 120Hz display is 120
|
||||
/// times a second; the walk builds a `pane_ids()` vector and a connection key
|
||||
/// per workspace, which is not free enough to do that often. Everything past
|
||||
/// this gate is idempotent, so the only cost of the gate is that a probe may
|
||||
/// start up to a quarter-second late.
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
thread_local! {
|
||||
/// When [`sweep`] last walked the list.
|
||||
///
|
||||
/// Deliberately *not* a field of [`PaneLivenessCache`]: reaching a global
|
||||
/// mutably notifies its observers, the app repaints on that notification,
|
||||
/// and the repaint sweeps again — a stamp stored in the global would spin
|
||||
/// the render loop at full speed forever. It is also honestly thread-local
|
||||
/// state, since only the UI thread ever sweeps.
|
||||
static LAST_SWEEP: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||
static LAST_SWEEP: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// What is known about a workspace's panes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Liveness {
|
||||
/// The machine answered and at least one of the workspace's panes is still
|
||||
/// running: reopening it reattaches to live shells.
|
||||
Alive,
|
||||
/// The machine answered and none of them are: the saved layout is all that
|
||||
/// is left, and reopening spawns fresh.
|
||||
Stopped,
|
||||
/// The machine could not be asked. Not the same as `Stopped` — see the
|
||||
/// module docs.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One machine's last landed answer.
|
||||
struct Answer {
|
||||
/// When it landed, for the TTL.
|
||||
at: Instant,
|
||||
/// The live pane ids the daemon reported, or `None` when the query failed.
|
||||
alive: Option<HashSet<u64>>,
|
||||
}
|
||||
|
||||
impl Answer {
|
||||
/// Whether this answer may still be used without re-asking.
|
||||
fn fresh(&self, host: HostId) -> bool {
|
||||
let ttl = match (&self.alive, host.is_local()) {
|
||||
(None, _) => UNREACHABLE_TTL,
|
||||
@@ -144,35 +43,16 @@ impl Answer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide liveness store (a gpui [`Global`](gpui::Global)), keyed by
|
||||
/// machine.
|
||||
///
|
||||
/// Per **machine**, not per workspace: two workspaces on one box share a pane
|
||||
/// registry, so they share one query. That is the same granularity
|
||||
/// [`HostId`] already has everywhere else in the client.
|
||||
#[derive(Default)]
|
||||
pub struct PaneLivenessCache {
|
||||
answers: HashMap<HostId, Answer>,
|
||||
/// Queries out, so a render that re-asks before one lands does not start a
|
||||
/// second. There is nothing to supersede a liveness answer with, so only
|
||||
/// the in-flight half of [`InFlight`] is used.
|
||||
probes: InFlight<HostId>,
|
||||
}
|
||||
|
||||
impl gpui::Global for PaneLivenessCache {}
|
||||
|
||||
impl PaneLivenessCache {
|
||||
/// Whether any of `pane_ids` is still running on `host`.
|
||||
///
|
||||
/// `pane_ids` must be the ids **that machine** minted — i.e. the ids of a
|
||||
/// workspace whose `host_id()` is `host`. Mixing them is the bug the whole
|
||||
/// module exists to prevent, and keying by host is how it is prevented:
|
||||
/// there is no way to spell the question without naming the machine.
|
||||
pub fn liveness(&self, host: HostId, pane_ids: &[u64]) -> Liveness {
|
||||
// Claims nothing, so nothing about it is in question — not even on a
|
||||
// machine we cannot reach. Answered before the cache is consulted so an
|
||||
// empty workspace never draws the "unknown" dot while it waits for an
|
||||
// answer that could not change it.
|
||||
if pane_ids.is_empty() {
|
||||
return Liveness::Stopped;
|
||||
}
|
||||
@@ -184,23 +64,15 @@ impl PaneLivenessCache {
|
||||
Liveness::Stopped
|
||||
}
|
||||
}
|
||||
// Never asked, or asked and refused. On this machine that is not a
|
||||
// mystery — an unreachable local daemon is a daemon with no panes
|
||||
// in it — so local resolves to `Stopped` and keeps the pre-remote
|
||||
// rendering exactly as it was.
|
||||
None if host.is_local() => Liveness::Stopped,
|
||||
None => Liveness::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The live ids `host` last reported, or `None` when the last word from it
|
||||
/// was a failure (or there has been no word at all).
|
||||
fn alive_set(&self, host: HostId) -> Option<&HashSet<u64>> {
|
||||
self.answers.get(&host)?.alive.as_ref()
|
||||
}
|
||||
|
||||
/// Whether a probe for `host` is worth starting: nothing fresh cached, and
|
||||
/// nothing already in flight.
|
||||
pub fn needs_probe(&self, host: HostId) -> bool {
|
||||
!self.probes.is_pending(&host)
|
||||
&& !self
|
||||
@@ -209,13 +81,10 @@ impl PaneLivenessCache {
|
||||
.is_some_and(|answer| answer.fresh(host))
|
||||
}
|
||||
|
||||
/// Claim the probe for `host`. `false` when someone else already has it.
|
||||
pub fn begin_probe(&mut self, host: HostId) -> bool {
|
||||
self.probes.begin(host)
|
||||
}
|
||||
|
||||
/// Fold a landed probe in: `Some(ids)` when the daemon answered, `None`
|
||||
/// when it could not be reached.
|
||||
pub fn finish_probe(&mut self, host: HostId, alive: Option<HashSet<u64>>) {
|
||||
self.probes.finish(&host);
|
||||
self.answers.insert(
|
||||
@@ -227,46 +96,22 @@ impl PaneLivenessCache {
|
||||
);
|
||||
}
|
||||
|
||||
/// Forget what `host` said, so the next sweep asks again.
|
||||
///
|
||||
/// For the moments the app itself made the answer wrong — stopping a
|
||||
/// workspace kills panes the cache still lists — where waiting out the TTL
|
||||
/// would leave a green dot on a workspace the user just shut down.
|
||||
pub fn invalidate(&mut self, host: HostId) {
|
||||
self.answers.remove(&host);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PaneLivenessCache::liveness`] for a whole workspace, read-only.
|
||||
///
|
||||
/// The one call the render sites make. It cannot ask the wrong machine: the
|
||||
/// host and the ids both come off the same [`WindowView`].
|
||||
pub fn liveness_of(cx: &App, workspace: &WindowView) -> Liveness {
|
||||
let host = workspace.host_id();
|
||||
// The ids live in the machine's tree; its mirror is where they are read. A
|
||||
// machine whose tree has not been pulled leaves us with no question to ask,
|
||||
// which is `Unknown` on any machine — reading it as `Stopped` would tell the
|
||||
// user their sessions are gone on the strength of our own ignorance. See the
|
||||
// module docs for why this is *not* the same as a failed local `List`.
|
||||
let Some(ids) = crate::ui::machine_mirror::pane_ids(cx, workspace) else {
|
||||
return Liveness::Unknown;
|
||||
};
|
||||
match cx.try_global::<PaneLivenessCache>() {
|
||||
Some(cache) => cache.liveness(host, &ids),
|
||||
// Before the app has installed the global. Asked of an empty cache
|
||||
// rather than answered here, so there is exactly one place that decides
|
||||
// what "nothing known yet" looks like and the first frame draws what
|
||||
// every later one will.
|
||||
None => PaneLivenessCache::default().liveness(host, &ids),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start whatever liveness queries the workspace list is missing.
|
||||
///
|
||||
/// Safe to call from `render`: it reads the cache, may start background work,
|
||||
/// and never blocks or waits. Rate-limited by [`SWEEP_INTERVAL`], deduplicated
|
||||
/// per machine by [`InFlight`], and gated per machine by the TTL — so a picker
|
||||
/// sitting open does not turn into a query loop.
|
||||
pub fn sweep(cx: &mut App) {
|
||||
let now = Instant::now();
|
||||
if LAST_SWEEP.get().is_some_and(|at| now < at + SWEEP_INTERVAL) {
|
||||
@@ -274,10 +119,6 @@ pub fn sweep(cx: &mut App) {
|
||||
}
|
||||
LAST_SWEEP.set(Some(now));
|
||||
|
||||
// One workspace per machine is enough to build that machine's route, and
|
||||
// only workspaces that claim panes have anything to ask about. Collected
|
||||
// first so the borrow of the store is released before the probes, which
|
||||
// need `cx` mutably.
|
||||
let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new();
|
||||
for w in &WorkspaceStore::all(cx).views {
|
||||
let host = w.host_id();
|
||||
@@ -294,10 +135,6 @@ pub fn sweep(cx: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask one machine, in the background.
|
||||
///
|
||||
/// `workspace` is only used to build the route; the answer is stored against
|
||||
/// the machine, and every workspace on it reads the same one.
|
||||
fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
|
||||
if !cx
|
||||
.try_global::<PaneLivenessCache>()
|
||||
@@ -305,31 +142,16 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Never dial for a dot. A routed query to a machine this process is not
|
||||
// connected to would have the daemon open an SSH session — with whatever
|
||||
// passphrase prompt and multi-second handshake that entails — because a
|
||||
// picker row was on screen. Unconnected stays `Unknown`, which is exactly
|
||||
// what it is.
|
||||
//
|
||||
// Recorded as a landed failure rather than returned from: a bare `return`
|
||||
// would leave `needs_probe` true, so the next frame would re-decide this,
|
||||
// and `HostLinks::get` reaches its global mutably — which notifies,
|
||||
// which repaints, which sweeps. Storing the answer puts the decision behind
|
||||
// the same TTL as every other one.
|
||||
if !host.is_local() && crate::ui::remote_connect::HostLinks::get(cx, host).is_none() {
|
||||
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, None));
|
||||
return;
|
||||
}
|
||||
let route = crate::ui::remote_workspace::pane_route_for(cx, workspace);
|
||||
// `global_mut` notifies, so the claim is taken last: everything above can
|
||||
// decline without costing a repaint.
|
||||
if !cx.global_mut::<PaneLivenessCache>().begin_probe(host) {
|
||||
return;
|
||||
}
|
||||
cx.spawn(async move |cx| {
|
||||
let alive = cx.background_spawn(async move { query(&route) }).await;
|
||||
// Landed through `update_global`, which is what wakes the
|
||||
// `observe_global` hook that repaints the picker and the title bar.
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, alive));
|
||||
});
|
||||
@@ -337,9 +159,6 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The blocking half: one `List` down `route`, reduced to the ids that are
|
||||
/// still running. `None` is "could not ask", which is the distinction the whole
|
||||
/// three-state rendering rests on.
|
||||
fn query(route: &PaneRoute) -> Option<HashSet<u64>> {
|
||||
match RemoteTerminal::try_list_panes_on(route) {
|
||||
Ok(panes) => Some(
|
||||
@@ -360,8 +179,6 @@ fn query(route: &PaneRoute) -> Option<HashSet<u64>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A remote machine, and a second one, so "keyed by host" can be tested
|
||||
/// rather than asserted.
|
||||
fn box_a() -> HostId {
|
||||
HostId::from_connection_key("ssh-direct:me@a:22")
|
||||
}
|
||||
@@ -369,65 +186,42 @@ mod tests {
|
||||
HostId::from_connection_key("ssh-direct:me@b:22")
|
||||
}
|
||||
|
||||
/// The three states, each from the input that produces it.
|
||||
#[test]
|
||||
fn the_three_states_come_from_three_different_situations() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
let host = box_a();
|
||||
|
||||
// Never asked: not "stopped" — unknown.
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown);
|
||||
|
||||
// Asked, and the machine listed one of them.
|
||||
cache.finish_probe(host, Some(HashSet::from([2, 9])));
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Alive);
|
||||
|
||||
// Asked, and none of this workspace's panes are in the answer.
|
||||
assert_eq!(cache.liveness(host, &[1, 3]), Liveness::Stopped);
|
||||
|
||||
// Asked and could not be reached: back to unknown, *not* stopped —
|
||||
// the panes are very probably still running over there.
|
||||
cache.finish_probe(host, None);
|
||||
assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown);
|
||||
}
|
||||
|
||||
/// The bug. Two machines mint the same small pane ids, and a workspace on
|
||||
/// one must never be lit up by the other's registry.
|
||||
#[test]
|
||||
fn one_machines_answer_never_speaks_for_another() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
// The local daemon is running panes 1 and 2 — the ids a fresh daemon
|
||||
// on any machine hands out first.
|
||||
cache.finish_probe(HostId::LOCAL, Some(HashSet::from([1, 2])));
|
||||
// The box has been off for a week: nothing of its own is claimed here.
|
||||
assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown);
|
||||
// And a *third* machine's answer does not leak into the second's.
|
||||
cache.finish_probe(box_b(), Some(HashSet::from([1, 2])));
|
||||
assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown);
|
||||
assert_eq!(cache.liveness(box_b(), &[1, 2]), Liveness::Alive);
|
||||
// Local still reads exactly as it always did.
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1, 2]), Liveness::Alive);
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[7]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// This machine never shows "unknown": an unreachable local daemon is a
|
||||
/// daemon with nothing running in it, and the picker drew that as a plain
|
||||
/// badge long before any of this was routed.
|
||||
#[test]
|
||||
fn local_never_renders_as_unknown() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
// Nothing asked yet — the state every first frame is in.
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped);
|
||||
// Asked, and no daemon answered.
|
||||
cache.finish_probe(HostId::LOCAL, None);
|
||||
assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// A workspace that claims no panes is stopped on any machine — there is
|
||||
/// nothing for an answer to contain, so it is not "unknown" either, even on
|
||||
/// a machine that was never asked. Caught in the GUI: an empty remote
|
||||
/// workspace sat in the picker wearing the muted dot, which reads as "we
|
||||
/// could not check" about a workspace there is nothing to check.
|
||||
#[test]
|
||||
fn a_workspace_with_no_claimed_panes_is_never_alive() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
@@ -439,8 +233,6 @@ mod tests {
|
||||
assert_eq!(cache.liveness(box_a(), &[]), Liveness::Stopped);
|
||||
}
|
||||
|
||||
/// One query per machine in flight, and a fresh answer stops the asking —
|
||||
/// this is what keeps a picker on screen from becoming a query loop.
|
||||
#[test]
|
||||
fn probes_are_deduplicated_and_then_throttled_by_the_ttl() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
@@ -454,19 +246,13 @@ mod tests {
|
||||
cache.finish_probe(host, Some(HashSet::from([1])));
|
||||
assert!(!cache.needs_probe(host), "the answer is fresh");
|
||||
|
||||
// A failure is cached too, or an unreachable machine would be retried
|
||||
// on every frame — each retry a connect timeout on a background task.
|
||||
cache.finish_probe(host, None);
|
||||
assert!(!cache.needs_probe(host));
|
||||
|
||||
// Invalidation is the way back to asking, for the moments the app
|
||||
// itself made the answer wrong.
|
||||
cache.invalidate(host);
|
||||
assert!(cache.needs_probe(host));
|
||||
}
|
||||
|
||||
/// Each machine's freshness is its own: a fresh local answer must not stop
|
||||
/// the sweep from asking the box.
|
||||
#[test]
|
||||
fn freshness_is_per_machine() {
|
||||
let mut cache = PaneLivenessCache::default();
|
||||
@@ -475,10 +261,6 @@ mod tests {
|
||||
assert!(cache.needs_probe(box_a()));
|
||||
}
|
||||
|
||||
/// The TTLs are ordered the way the comments claim: a local socket may be
|
||||
/// re-asked far sooner than a routed round trip, and a failure — which is
|
||||
/// not a fact and is the state the user most wants corrected — is retried
|
||||
/// sooner than a success is refreshed.
|
||||
#[test]
|
||||
fn ttls_are_ordered_by_what_the_query_costs() {
|
||||
assert!(LOCAL_TTL < UNREACHABLE_TTL);
|
||||
|
||||
+3
-1127
File diff suppressed because it is too large
Load Diff
@@ -1,57 +1,27 @@
|
||||
//! Ctrl+R history search, extracted from the terminal view so the search
|
||||
//! *logic* (query editing + ranking `history` into a match list) lives apart
|
||||
//! from the GPUI plumbing (focus, repaint). The view owns an
|
||||
//! `Option<ReverseSearch>`, forwards keys and typed text to it, and acts on the
|
||||
//! returned [`Action`] — it never reaches into the query or match list beyond
|
||||
//! the read-only accessors the menu renderer uses.
|
||||
//!
|
||||
//! Matching is fuzzy (see the [`fuzzy`](super::fuzzy) module), blended with the
|
||||
//! entry's frecency so a command you run constantly — or ran *in this
|
||||
//! directory* — outranks an equally-good textual match you typed once. An
|
||||
//! empty query ranks the whole history by frecency alone, so bare Ctrl+R is a
|
||||
//! browsable "recent & relevant" list rather than a blank prompt.
|
||||
|
||||
use super::fuzzy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// How much an entry's frecency score (roughly `0..7`: recency `0..1` +
|
||||
/// dampened frequency + current-directory bonus) adds to its fuzzy match
|
||||
/// score (16+ per matched char). At 2× it decides ties and near-ties between
|
||||
/// textually similar matches without ever drowning a clearly better match.
|
||||
const FRECENCY_WEIGHT: f64 = 2.0;
|
||||
|
||||
/// In-progress search: the typed query and the ranked matches, best first.
|
||||
pub(super) struct ReverseSearch {
|
||||
query: String,
|
||||
matches: Vec<Match>,
|
||||
/// Cursor into `matches`: the entry Enter accepts, highlighted in the menu.
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
/// One ranked match: where it lives in the view's chronological `history`, and
|
||||
/// which of its chars the query matched (for menu highlighting; empty for the
|
||||
/// empty-query frecency listing).
|
||||
pub(super) struct Match {
|
||||
pub index: usize,
|
||||
pub positions: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What the view should do after handing a key to an active search.
|
||||
pub(super) enum Action {
|
||||
/// Stay open; just repaint (query, matches or selection changed).
|
||||
Redraw,
|
||||
/// Close the search and leave the edited line untouched (Esc / Ctrl+G / Ctrl+C).
|
||||
Cancel,
|
||||
/// Close the search; if `Some`, load that history line into the editor
|
||||
/// (Enter — the user still presses Enter again to run it).
|
||||
Accept(Option<String>),
|
||||
/// Close the search and run that history line outright (Cmd+Enter).
|
||||
Run(String),
|
||||
}
|
||||
|
||||
impl ReverseSearch {
|
||||
/// Open a search: the empty query immediately lists the history by
|
||||
/// frecency, so the menu is useful before a single key is typed.
|
||||
pub(super) fn new(history: &[String], frecency: &[f64]) -> Self {
|
||||
let mut rs = Self {
|
||||
query: String::new(),
|
||||
@@ -62,39 +32,29 @@ impl ReverseSearch {
|
||||
rs
|
||||
}
|
||||
|
||||
/// The typed query, for the prompt the view renders.
|
||||
pub(super) fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
|
||||
/// The ranked matches, best first — the menu renders a window of these.
|
||||
pub(super) fn matches(&self) -> &[Match] {
|
||||
&self.matches
|
||||
}
|
||||
|
||||
/// Index of the selected match within [`matches`](Self::matches).
|
||||
pub(super) fn selected(&self) -> usize {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// The history line the selection sits on, if any.
|
||||
pub(super) fn selected_line<'a>(&self, history: &'a [String]) -> Option<&'a str> {
|
||||
self.matches
|
||||
.get(self.selected)
|
||||
.map(|m| history[m.index].as_str())
|
||||
}
|
||||
|
||||
/// Recompute the match list. Entries are deduplicated by content (the most
|
||||
/// recent occurrence wins) and ranked by fuzzy score blended with frecency;
|
||||
/// an empty query ranks everything by frecency alone. `frecency` is
|
||||
/// index-aligned with `history`. Resets the selection to the best match.
|
||||
fn update(&mut self, history: &[String], frecency: &[f64]) {
|
||||
self.selected = 0;
|
||||
let list_all = self.query.trim().is_empty();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut scored: Vec<(f64, Match)> = Vec::new();
|
||||
// Newest → oldest, so the stable sort below keeps recent entries first
|
||||
// among equal scores.
|
||||
for i in (0..history.len()).rev() {
|
||||
let line = history[i].as_str();
|
||||
if !seen.insert(line) {
|
||||
@@ -123,25 +83,16 @@ impl ReverseSearch {
|
||||
self.matches = scored.into_iter().map(|(_, m)| m).collect();
|
||||
}
|
||||
|
||||
/// Move the selection `delta` steps down the ranked list (positive → worse
|
||||
/// matches, the classic "older hit" direction of a repeated Ctrl+R),
|
||||
/// sticking at the ends.
|
||||
fn step(&mut self, delta: isize) {
|
||||
let last = self.matches.len().saturating_sub(1);
|
||||
self.selected = self.selected.saturating_add_signed(delta).min(last);
|
||||
}
|
||||
|
||||
/// Append typed text to the query and re-rank. Text arrives either via the
|
||||
/// IME path (`replace_text_in_range` → the view's `input_text`) or, for a
|
||||
/// plain ASCII input source, as a direct `key_char` the view forwards from
|
||||
/// `handle_reverse_search_key`.
|
||||
pub(super) fn push_query(&mut self, text: &str, history: &[String], frecency: &[f64]) {
|
||||
self.query.push_str(text);
|
||||
self.update(history, frecency);
|
||||
}
|
||||
|
||||
/// Handle a key while the search is active. Query text itself arrives via
|
||||
/// [`push_query`](Self::push_query); this covers the control keys only.
|
||||
pub(super) fn handle_key(
|
||||
&mut self,
|
||||
ks: &gpui::Keystroke,
|
||||
@@ -151,23 +102,17 @@ impl ReverseSearch {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
if (m.control && key == "r") || key == "down" {
|
||||
// Next (worse-ranked) match — the classic repeated-Ctrl+R step.
|
||||
self.step(1);
|
||||
Action::Redraw
|
||||
} else if (m.control && key == "s") || key == "up" {
|
||||
// Back toward the best match (readline's forward-search direction).
|
||||
self.step(-1);
|
||||
Action::Redraw
|
||||
} else if (m.control && (key == "g" || key == "c")) || key == "escape" {
|
||||
Action::Cancel
|
||||
} else if key == "enter" || (m.control && (key == "j" || key == "m")) {
|
||||
// ⌃J / ⌃M are accept-line's control codes — Enter by another name.
|
||||
let line = self.selected_line(history).map(str::to_string);
|
||||
match (m.platform, line) {
|
||||
// Cmd+Enter: run the selected line outright.
|
||||
(true, Some(line)) => Action::Run(line),
|
||||
// Enter: hand back the match (the user still presses Enter to
|
||||
// run it). A bare Enter with no match just exits the search.
|
||||
(_, line) => Action::Accept(line),
|
||||
}
|
||||
} else if key == "backspace" {
|
||||
@@ -175,7 +120,6 @@ impl ReverseSearch {
|
||||
self.update(history, frecency);
|
||||
Action::Redraw
|
||||
} else {
|
||||
// Other keys are ignored while searching.
|
||||
Action::Redraw
|
||||
}
|
||||
}
|
||||
@@ -186,14 +130,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn history() -> Vec<String> {
|
||||
// oldest → newest
|
||||
["git status", "cargo build", "git commit -m x", "cargo test"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Uniform frecency: ranking falls back to fuzzy score + recency order.
|
||||
fn flat(h: &[String]) -> Vec<f64> {
|
||||
vec![0.0; h.len()]
|
||||
}
|
||||
@@ -216,28 +158,22 @@ mod tests {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("git", &h, &flat(&h));
|
||||
// Both git commands match equally well; the newer one wins the tie.
|
||||
assert_eq!(rs.selected_line(&h), Some("git commit -m x"));
|
||||
assert_eq!(rs.matches().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matching_spans_words() {
|
||||
// `gst` is a subsequence of `git status` — the substring search this
|
||||
// replaces could never find it.
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("gst", &h, &flat(&h));
|
||||
assert_eq!(rs.selected_line(&h), Some("git status"));
|
||||
// The matched positions point at g, s, t for the menu highlight.
|
||||
assert_eq!(rs.matches()[0].positions, vec![0, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_outranks_recency_between_equal_text_matches() {
|
||||
let h = history();
|
||||
// "git status" (oldest) is heavily used; the newer "git commit -m x"
|
||||
// is a one-off. The blend should float the frequent one on top.
|
||||
let frecency = vec![5.0, 0.0, 0.0, 0.0];
|
||||
let mut rs = ReverseSearch::new(&h, &frecency);
|
||||
rs.push_query("git", &h, &frecency);
|
||||
@@ -249,7 +185,7 @@ mod tests {
|
||||
let h: Vec<String> = ["ls", "make", "ls"].into_iter().map(String::from).collect();
|
||||
let rs = ReverseSearch::new(&h, &flat(&h));
|
||||
let idx: Vec<usize> = rs.matches().iter().map(|m| m.index).collect();
|
||||
assert_eq!(idx, [2, 1]); // one "ls", at its newest position
|
||||
assert_eq!(idx, [2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,10 +199,8 @@ mod tests {
|
||||
Action::Redraw
|
||||
));
|
||||
assert_eq!(rs.selected_line(&h), Some("git status"));
|
||||
// Already on the last match — a further step sticks.
|
||||
rs.handle_key(&key("ctrl-r"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 1);
|
||||
// Ctrl+S / Up steps back toward the best match, sticking at the top.
|
||||
rs.handle_key(&key("ctrl-s"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 0);
|
||||
rs.handle_key(&key("up"), &h, &flat(&h));
|
||||
@@ -308,7 +242,6 @@ mod tests {
|
||||
Action::Run(line) => assert_eq!(line, "cargo test"),
|
||||
_ => panic!("expected Run with the selected line"),
|
||||
}
|
||||
// A bare Enter with no match accepts nothing (just exits).
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("zzz_nope", &h, &flat(&h));
|
||||
assert!(rs.matches().is_empty());
|
||||
@@ -318,8 +251,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌃J / ⌃M carry accept-line's control codes, so inside the menu they must
|
||||
/// accept the selection exactly as Enter does (#163).
|
||||
#[test]
|
||||
fn ctrl_j_and_ctrl_m_accept_like_enter() {
|
||||
let h = history();
|
||||
@@ -337,9 +268,8 @@ mod tests {
|
||||
fn handle_key_backspace_pops_query_and_re_ranks() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("gitq", &h, &flat(&h)); // no match (no q anywhere)
|
||||
rs.push_query("gitq", &h, &flat(&h));
|
||||
assert!(rs.matches().is_empty());
|
||||
// Backspace drops the trailing 'q', restoring the git matches.
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("backspace"), &h, &flat(&h)),
|
||||
Action::Redraw
|
||||
@@ -352,7 +282,6 @@ mod tests {
|
||||
fn handle_key_other_keys_are_ignored_with_redraw() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
// A plain letter is handled via push_query, not handle_key; here it's a no-op redraw.
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("a"), &h, &flat(&h)),
|
||||
Action::Redraw
|
||||
|
||||
+6
-222
@@ -1,8 +1,3 @@
|
||||
//! In-terminal incremental search (Cmd+F): the `SearchState` that backs the
|
||||
//! search bar, the `TerminalView` methods that drive it (open/close, recompute
|
||||
//! the match list, step between matches) and the search-bar UI. Also hosts
|
||||
//! `url_at`, the cursor-to-URL probe used for Cmd+click link opening.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use alacritty_terminal::event::EventListener;
|
||||
@@ -19,16 +14,11 @@ use gpui_component::{
|
||||
|
||||
use super::view::TerminalView;
|
||||
|
||||
/// Upper bound on matches collected for a single query. Prevents a very broad
|
||||
/// query (e.g. one character) against a large scrollback from producing an
|
||||
/// unbounded list and stalling the recompute.
|
||||
const MAX_MATCHES: usize = 10_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) enum LinkTarget {
|
||||
Url(String),
|
||||
/// An existing local file — or directory (`line`/`column` then `None`;
|
||||
/// dirs never match a `path:line` form).
|
||||
File {
|
||||
path: PathBuf,
|
||||
line: Option<u32>,
|
||||
@@ -43,25 +33,14 @@ pub(super) struct LinkMatch {
|
||||
pub target: LinkTarget,
|
||||
}
|
||||
|
||||
/// State backing the Cmd+F search bar. The query text, caret, selection, IME
|
||||
/// composition and in-field editing keys are all owned by `input` (a
|
||||
/// gpui-component `InputState`); this struct only adds the match bookkeeping.
|
||||
pub struct SearchState {
|
||||
/// The text field. Owns focus, caret blink, IME, Cmd+A, arrow keys, etc.
|
||||
pub input: Entity<InputState>,
|
||||
/// All matches for the query, ordered from the top of the buffer (scrollback)
|
||||
/// to the bottom. Recomputed only when the query changes.
|
||||
pub matches: Vec<Match>,
|
||||
/// Index into `matches` of the focused ("current") match, or `None` when
|
||||
/// there are no matches. Single source of truth — `current()` derives the
|
||||
/// actual match from it so the two never disagree.
|
||||
pub current_index: Option<usize>,
|
||||
/// Subscription to the field's `InputEvent`s (query changes, Enter, focus).
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
/// The focused match, if any.
|
||||
pub fn current(&self) -> Option<&Match> {
|
||||
self.current_index.and_then(|i| self.matches.get(i))
|
||||
}
|
||||
@@ -69,13 +48,8 @@ impl SearchState {
|
||||
|
||||
impl TerminalView {
|
||||
pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Build the field on first open (Cmd+F again just refocuses it). The
|
||||
// InputState owns the query text, caret, selection, Cmd+A and IME.
|
||||
let fresh = self.search.is_none();
|
||||
if fresh {
|
||||
// Seed the query: a single-line terminal selection is the strongest
|
||||
// signal of intent (select-then-⌘F), otherwise fall back to the last
|
||||
// query so reopening resumes where the user left off.
|
||||
let seed = self
|
||||
.selected_search_seed()
|
||||
.unwrap_or_else(|| self.search_last_query.clone());
|
||||
@@ -95,16 +69,12 @@ impl TerminalView {
|
||||
if let Some(input) = self.search.as_ref().map(|s| s.input.clone()) {
|
||||
input.update(cx, |state, cx| state.focus(window, cx));
|
||||
}
|
||||
// A freshly seeded (or restored) query has matches to compute right away;
|
||||
// Cmd+F on an already-open bar just refocuses and keeps the current list.
|
||||
if fresh {
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The current terminal selection as a search seed: a non-empty, single-line
|
||||
/// selection with no newline. Multi-line selections aren't useful as a query.
|
||||
fn selected_search_seed(&self) -> Option<String> {
|
||||
let text = self.terminal.term.lock().selection_to_string()?;
|
||||
let trimmed = text.trim_matches(['\n', '\r']);
|
||||
@@ -116,8 +86,6 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
pub fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Remember the query so the next open resumes it (toggles persist on the
|
||||
// view already). Then tear down the field and any error state.
|
||||
if let Some(s) = self.search.as_ref() {
|
||||
self.search_last_query = s.input.read(cx).value().to_string();
|
||||
}
|
||||
@@ -125,14 +93,10 @@ impl TerminalView {
|
||||
self.search_focused = false;
|
||||
self.search_regex_error = false;
|
||||
self.terminal.term.lock().selection = None;
|
||||
// Return focus to the terminal so typing resumes feeding the PTY.
|
||||
window.focus(&self.focus_handle, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// React to the search field's events: a query change recomputes matches and
|
||||
/// Enter / Shift+Enter steps to the next / previous match. Focus changes are
|
||||
/// mirrored into `search_focused` for Escape routing in `on_key_down`.
|
||||
fn on_search_event(
|
||||
&mut self,
|
||||
_input: &Entity<InputState>,
|
||||
@@ -143,8 +107,6 @@ impl TerminalView {
|
||||
match event {
|
||||
InputEvent::Change => self.recompute_matches(cx),
|
||||
InputEvent::PressEnter { shift, .. } => {
|
||||
// Enter: next match (toward the bottom). Shift+Enter: previous
|
||||
// (toward the top). Matches are ordered top→bottom.
|
||||
let dir = if *shift {
|
||||
Direction::Left
|
||||
} else {
|
||||
@@ -163,12 +125,6 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the full match list for the current query, ordered from the top
|
||||
/// of the buffer (scrollback) to the bottom. Called only when the query
|
||||
/// changes — never per frame. Afterwards `current_index` is set to the match
|
||||
/// nearest the bottom of the viewport (mirroring the old "search up from the
|
||||
/// newest content" behavior), falling back to the first match, or `None`
|
||||
/// when there are no matches / the query is empty.
|
||||
pub(super) fn recompute_matches(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(query) = self
|
||||
.search
|
||||
@@ -185,23 +141,12 @@ impl TerminalView {
|
||||
if !query.is_empty() {
|
||||
let pattern = self.effective_search_pattern(&query);
|
||||
let compiled = RegexSearch::new(&pattern);
|
||||
// A pattern only fails to compile in regex mode (a literal query is
|
||||
// escaped, and the `(?-i)` case prefix is always valid), so a failure
|
||||
// means the user typed a broken regex — flag it instead of silently
|
||||
// showing zero matches.
|
||||
regex_error = compiled.is_err();
|
||||
if let Ok(mut regex) = compiled {
|
||||
let term = self.terminal.term.lock();
|
||||
let grid = term.grid();
|
||||
let mut origin = Point::new(grid.topmost_line(), Column(0));
|
||||
|
||||
// Walk downward collecting every match. `search_next` wraps
|
||||
// around the buffer when nothing lies ahead, so we stop as soon
|
||||
// as a returned match is not strictly past the previous one (it
|
||||
// wrapped) or once advancing past a match wraps the origin. That
|
||||
// guarantees forward progress and rules out an infinite loop.
|
||||
// MAX_MATCHES caps pathological inputs (e.g. a single-character
|
||||
// query against a huge scrollback) so a recompute stays bounded.
|
||||
while matches.len() < MAX_MATCHES {
|
||||
let Some(m) =
|
||||
term.search_next(&mut regex, origin, Direction::Right, Side::Left, None)
|
||||
@@ -219,8 +164,6 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the last match at or above the bottom of the visible
|
||||
// viewport; fall back to the first match otherwise.
|
||||
if !matches.is_empty() {
|
||||
let display_offset = grid.display_offset() as i32;
|
||||
let bottom = Point::new(
|
||||
@@ -242,9 +185,6 @@ impl TerminalView {
|
||||
}
|
||||
self.search_regex_error = regex_error;
|
||||
|
||||
// Clear any stray selection and bring the focused match into view, but
|
||||
// only when it's off-screen so an in-viewport match doesn't jerk the
|
||||
// scroll position around as the user refines the query.
|
||||
let current = self.search.as_ref().and_then(|s| s.current().cloned());
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
@@ -255,9 +195,6 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Move to the next (`Direction::Right`, toward the bottom) or previous
|
||||
/// (`Direction::Left`, toward the top) match, wrapping around, and scroll the
|
||||
/// new current match into view. Never recomputes the match list.
|
||||
pub(super) fn step_match(&mut self, direction: Direction, cx: &mut Context<Self>) {
|
||||
let current = {
|
||||
let Some(s) = self.search.as_mut() else {
|
||||
@@ -275,15 +212,10 @@ impl TerminalView {
|
||||
s.current_index = Some(next);
|
||||
s.matches[next].clone()
|
||||
};
|
||||
// Explicit navigation always reveals the target: unlike a live query
|
||||
// change, stepping past a match already on screen should still recenter
|
||||
// it if it sits off-screen, but leave the viewport alone when it's visible.
|
||||
scroll_match_into_view(&mut self.terminal.term.lock(), ¤t);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Toggle the "Aa" (force case-sensitive) option and re-search. Does nothing
|
||||
/// when the bar is closed.
|
||||
fn toggle_search_case(&mut self, cx: &mut Context<Self>) {
|
||||
if self.search.is_none() {
|
||||
return;
|
||||
@@ -292,7 +224,6 @@ impl TerminalView {
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
|
||||
/// Toggle the ".*" (regex vs literal) option and re-search.
|
||||
fn toggle_search_regex(&mut self, cx: &mut Context<Self>) {
|
||||
if self.search.is_none() {
|
||||
return;
|
||||
@@ -301,11 +232,6 @@ impl TerminalView {
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
|
||||
/// Turn the user's query into the pattern fed to alacritty's `RegexSearch`,
|
||||
/// applying the two toggles. In literal mode the query is regex-escaped so
|
||||
/// metacharacters (`.`, `*`, `(`, …) match themselves. A `(?-i)` prefix forces
|
||||
/// case sensitivity when "Aa" is on; when off, alacritty's smart-case default
|
||||
/// applies (insensitive unless the query already contains an uppercase char).
|
||||
fn effective_search_pattern(&self, query: &str) -> String {
|
||||
let base = if self.search_regex {
|
||||
query.to_string()
|
||||
@@ -325,36 +251,25 @@ impl TerminalView {
|
||||
_window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement + use<> {
|
||||
// Snapshot theme colors up front so the `cx` borrow is released before we
|
||||
// build click listeners with `cx.listener` below.
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
let border = theme.border;
|
||||
let popover = theme.popover;
|
||||
let accent = theme.accent;
|
||||
let danger = theme.red;
|
||||
// (The `theme` borrow of `cx` ends here, before the `cx.listener` calls below.)
|
||||
|
||||
let total = state.matches.len();
|
||||
let has_query = !state.input.read(cx).value().is_empty();
|
||||
let has_matches = !state.matches.is_empty();
|
||||
// Highlight the border while the field is focused so the bar reads as the
|
||||
// active input. Caret/selection/IME all live inside the field itself. A
|
||||
// broken regex (only possible in regex mode) turns the border red instead.
|
||||
let focused = self.search_focused;
|
||||
let regex_error = self.search_regex_error;
|
||||
let case_on = self.search_case_sensitive;
|
||||
let regex_on = self.search_regex;
|
||||
|
||||
// The query field — a gpui-component InputState. It owns focus, the
|
||||
// blinking caret, text selection, Cmd+A, arrow keys and IME composition.
|
||||
// `appearance(false)` drops its own border/background so it sits flush in
|
||||
// our bar instead of looking like a nested box.
|
||||
let field = Input::new(&state.input)
|
||||
.appearance(false)
|
||||
.with_size(Size::Small);
|
||||
|
||||
// Match counter `current/total`, only once something has been typed.
|
||||
let count = has_query.then(|| {
|
||||
let current = if has_matches {
|
||||
state.current_index.map(|i| i + 1).unwrap_or(0)
|
||||
@@ -368,9 +283,6 @@ impl TerminalView {
|
||||
.child(format!("{current}/{total}"))
|
||||
});
|
||||
|
||||
// Option toggles: "Aa" forces case-sensitive matching, ".*" switches the
|
||||
// query between literal and regex. Both read as pressed (accent fill) when
|
||||
// active and re-search on click.
|
||||
let case_toggle = Button::new("search-case")
|
||||
.label("Aa")
|
||||
.ghost()
|
||||
@@ -390,12 +302,8 @@ impl TerminalView {
|
||||
this.toggle_search_regex(cx);
|
||||
}));
|
||||
|
||||
// Thin rule separating the query zone from the action buttons.
|
||||
let divider = div().flex_none().w(px(1.)).h(px(16.)).bg(border);
|
||||
|
||||
// ↑ = previous match (toward the top), ↓ = next (toward the bottom) —
|
||||
// mirroring the Enter / Shift+Enter bindings. Button stops propagation
|
||||
// internally, so clicks won't bubble to the terminal surface.
|
||||
let prev = Button::new("search-prev")
|
||||
.icon(IconName::ChevronUp)
|
||||
.ghost()
|
||||
@@ -424,10 +332,6 @@ impl TerminalView {
|
||||
.absolute()
|
||||
.top_2()
|
||||
.right_4()
|
||||
// Block mouse events over the bar so a click (or drag) on it doesn't
|
||||
// fall through to the terminal surface and start a selection — the
|
||||
// terminal's mouse handlers gate on `Hitbox::is_hovered`, which this
|
||||
// occluding hitbox turns off for the cells beneath the bar.
|
||||
.occlude()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -447,8 +351,6 @@ impl TerminalView {
|
||||
})
|
||||
.bg(popover)
|
||||
.shadow_md()
|
||||
// The field fills the remaining width; count + toggles + buttons keep
|
||||
// fixed size.
|
||||
.child(div().flex_1().min_w_0().child(field))
|
||||
.children(count)
|
||||
.child(case_toggle)
|
||||
@@ -460,11 +362,6 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll `term` so `m`'s start is on screen, but only when it isn't already —
|
||||
/// an in-viewport match keeps the current scroll position so refining the query
|
||||
/// or stepping between nearby matches doesn't jerk the view around. The visible
|
||||
/// line range for the current `display_offset` is `[-offset, screen_lines-1-offset]`
|
||||
/// (the same arithmetic `recompute_matches` uses to pick the initial match).
|
||||
fn scroll_match_into_view<T: EventListener>(term: &mut Term<T>, m: &Match) {
|
||||
let grid = term.grid();
|
||||
let display_offset = grid.display_offset() as i32;
|
||||
@@ -476,9 +373,6 @@ fn scroll_match_into_view<T: EventListener>(term: &mut Term<T>, m: &Match) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape regex metacharacters so a literal-mode query matches itself. Mirrors
|
||||
/// `regex::escape` (which isn't a direct dependency): backslash-prefix every
|
||||
/// character the regex parser treats as special.
|
||||
fn regex_escape(query: &str) -> String {
|
||||
let mut out = String::with_capacity(query.len());
|
||||
for c in query.chars() {
|
||||
@@ -509,16 +403,11 @@ fn regex_escape(query: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Test-only convenience over [`url_span_at`]: just the resolved address.
|
||||
#[cfg(test)]
|
||||
pub(super) fn url_at(text: &str, col: usize) -> Option<String> {
|
||||
url_span_at(text, col).map(|(_, _, url)| url)
|
||||
}
|
||||
|
||||
/// Detect a link spanning column `col` within a line's text: a bare URL
|
||||
/// always (see [`url_span_at`]), plus an existing file or directory path when
|
||||
/// `include_files` — URL detection wins when both would match. `cwd` anchors
|
||||
/// relative paths and `~` expansion.
|
||||
pub(super) fn link_at(
|
||||
text: &str,
|
||||
col: usize,
|
||||
@@ -537,11 +426,6 @@ pub(super) fn link_at(
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Detect a bare URL spanning column `col` within a line's text. Splits on
|
||||
/// whitespace and accepts tokens starting with a known scheme (or `www.`),
|
||||
/// trimming trailing punctuation that's usually not part of the link. Also
|
||||
/// reports the inclusive column span `[start, end]` the URL token occupies in
|
||||
/// `text`, used to underline the exact cells on hover.
|
||||
pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, String)> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if col >= chars.len() {
|
||||
@@ -550,7 +434,6 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin
|
||||
if chars[col].is_whitespace() {
|
||||
return None;
|
||||
}
|
||||
// Expand to the surrounding non-whitespace token.
|
||||
let mut start = col;
|
||||
while start > 0 && !chars[start - 1].is_whitespace() {
|
||||
start -= 1;
|
||||
@@ -560,46 +443,21 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin
|
||||
end += 1;
|
||||
}
|
||||
let mut token: String = chars[start..=end].iter().collect();
|
||||
// Strip trailing punctuation (see `trim_trailing_punct`) so the underline stops
|
||||
// where the link does. None of these characters occur inside real URLs.
|
||||
trim_trailing_punct(&mut token);
|
||||
|
||||
// A URL is frequently glued to preceding text with no ASCII whitespace: not
|
||||
// only wrappers like `(`/`[`, but CJK prose and full-width punctuation, e.g.
|
||||
// `已创建:https://…`. Rather than enumerate every possible prefix, find where a
|
||||
// known scheme begins inside the token and drop everything before it, advancing
|
||||
// `start` by the number of (possibly multi-byte) chars removed so the reported
|
||||
// span still lines up with the cells.
|
||||
const SCHEMES: [&str; 4] = ["https://", "http://", "file://", "ftp://"];
|
||||
if let Some(off) = SCHEMES.iter().filter_map(|s| token.find(s)).min() {
|
||||
start += token[..off].chars().count();
|
||||
token.drain(..off);
|
||||
// A URL can also be glued to *following* prose with no ASCII space, e.g.
|
||||
// `…/pull/343(fix/… → dev)`, where the full-width `(` opens a parenthetical
|
||||
// that the whitespace split can't separate. URL characters are all ASCII
|
||||
// (RFC 3986), so truncate at the first char that can't appear in one — a CJK
|
||||
// character, full-width bracket, arrow or emoji — which marks where it ends.
|
||||
if let Some(bad) = token.find(|c| !is_url_char(c)) {
|
||||
token.truncate(bad);
|
||||
}
|
||||
// ASCII `(`/`)` pass the char test (Wikipedia URLs use them), but a closer
|
||||
// with no matching opener *inside the URL* belongs to the prose around it:
|
||||
// `(…/pull/43)(Fixes` must end at `43`, not swallow `)(Fixes`. Cut at the
|
||||
// first unbalanced closer; what survives is balanced, so the trailing trim
|
||||
// below knows any `)`/`]` still standing is part of the address.
|
||||
truncate_at_unbalanced_close(&mut token);
|
||||
// Truncating there can re-expose trailing punctuation (`a.com,说明` → `a.com,`).
|
||||
trim_trailing_punct(&mut token);
|
||||
let end = start + token.chars().count() - 1;
|
||||
// Only resolve when the cursor actually sits on the URL, not on the prefix
|
||||
// we dropped — for spaceless CJK that prefix can be a whole sentence.
|
||||
return (start..=end).contains(&col).then_some((start, end, token));
|
||||
}
|
||||
|
||||
// No explicit scheme: fall back to a bare `www.` host, trimming the ASCII
|
||||
// wrappers URLs are commonly parenthesized or quoted with (e.g. `(www.x)`).
|
||||
// Advance `start` per removed char so the reported span stays aligned; these
|
||||
// wrappers are ASCII, so `remove(0)` stays on a boundary.
|
||||
while token
|
||||
.chars()
|
||||
.next()
|
||||
@@ -608,9 +466,6 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin
|
||||
token.remove(0);
|
||||
start += 1;
|
||||
}
|
||||
// Removing the wrappers can orphan their closing halves (`(www.x)` kept its
|
||||
// `)` through the first trim because the pair looked balanced): trim again
|
||||
// now that the openers are gone.
|
||||
trim_trailing_punct(&mut token);
|
||||
if token.starts_with("www.") && token.contains('.') {
|
||||
let end = start + token.chars().count() - 1;
|
||||
@@ -636,9 +491,6 @@ fn file_span_at(text: &str, col: usize, cwd: Option<&Path>) -> Option<LinkMatch>
|
||||
location = split_file_location(&token);
|
||||
}
|
||||
|
||||
// A `:line` suffix only makes sense for a file — without requiring one,
|
||||
// `localhost:8080` would link whenever a directory named `localhost`
|
||||
// happens to exist in the cwd.
|
||||
let path = resolve_existing_path(&location.path, cwd, location.line.is_some())?;
|
||||
(start..=end).contains(&col).then_some(LinkMatch {
|
||||
start,
|
||||
@@ -804,12 +656,6 @@ fn home_from_cwd(_cwd: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Trim trailing punctuation a URL gets glued to in prose — `.,;:'"` and `>` plus
|
||||
/// their full-width / CJK counterparts — so the link stops where the address does.
|
||||
/// None of these characters occur at the end of a real URL. ASCII `)` and `]` *can*
|
||||
/// (`…/Rust_(programming_language)`), so those are stripped only while unmatched
|
||||
/// within the token — a closer with an opener earlier in the token is part of the
|
||||
/// address (or of a wrapper pair the leading-strip will remove), not glue.
|
||||
fn trim_trailing_punct(token: &mut String) {
|
||||
loop {
|
||||
let strip = match token.chars().next_back() {
|
||||
@@ -832,10 +678,6 @@ fn count_char(s: &str, needle: char) -> usize {
|
||||
s.chars().filter(|&c| c == needle).count()
|
||||
}
|
||||
|
||||
/// Cut `token` at the first ASCII `)` or `]` that has no matching opener before it
|
||||
/// in the token. Balanced pairs — legal and common in URLs — survive; the first
|
||||
/// orphan closer marks where surrounding prose (`(url)(more…`, `[see url] next`)
|
||||
/// takes over. Parens and brackets balance independently, each as a plain counter.
|
||||
fn truncate_at_unbalanced_close(token: &mut String) {
|
||||
let mut parens = 0usize;
|
||||
let mut brackets = 0usize;
|
||||
@@ -858,9 +700,6 @@ fn truncate_at_unbalanced_close(token: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` may appear inside a URL per RFC 3986 (unreserved + reserved + `%`).
|
||||
/// Every such character is ASCII, so any CJK character, full-width bracket, arrow or
|
||||
/// emoji is rejected — which is what lets a URL be cut off from trailing CJK prose.
|
||||
pub(super) fn is_url_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
@@ -896,18 +735,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn regex_escape_neutralizes_metacharacters() {
|
||||
// A literal query for regex metacharacters must match them verbatim.
|
||||
assert_eq!(regex_escape("a.b*c"), r"a\.b\*c");
|
||||
assert_eq!(regex_escape("foo(bar)"), r"foo\(bar\)");
|
||||
assert_eq!(regex_escape("1+1=2"), r"1\+1=2");
|
||||
// Plain alphanumerics are left untouched.
|
||||
assert_eq!(regex_escape("hello"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_detects_http_and_strips_trailing_punct() {
|
||||
let line = "go https://example.com, now";
|
||||
// A column anywhere inside the URL token resolves the whole URL.
|
||||
assert_eq!(url_at(line, 6).as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
@@ -918,14 +754,13 @@ mod tests {
|
||||
Some("https://www.rust-lang.org")
|
||||
);
|
||||
assert_eq!(url_at("just a word", 6), None);
|
||||
assert_eq!(url_at("word ", 4), None); // whitespace cell
|
||||
assert_eq!(url_at("word", 99), None); // out of range
|
||||
assert_eq!(url_at("word ", 4), None);
|
||||
assert_eq!(url_at("word", 99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_span_at_reports_inclusive_columns_without_trailing_punct() {
|
||||
let line = "go https://example.com, now";
|
||||
// The URL occupies columns 3..=21; the trailing comma is excluded.
|
||||
assert_eq!(
|
||||
url_span_at(line, 10),
|
||||
Some((3, 21, "https://example.com".to_string()))
|
||||
@@ -947,8 +782,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_span_at_strips_various_trailing_punctuation() {
|
||||
// Only *trailing* punctuation is trimmed (the token must still start with a
|
||||
// scheme): closing bracket, angle bracket, quote, colon and semicolon.
|
||||
assert_eq!(
|
||||
url_at("open https://a.com] done", 7).as_deref(),
|
||||
Some("https://a.com")
|
||||
@@ -957,7 +790,6 @@ mod tests {
|
||||
url_at("open https://a.com> done", 7).as_deref(),
|
||||
Some("https://a.com")
|
||||
);
|
||||
// A run of mixed trailing punctuation is all trimmed.
|
||||
assert_eq!(
|
||||
url_at("open https://a.com';: done", 7).as_deref(),
|
||||
Some("https://a.com")
|
||||
@@ -966,8 +798,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_span_at_strips_leading_wrappers() {
|
||||
// Parenthesized / bracketed / angle-bracketed / quoted URLs are common in
|
||||
// prose and logs; the leading wrapper must be trimmed so the link resolves.
|
||||
assert_eq!(
|
||||
url_at("see (https://a.com) ok", 8).as_deref(),
|
||||
Some("https://a.com")
|
||||
@@ -984,7 +814,6 @@ mod tests {
|
||||
url_at("say \"https://a.com\" ok", 8).as_deref(),
|
||||
Some("https://a.com")
|
||||
);
|
||||
// A bare www. wrapped in parens is still promoted to https.
|
||||
assert_eq!(
|
||||
url_at("(www.rust-lang.org)", 5).as_deref(),
|
||||
Some("https://www.rust-lang.org")
|
||||
@@ -993,57 +822,42 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_span_at_reports_trimmed_span_after_stripping_both_ends() {
|
||||
// The reported inclusive span must cover only the URL cells, excluding both
|
||||
// the leading `[` and the trailing `]`.
|
||||
let line = "log [https://a.com] end";
|
||||
let (start, end, url) = url_span_at(line, 8).expect("URL inside the brackets");
|
||||
assert_eq!(url, "https://a.com");
|
||||
assert_eq!(&line[start..=end], "https://a.com");
|
||||
// The bracket cells sit just outside the reported span.
|
||||
assert_eq!(&line[start - 1..start], "[");
|
||||
assert_eq!(&line[end + 1..end + 2], "]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_detects_url_glued_to_cjk_prefix() {
|
||||
// Regression: a URL glued to CJK prose + a full-width colon, with no ASCII
|
||||
// whitespace between them (`PR 已创建:https://…`). The scheme is found
|
||||
// inside the token and the prefix dropped, so the link still resolves.
|
||||
let url = "https://github.com/acme/app/pull/42";
|
||||
let line = format!("已创建:{url}");
|
||||
// Column of the `h` in `https` (after the 3 hanzi + full-width colon).
|
||||
let scheme_col = 4;
|
||||
assert_eq!(url_at(&line, scheme_col).as_deref(), Some(url));
|
||||
// Hovering deeper inside the URL resolves it too.
|
||||
assert_eq!(url_at(&line, 12).as_deref(), Some(url));
|
||||
// The reported span starts at the scheme, excluding the `已创建:` prefix.
|
||||
let (start, end, got) = url_span_at(&line, scheme_col).expect("URL after prefix");
|
||||
assert_eq!(start, scheme_col);
|
||||
assert_eq!(got, url);
|
||||
assert_eq!(end, line.chars().count() - 1);
|
||||
|
||||
// Same shape but with a half-width ASCII colon, and the URL mid-line
|
||||
// followed by more text after a space (`… 42 🎉收尾:…`): the token ends at
|
||||
// the space, so the trailing emoji/prose never leaks into the link.
|
||||
let row = format!("PR 已创建:{url} 🎉收尾:删除临时");
|
||||
let h = row.chars().position(|c| c == 'h').expect("scheme start");
|
||||
assert_eq!(url_at(&row, h).as_deref(), Some(url));
|
||||
assert_eq!(url_at(&row, 0), None); // on `P` of the `PR ` label
|
||||
assert_eq!(url_at(&row, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_ignores_hover_on_cjk_prefix_before_url() {
|
||||
// Hovering over the prose that precedes the URL must not underline / open
|
||||
// the link — only cells on the URL itself count.
|
||||
let line = "已创建:https://a.com";
|
||||
assert_eq!(url_at(line, 0), None); // on `已`
|
||||
assert_eq!(url_at(line, 3), None); // on the full-width colon
|
||||
assert_eq!(url_at(line, 4).as_deref(), Some("https://a.com")); // on `h`
|
||||
assert_eq!(url_at(line, 0), None);
|
||||
assert_eq!(url_at(line, 3), None);
|
||||
assert_eq!(url_at(line, 4).as_deref(), Some("https://a.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_strips_full_width_trailing_punctuation() {
|
||||
// A URL closed by a full-width bracket or stop in CJK prose keeps neither.
|
||||
assert_eq!(
|
||||
url_at("见(https://a.com)", 3).as_deref(),
|
||||
Some("https://a.com")
|
||||
@@ -1056,40 +870,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_at_stops_at_full_width_open_bracket_glued_after_url() {
|
||||
// Regression: a URL immediately followed by a full-width `(parenthetical)`
|
||||
// with no ASCII space — `…/pull/343(fix/… → dev)`. The `(` is not
|
||||
// whitespace, so the token runs past the URL into the bracket; truncating at
|
||||
// the first non-URL char keeps only the address.
|
||||
let url = "https://github.com/acme/app/pull/343";
|
||||
let line = format!("PR 已创建:{url}(fix/cache-write-tokens → dev)");
|
||||
let h = line.chars().position(|c| c == 'h').expect("scheme start");
|
||||
assert_eq!(url_at(&line, h).as_deref(), Some(url));
|
||||
// Hovering deeper inside the URL resolves the same span, sans bracket.
|
||||
let (start, end, got) = url_span_at(&line, h + 10).expect("URL before bracket");
|
||||
assert_eq!(got, url);
|
||||
assert_eq!(start, h);
|
||||
assert_eq!(line.chars().nth(end + 1), Some('('));
|
||||
// Hovering on the parenthetical text after the URL is not a link.
|
||||
let f = line.chars().position(|c| c == 'f').expect("`fix` start");
|
||||
assert_eq!(url_at(&line, f), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_keeps_ascii_parens_inside_a_url() {
|
||||
// ASCII `(`/`)` are valid URL characters (e.g. Wikipedia), so a pair in the
|
||||
// middle of the path must survive — the non-URL-char truncation only fires on
|
||||
// a full-width bracket, never an ASCII one.
|
||||
let url = "https://en.wikipedia.org/wiki/Rust_(programming_language)/history";
|
||||
assert_eq!(url_at(url, 40).as_deref(), Some(url));
|
||||
// A *trailing* balanced pair survives too: the closer has its opener inside
|
||||
// the URL, so it is part of the address, not prose glue.
|
||||
let url = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
|
||||
assert_eq!(url_at(url, 40).as_deref(), Some(url));
|
||||
// Even when that URL is itself parenthesized: the wrapper pair is stripped,
|
||||
// the URL's own pair is kept.
|
||||
let line = format!("see ({url}) ok");
|
||||
assert_eq!(url_at(&line, 8).as_deref(), Some(url));
|
||||
// IPv6 literals keep their brackets the same way.
|
||||
let url = "http://[::1]:8080/status";
|
||||
let line = format!("probe [{url}] done");
|
||||
assert_eq!(url_at(&line, 10).as_deref(), Some(url));
|
||||
@@ -1097,23 +897,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_at_stops_at_unbalanced_close_paren_glued_after_url() {
|
||||
// Regression: `#43 (https://…/pull/43)(Fixes #42),分支 …` — the token runs
|
||||
// `(url)(Fixes` with no space, every char is URL-legal, and the link used to
|
||||
// swallow `)(Fixes`. The first `)` has no opener inside the URL (the `(`
|
||||
// before the scheme was dropped with the prefix), so the link ends at `43`.
|
||||
let url = "https://github.com/l0ng-ai/tty7/pull/43";
|
||||
let line = format!("PR 已开:#43 ({url})(Fixes #42),分支 fix-x。");
|
||||
let h = line.chars().position(|c| c == 'h').expect("scheme start");
|
||||
assert_eq!(url_at(&line, h).as_deref(), Some(url));
|
||||
// The span covers exactly the URL: the wrapping `(` sits before it, the
|
||||
// `)(Fixes` glue after it, and hovering the glue is not a link.
|
||||
let (start, end, got) = url_span_at(&line, h + 10).expect("URL inside parens");
|
||||
assert_eq!(got, url);
|
||||
assert_eq!(line.chars().nth(start - 1), Some('('));
|
||||
assert_eq!(line.chars().nth(end + 1), Some(')'));
|
||||
let f = line.chars().position(|c| c == 'F').expect("`Fixes` start");
|
||||
assert_eq!(url_at(&line, f), None);
|
||||
// Same for an orphan `]`: `[see https://a.com/x] next` glued without spaces.
|
||||
assert_eq!(
|
||||
url_at("read https://a.com/x]next now", 8).as_deref(),
|
||||
Some("https://a.com/x")
|
||||
@@ -1122,11 +915,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn url_span_at_rejects_www_without_a_dot_and_empty_tokens() {
|
||||
// "www" alone (no extra dot after stripping) is not promoted.
|
||||
assert_eq!(url_at("www near text", 1), None);
|
||||
// A token that is entirely trailing punctuation shrinks to empty → None.
|
||||
assert_eq!(url_at("...", 1), None);
|
||||
// A plain word starting like a scheme but not one.
|
||||
assert_eq!(url_at("httpsomething", 3), None);
|
||||
}
|
||||
|
||||
@@ -1264,17 +1054,12 @@ mod tests {
|
||||
LinkTarget::Url(url) => panic!("expected directory link, got URL {url}"),
|
||||
}
|
||||
|
||||
// `ls -p` style trailing slash resolves too.
|
||||
assert!(link_at("ls dircase/nested/ done", 5, Some(cwd), true).is_some());
|
||||
// Off without the modifier, like files.
|
||||
assert!(link_at("artifacts in dircase/nested here", 14, Some(cwd), false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_at_requires_a_file_when_a_line_suffix_is_present() {
|
||||
// `localhost:8080` must not become a link just because a directory
|
||||
// named `localhost` exists in the cwd — `:line` only makes sense for
|
||||
// files.
|
||||
let file = temp_file("localhost/keep.txt");
|
||||
let cwd = file.parent().and_then(Path::parent).unwrap();
|
||||
|
||||
@@ -1282,7 +1067,6 @@ mod tests {
|
||||
link_at("listening on localhost:8080", 15, Some(cwd), true),
|
||||
None
|
||||
);
|
||||
// The bare directory still links.
|
||||
assert!(link_at("listening on localhost", 15, Some(cwd), true).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
//! Per-command completion signatures — tty7's take on rich command
|
||||
//! signatures (built on Fig's autocomplete specs).
|
||||
//!
|
||||
//! The data is generated offline from Fig's MIT-licensed spec corpus by
|
||||
//! `scripts/fig-convert/convert.mjs`, which executes each compiled spec and
|
||||
//! snapshots its *static* shape (subcommands, options, args, descriptions,
|
||||
//! static generator `script`s) into `assets/completions/<cmd>.json`. This module
|
||||
//! is only the runtime consumer: a serde model plus a per-command **lazy,
|
||||
//! memoized registry** — a command's JSON is parsed the first time it's typed
|
||||
//! and cached for the session.
|
||||
//!
|
||||
//! Specs are read from an on-disk `completions/` directory rather than embedded
|
||||
//! in the binary, so the corpus can grow (or a user can drop in their own specs)
|
||||
//! without a recompile and without bloating the executable. [`spec_source`]
|
||||
//! resolves that directory across the shapes tty7 runs in — a packaged bundle,
|
||||
//! an unpackaged binary, `cargo run`, and tests — plus an optional user override
|
||||
//! under the config dir; see its docs for the search order. The lookup only ever
|
||||
//! maps a bare command name to `<dir>/<cmd>.json`, so a typed token can't escape
|
||||
//! the completions dir.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A command's completion signature (the JSON root). Shares the `options` /
|
||||
/// `args` / `subcommands` shape with [`Subcommand`] via the [`CmdNode`] trait so
|
||||
/// the argv walk can treat the root and any nested subcommand uniformly.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Signature {
|
||||
#[allow(dead_code)]
|
||||
@@ -42,8 +19,6 @@ pub struct Signature {
|
||||
pub subcommands: Vec<Subcommand>,
|
||||
}
|
||||
|
||||
/// A subcommand node — the same fields as [`Signature`] but carrying its own
|
||||
/// aliases (`names`) and a `hidden` flag we keep out of the menu.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Subcommand {
|
||||
#[serde(default)]
|
||||
@@ -52,8 +27,6 @@ pub struct Subcommand {
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
/// A per-entry icon from the Fig spec: an emoji, a `fig://icon?type=…`
|
||||
/// template, or a `fig://template?…`. The menu renderer interprets it.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -64,8 +37,6 @@ pub struct Subcommand {
|
||||
pub subcommands: Vec<Subcommand>,
|
||||
}
|
||||
|
||||
/// A flag / option. `names` holds every spelling (`["-m", "--message"]`); a
|
||||
/// non-empty `args` means the option takes a value.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Opt {
|
||||
#[serde(default)]
|
||||
@@ -82,23 +53,16 @@ pub struct Opt {
|
||||
pub repeatable: bool,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
/// Per-option icon from the Fig spec (see [`Subcommand::icon`]).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// Whether this option consumes a following value token.
|
||||
pub fn takes_arg(&self) -> bool {
|
||||
!self.args.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A positional / value argument. `template` mirrors Fig's `"filepaths"` /
|
||||
/// `"folders"` (→ tty7's path completion); `suggestions` is a static candidate
|
||||
/// list; `generators` holds the *static* shell `script`s whose stdout becomes
|
||||
/// candidates — the completion engine collects them and the view runs them
|
||||
/// asynchronously (see [`super::generator`]).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Arg {
|
||||
#[allow(dead_code)]
|
||||
@@ -119,62 +83,45 @@ pub struct Arg {
|
||||
}
|
||||
|
||||
impl Arg {
|
||||
/// Whether this arg wants filesystem completion (Fig `filepaths`/`folders`).
|
||||
pub fn wants_paths(&self) -> bool {
|
||||
self.template
|
||||
.iter()
|
||||
.any(|t| t == "filepaths" || t == "folders")
|
||||
}
|
||||
|
||||
/// Whether this arg's filesystem completion is directories only — a Fig
|
||||
/// `folders` template with no `filepaths` alongside it.
|
||||
pub fn wants_dirs_only(&self) -> bool {
|
||||
self.template.iter().any(|t| t == "folders")
|
||||
&& !self.template.iter().any(|t| t == "filepaths")
|
||||
}
|
||||
}
|
||||
|
||||
/// A static value suggestion for an argument.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Suggestion {
|
||||
#[serde(default)]
|
||||
pub names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Per-suggestion icon from the Fig spec (see [`Subcommand::icon`]).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// A dynamic-value generator, reduced to its static shell `script` (the JS
|
||||
/// `postProcess` is dropped at conversion time; tty7 defaults to
|
||||
/// one-suggestion-per-line, overridable per script — see [`super::generator`]).
|
||||
/// The tokens are joined with single spaces and re-parsed by `/bin/sh -c`, since
|
||||
/// the converter word-split original string scripts (so `bash -c "…"` entries
|
||||
/// only survive re-joining).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Generator {
|
||||
#[serde(default)]
|
||||
pub script: Vec<String>,
|
||||
}
|
||||
|
||||
/// Uniform read access to a command node's children, so the argv walk in
|
||||
/// `completion` can start at the [`Signature`] root and descend into
|
||||
/// [`Subcommand`]s without special-casing.
|
||||
pub trait CmdNode {
|
||||
fn subcommands(&self) -> &[Subcommand];
|
||||
fn options(&self) -> &[Opt];
|
||||
fn args(&self) -> &[Arg];
|
||||
|
||||
/// The subcommand whose name/alias equals `token`, if any.
|
||||
fn find_subcommand(&self, token: &str) -> Option<&Subcommand> {
|
||||
self.subcommands()
|
||||
.iter()
|
||||
.find(|s| s.names.iter().any(|n| n == token))
|
||||
}
|
||||
|
||||
/// The option matching a flag token (`--message`, `-m`); the token is
|
||||
/// compared after stripping any `=value` suffix.
|
||||
fn find_option(&self, token: &str) -> Option<&Opt> {
|
||||
let flag = token.split('=').next().unwrap_or(token);
|
||||
self.options()
|
||||
@@ -207,17 +154,6 @@ impl CmdNode for Subcommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// The directories searched for `<cmd>.json`, most-specific first, resolved once.
|
||||
///
|
||||
/// Order (first hit wins, so earlier entries override later ones):
|
||||
/// 1. `$TTY7_COMPLETIONS_DIR` — explicit override for dev / testing.
|
||||
/// 2. `<config-dir>/completions` — user-supplied specs (mirrors how the rest of
|
||||
/// tty7 lets `~/.config/tty7` override built-ins).
|
||||
/// 3. bundle/executable-relative — where each packaging script installs the
|
||||
/// specs: `../Resources/completions` inside a macOS `.app`, or a
|
||||
/// `completions/` dir beside the executable on Linux/Windows.
|
||||
/// 4. the in-tree `assets/completions` — the `cargo run` / test fallback,
|
||||
/// baked in via `CARGO_MANIFEST_DIR` so an unpackaged run still finds specs.
|
||||
fn spec_source() -> &'static [PathBuf] {
|
||||
static DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
||||
DIRS.get_or_init(|| {
|
||||
@@ -230,8 +166,8 @@ fn spec_source() -> &'static [PathBuf] {
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
dirs.push(dir.join("../Resources/completions")); // macOS .app
|
||||
dirs.push(dir.join("completions")); // Linux / Windows sibling
|
||||
dirs.push(dir.join("../Resources/completions"));
|
||||
dirs.push(dir.join("completions"));
|
||||
}
|
||||
}
|
||||
dirs.push(PathBuf::from(concat!(
|
||||
@@ -242,11 +178,6 @@ fn spec_source() -> &'static [PathBuf] {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the raw JSON for `cmd` from the first [`spec_source`] dir that has it.
|
||||
///
|
||||
/// `cmd` maps to the bare filename `<cmd>.json`; anything that isn't a plain
|
||||
/// command token (letters, digits, and `._+-`) is rejected up front so a typed
|
||||
/// token can never contain a path separator or `..` and read outside the dir.
|
||||
fn raw_spec(cmd: &str) -> Option<String> {
|
||||
if cmd.is_empty()
|
||||
|| !cmd
|
||||
@@ -261,8 +192,6 @@ fn raw_spec(cmd: &str) -> Option<String> {
|
||||
.find_map(|dir| std::fs::read_to_string(dir.join(&file)).ok())
|
||||
}
|
||||
|
||||
/// The parse cache: `None` marks a command we've looked up and have no (or
|
||||
/// unparseable) signature for, so a miss is memoized too.
|
||||
type Registry = Mutex<HashMap<String, Option<Arc<Signature>>>>;
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
@@ -270,17 +199,10 @@ fn registry() -> &'static Registry {
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The signature for `cmd`, parsed lazily on first use and memoized (hit or
|
||||
/// miss). Returns `None` for commands outside the embedded corpus or whose JSON
|
||||
/// fails to parse — callers fall back to generic completion.
|
||||
pub fn signature(cmd: &str) -> Option<Arc<Signature>> {
|
||||
// Fast path: return the memoized result (hit or miss) without touching disk.
|
||||
if let Some(cached) = registry().lock().unwrap().get(cmd) {
|
||||
return cached.clone();
|
||||
}
|
||||
// Read + parse off-lock so filesystem IO never blocks another lookup. A
|
||||
// concurrent miss may load the same spec twice; that's idempotent, and the
|
||||
// insert below just re-publishes the same value.
|
||||
let parsed = raw_spec(cmd).and_then(|raw| match serde_json::from_str::<Signature>(&raw) {
|
||||
Ok(sig) => Some(Arc::new(sig)),
|
||||
Err(e) => {
|
||||
@@ -304,7 +226,6 @@ mod tests {
|
||||
let sig = signature("git").expect("git spec on disk");
|
||||
assert_eq!(sig.name, "git");
|
||||
assert!(sig.subcommands.len() > 20, "git has many subcommands");
|
||||
// Second call returns the same cached Arc.
|
||||
let again = signature("git").unwrap();
|
||||
assert!(Arc::ptr_eq(&sig, &again));
|
||||
}
|
||||
@@ -312,7 +233,6 @@ mod tests {
|
||||
#[test]
|
||||
fn docker_loadspec_grafted_compose() {
|
||||
let sig = signature("docker").expect("docker spec on disk");
|
||||
// `docker compose` was grafted from the docker-compose spec via loadSpec.
|
||||
let compose = sig
|
||||
.find_subcommand("compose")
|
||||
.expect("compose subcommand present");
|
||||
@@ -336,9 +256,6 @@ mod tests {
|
||||
assert!(signature("definitely-not-a-real-cmd-xyz").is_none());
|
||||
}
|
||||
|
||||
/// Every spec that ships in-tree must parse into the serde model — a
|
||||
/// malformed one should fail here (at CI time) rather than silently
|
||||
/// degrading to generic completion on a user's machine.
|
||||
#[test]
|
||||
fn every_shipped_spec_parses() {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/completions");
|
||||
@@ -359,8 +276,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A typed token that isn't a bare command name must never read a file —
|
||||
/// path separators and `..` are rejected before touching the filesystem.
|
||||
#[test]
|
||||
fn raw_spec_rejects_path_traversal() {
|
||||
assert!(raw_spec("git").is_some());
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
//! `TermSize`: the fixed grid dimensions handed to the VT emulator and the PTY.
|
||||
//!
|
||||
//! This used to live alongside an in-process PTY-backed `Terminal` here, but the
|
||||
//! PTY now lives in the daemon (`daemon::pane`) and the GUI talks to it through
|
||||
//! `terminal::remote::RemoteTerminal`. All that survives on the client side is
|
||||
//! this size type, shared by the remote terminal and the view.
|
||||
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
|
||||
/// Fixed dimensions handed to `Term` / `Term::resize`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TermSize {
|
||||
pub cols: usize,
|
||||
|
||||
+12
-310
@@ -1,14 +1,3 @@
|
||||
//! Double-click smart selection (à la iTerm2): when a double-click's
|
||||
//! plain word selection sits inside a larger semantic object — a URL, an
|
||||
//! email address, a file path, a matching bracket pair, or an OSC 8
|
||||
//! hyperlink — expand the selection to cover the whole object.
|
||||
//!
|
||||
//! The expansion is strictly additive: a candidate is only applied when it
|
||||
//! *contains* the plain word the double-click would have selected, so the
|
||||
//! feature can never shrink a selection below what alacritty's semantic
|
||||
//! (word) selection yields. With no candidate the caller falls back to the
|
||||
//! stock `SelectionType::Semantic` behavior unchanged.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use alacritty_terminal::event::EventListener;
|
||||
@@ -18,18 +7,10 @@ use alacritty_terminal::term::Term;
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use regex::Regex;
|
||||
|
||||
/// How many soft-wrapped rows to join on each side of the clicked row when
|
||||
/// reconstructing the logical line. Caps the text a pathological fully-wrapped
|
||||
/// scrollback line (minified JS piped to `cat`) can feed the regexes.
|
||||
const MAX_WRAP_ROWS: usize = 32;
|
||||
|
||||
/// How many chars around the click offset the regex window covers on each
|
||||
/// side. Matches never straddle real whitespace anyway, so a bounded window
|
||||
/// only drops matches on absurdly long unbroken runs.
|
||||
const MATCH_WINDOW: usize = 2000;
|
||||
|
||||
/// Bracket pairs a double-click on either half expands across (with
|
||||
/// nesting): the ASCII pairs plus the full-width/CJK ones.
|
||||
const BRACKET_PAIRS: [(char, char); 15] = [
|
||||
('(', ')'),
|
||||
('[', ']'),
|
||||
@@ -48,41 +29,22 @@ const BRACKET_PAIRS: [(char, char); 15] = [
|
||||
('‘', '’'),
|
||||
];
|
||||
|
||||
/// Symmetric quotes: open and close are the same char, so pairing needs the
|
||||
/// parity heuristic in [`quote_range`] instead of the bracket scan.
|
||||
const SYMMETRIC_QUOTES: [char; 3] = ['\'', '"', '`'];
|
||||
|
||||
/// A resolved smart selection: an inclusive grid-point span, plus whether the
|
||||
/// span is `exact`. Exact spans have endpoints that may sit mid-word-run (CJK
|
||||
/// prose, a candidate glued to non-separator text), so the caller must select
|
||||
/// them with `SelectionType::Simple` — a `Semantic` anchor would re-expand the
|
||||
/// endpoints across the very boundary the smart range established. Non-exact
|
||||
/// spans end on run boundaries and can keep `Semantic` for word-wise dragging.
|
||||
pub(super) struct SmartRange {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
pub exact: bool,
|
||||
}
|
||||
|
||||
/// Resolve a smart selection range for a double-click at `click` (grid
|
||||
/// coordinates). `None` means "no candidate beats the plain word" and the
|
||||
/// caller should keep the stock semantic selection.
|
||||
pub(super) fn grid_smart_range<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
) -> Option<SmartRange> {
|
||||
// 0) The click carries the geometry of the frame that dispatched it, and
|
||||
// the grid can shrink out from under it (a split, a window drag, a
|
||||
// replayed attach size landing on the reader thread). Both walks below
|
||||
// index `grid[click.line]` straight away, and `Grid`'s `Index<Line>`
|
||||
// only `debug_assert`s the bound — a release build walks off the
|
||||
// storage. Same guard, same reason, as `TerminalView::grid_line`.
|
||||
if click.line < term.topmost_line() || click.line > term.bottommost_line() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1) An explicit OSC 8 hyperlink run wins outright — the program told us
|
||||
// the exact extent, no guessing needed.
|
||||
if let Some((start, end)) = hyperlink_run(term, click) {
|
||||
return Some(SmartRange {
|
||||
start,
|
||||
@@ -94,10 +56,6 @@ pub(super) fn grid_smart_range<T: EventListener>(
|
||||
let (text, points, click_idx) = logical_line_at(term, click, false)?;
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let separators = term.semantic_escape_chars();
|
||||
// A span whose flanks are separator chars ends exactly where alacritty's
|
||||
// semantic re-expansion would stop anyway; anything else must stay exact.
|
||||
// Only the separator set counts here — alacritty stops at nothing else,
|
||||
// so a flank of e.g. U+3000 ideographic space would still re-expand.
|
||||
let resolved = |s: usize, e: usize| SmartRange {
|
||||
start: points[s],
|
||||
end: points[e],
|
||||
@@ -105,66 +63,48 @@ pub(super) fn grid_smart_range<T: EventListener>(
|
||||
|| !(e + 1 == chars.len() || separators.contains(chars[e + 1])),
|
||||
};
|
||||
|
||||
// 2) Double-click on a bracket or quote selects through its match.
|
||||
if let Some((s, e)) = pair_range(&chars, click_idx) {
|
||||
return Some(resolved(s, e));
|
||||
}
|
||||
|
||||
// 3) CJK prose has no separators to walk — the whole clause is one run —
|
||||
// so segment it with a dictionary instead of selecting the entire
|
||||
// unbroken run. No segmenter available means the run stands as-is.
|
||||
if is_cjk(chars[click_idx])
|
||||
&& let Some((s, e)) = cjk_word_range(&text, click_idx)
|
||||
{
|
||||
return Some(resolved(s, e));
|
||||
}
|
||||
|
||||
// 4) URL / email / path / identifier patterns around the click.
|
||||
let (s, e) = smart_range(&text, &chars, click_idx, separators)?;
|
||||
Some(resolved(s, e))
|
||||
}
|
||||
|
||||
/// Whether a char belongs to a CJK script (Han, Kana, Hangul, or the
|
||||
/// full-width/CJK punctuation blocks) — text whose words aren't delimited by
|
||||
/// whitespace or the separator set.
|
||||
pub(super) fn is_cjk(c: char) -> bool {
|
||||
matches!(
|
||||
u32::from(c),
|
||||
0x1100..=0x11FF // Hangul Jamo
|
||||
| 0x2E80..=0x9FFF // CJK radicals, punctuation, Kana, ideographs
|
||||
| 0xAC00..=0xD7AF // Hangul syllables
|
||||
| 0xF900..=0xFAFF // CJK compatibility ideographs
|
||||
| 0xFF00..=0xFFEF // full-width forms
|
||||
| 0x20000..=0x3134F // ideograph extensions
|
||||
0x1100..=0x11FF
|
||||
| 0x2E80..=0x9FFF
|
||||
| 0xAC00..=0xD7AF
|
||||
| 0xF900..=0xFAFF
|
||||
| 0xFF00..=0xFFEF
|
||||
| 0x20000..=0x3134F
|
||||
)
|
||||
}
|
||||
|
||||
/// Kana or Hangul — the scripts jieba has no dictionary for. A run holding
|
||||
/// either is left unsegmented rather than handed to jieba, which shreds it
|
||||
/// into single characters (`です` → `で` `す`); selecting the whole run is the
|
||||
/// friendlier failure.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn is_kana_or_hangul(c: char) -> bool {
|
||||
matches!(
|
||||
u32::from(c),
|
||||
0x1100..=0x11FF // Hangul Jamo
|
||||
| 0x3040..=0x30FF // Hiragana + Katakana
|
||||
| 0x31F0..=0x31FF // Katakana phonetic extensions
|
||||
| 0xA960..=0xA97F // Hangul Jamo Extended-A
|
||||
| 0xAC00..=0xD7FF // Hangul syllables + Jamo Extended-B
|
||||
| 0xFF66..=0xFF9F // half-width Katakana
|
||||
0x1100..=0x11FF
|
||||
| 0x3040..=0x30FF
|
||||
| 0x31F0..=0x31FF
|
||||
| 0xA960..=0xA97F
|
||||
| 0xAC00..=0xD7FF
|
||||
| 0xFF66..=0xFF9F
|
||||
)
|
||||
}
|
||||
|
||||
/// The jieba segmenter, built once on a background thread. The table costs
|
||||
/// ~55 MB resident and ~130 ms to build, so it is constructed only if a CJK
|
||||
/// double-click actually happens — see [`jieba_word_range`].
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
static JIEBA: OnceLock<jieba_rs::Jieba> = OnceLock::new();
|
||||
|
||||
/// Kick off dictionary construction on a background thread (idempotent).
|
||||
/// Never called eagerly: the first CJK double-click triggers it and settles
|
||||
/// for the unsegmented run, so the UI thread never blocks on the build.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn warm() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
@@ -175,13 +115,6 @@ fn warm() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Dictionary-based word bounds for CJK text: the inclusive char range of the
|
||||
/// word containing char index `click`, or `None` to keep the whole run.
|
||||
///
|
||||
/// The OS tokenizer wins wherever there is one. macOS's CFStringTokenizer
|
||||
/// carries a Chinese lexicon that matches jieba on most prose, is locale-
|
||||
/// independent, handles Japanese and Korean properly, and costs nothing —
|
||||
/// jieba is only worth its ~55 MB on platforms with no such API.
|
||||
pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -195,9 +128,6 @@ pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)>
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment the contiguous CJK run around `click` with jieba and return the
|
||||
/// token containing it. `None` — meaning "select the whole run" — when the
|
||||
/// dictionary isn't built yet or the run isn't Chinese.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
let mut rs = click;
|
||||
@@ -208,20 +138,14 @@ fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
while re + 1 < chars.len() && is_cjk(chars[re + 1]) {
|
||||
re += 1;
|
||||
}
|
||||
// Japanese/Korean: jieba's Chinese dictionary would cut the run into
|
||||
// single characters, which is worse than not segmenting at all.
|
||||
if chars[rs..=re].iter().copied().any(is_kana_or_hangul) {
|
||||
return None;
|
||||
}
|
||||
// Building the table takes ~130 ms — far too long to hold the UI thread
|
||||
// on a click. Start it in the background and let this one click select
|
||||
// the whole run; every later click finds the table ready.
|
||||
let Some(jieba) = JIEBA.get() else {
|
||||
warm();
|
||||
return None;
|
||||
};
|
||||
let run: String = chars[rs..=re].iter().collect();
|
||||
// Token start/end are Unicode char offsets into `run`.
|
||||
let rel = click - rs;
|
||||
jieba
|
||||
.cut(&run, true)
|
||||
@@ -230,8 +154,6 @@ fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
.map(|tok| (rs + tok.start, rs + tok.end - 1))
|
||||
}
|
||||
|
||||
/// CFStringTokenizer FFI. The tokenizer functions aren't wrapped by the
|
||||
/// `core-foundation` crate, so declare them directly against its types.
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tokenizer {
|
||||
use core_foundation::base::{CFIndex, CFRange, TCFType};
|
||||
@@ -241,9 +163,6 @@ mod tokenizer {
|
||||
type CFStringTokenizerRef = *mut c_void;
|
||||
type CFLocaleRef = *const c_void;
|
||||
|
||||
/// `kCFStringTokenizerUnitWordBoundary`: every position belongs to a
|
||||
/// token (words, punctuation runs, whitespace runs alike), which is the
|
||||
/// double-click contract.
|
||||
const UNIT_WORD_BOUNDARY: u64 = 4;
|
||||
|
||||
unsafe extern "C" {
|
||||
@@ -263,9 +182,6 @@ mod tokenizer {
|
||||
fn CFRelease(cf: *const c_void);
|
||||
}
|
||||
|
||||
/// Inclusive char range of the token containing char index `click`.
|
||||
/// CFString ranges are UTF-16 code-unit offsets, so map through a
|
||||
/// per-char offset table both ways.
|
||||
pub(super) fn word_range(text: &str, click: usize) -> Option<(usize, usize)> {
|
||||
let mut u16_of: Vec<CFIndex> = Vec::new();
|
||||
let mut total: CFIndex = 0;
|
||||
@@ -302,9 +218,6 @@ mod tokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the
|
||||
/// clicked cell, following soft wraps in both directions (a long link wraps
|
||||
/// across rows; stopping at the row edge would truncate the selection).
|
||||
pub(super) fn hyperlink_run<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
@@ -362,17 +275,6 @@ pub(super) fn hyperlink_run<T: EventListener>(
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Reconstruct the logical (soft-wrap-joined) line containing `click`:
|
||||
/// the text with wide-char spacers dropped, a per-char grid point, and the
|
||||
/// char index the click landed on. `None` when the click maps to no char
|
||||
/// (out-of-bounds column).
|
||||
///
|
||||
/// When `bridge_hard_wrap` is set, rows are also joined across a *producer*
|
||||
/// hard newline (no `WRAPLINE` flag) when the row is filled to the right edge
|
||||
/// with a link char that continues into the first column of the next row. This
|
||||
/// lets link resolution recover a URL a printing program split with a literal
|
||||
/// `\n`, while double-click smart-select (which passes `false`) keeps its
|
||||
/// word/semantic boundaries and never glues separate output lines together.
|
||||
pub(super) fn logical_line_at<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
@@ -387,27 +289,8 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
let top = term.topmost_line();
|
||||
let bottom = term.bottommost_line();
|
||||
let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE);
|
||||
// A hard bridge joins `line` to `line + 1` when the row is full to the
|
||||
// right edge with a link char and the next row opens with one too, which
|
||||
// rules out gluing an ordinary short line onto the following paragraph.
|
||||
//
|
||||
// It cannot rule out the converse: a hard newline carries no signal about
|
||||
// whether the producer split a URL, so a *complete* URL that happens to end
|
||||
// exactly at the right edge is bridged onto whatever the next row starts
|
||||
// with (`…/a` + `README.md` resolves as `…/aREADME.md`). There is no
|
||||
// reliable test for that — the head of a genuinely split URL is itself a
|
||||
// valid URL — so we accept the false positive: the address bar shows the
|
||||
// mistake and the user is one glance from spotting it.
|
||||
//
|
||||
// What we do not accept is the same accident promoting the *second* row to
|
||||
// the authority. `https://good.com` + `@evil.com/x` parses as userinfo, so
|
||||
// the real host becomes `evil.com` while the underline still reads
|
||||
// `good.com` — a phishing hop wearing a trusted label. Never bridge into
|
||||
// one.
|
||||
let is_link_char = |c: char| super::search::is_url_char(c);
|
||||
let hard = |line: Line| {
|
||||
// `line < bottom` must stay ahead of the `line + 1` lookup — the last
|
||||
// grid line has no successor to index.
|
||||
bridge_hard_wrap && line < bottom && is_link_char(grid[line][last_col].c) && {
|
||||
let next = grid[Line(line.0 + 1)][Column(0)].c;
|
||||
is_link_char(next) && next != '@'
|
||||
@@ -436,10 +319,6 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
for col in 0..cols {
|
||||
let cell = &grid[line][Column(col)];
|
||||
let p = Point::new(line, Column(col));
|
||||
// Spacer cells pad wide (CJK/emoji) glyphs. A trailing spacer
|
||||
// follows its wide char; a leading spacer pads the end of a row
|
||||
// whose wide char wrapped to the next row, so it belongs to the
|
||||
// *next* pushed char.
|
||||
if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
|
||||
if p == click {
|
||||
click_idx = Some(points.len());
|
||||
@@ -460,24 +339,14 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
}
|
||||
line += 1;
|
||||
}
|
||||
// A leading spacer at the very end of the collected range can point one
|
||||
// past the last char; treat that as no hit.
|
||||
let click_idx = click_idx.filter(|&i| i < points.len())?;
|
||||
Some((text, points, click_idx))
|
||||
}
|
||||
|
||||
/// Double-click on a paired delimiter — bracket or quote — selects through
|
||||
/// its match. `None` when the clicked char is neither, or has no match on
|
||||
/// the logical line.
|
||||
pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
bracket_range(chars, click).or_else(|| quote_range(chars, click))
|
||||
}
|
||||
|
||||
/// Whether the `'` at `i` is a contraction apostrophe rather than a quote.
|
||||
///
|
||||
/// A delimiter has whitespace, punctuation, or a line edge on at least one
|
||||
/// side; a contraction is welded into a word on both (`it's`, `isn't`,
|
||||
/// `won't`). Only `'` needs this — `"` and `` ` `` don't appear inside words.
|
||||
fn is_contraction(chars: &[char], i: usize) -> bool {
|
||||
if chars[i] != '\'' {
|
||||
return false;
|
||||
@@ -489,17 +358,6 @@ fn is_contraction(chars: &[char], i: usize) -> bool {
|
||||
flanked(i.checked_sub(1)) && flanked(Some(i + 1))
|
||||
}
|
||||
|
||||
/// Select through a matching symmetric quote (`'`, `"`, `` ` ``). Open and
|
||||
/// close are the same char, so direction comes from parity: an even count of
|
||||
/// that quote before the click means it opens (match forward), odd means it
|
||||
/// closes (match backward).
|
||||
///
|
||||
/// Contraction apostrophes are excluded throughout — clicking one falls
|
||||
/// through to the stock word, and they count neither toward the parity nor as
|
||||
/// a candidate match. Without that, `it's a test, isn't it` pairs the two
|
||||
/// contractions and a double-click on either selects `'s a test, isn'`. This
|
||||
/// path returns before the `extends` guard that keeps other candidates
|
||||
/// additive (see [`pair_is_plausible`]), so a bad match here has no safety net.
|
||||
pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
let q = *chars.get(click)?;
|
||||
if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) {
|
||||
@@ -516,18 +374,6 @@ pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a candidate span is an acceptable match for its bracket pair.
|
||||
///
|
||||
/// Every pair but `<>` is accepted outright — `( a )` is a legitimate subshell,
|
||||
/// `[ 1 ]` a legitimate index. `<` and `>` are different: they are comparison
|
||||
/// and redirection operators at least as often as delimiters, and the bracket
|
||||
/// path returns before the `extends` guard that keeps every other candidate
|
||||
/// additive, so a bad match here has no safety net. Require the span to hug its
|
||||
/// contents, which real delimiters do (`Vec<String>`, `<div>`, `<user@host>`,
|
||||
/// `<info>`) and a comparison doesn't (`a < b > c`, `x <= 0 || y > 9`).
|
||||
///
|
||||
/// Redirections need no special handling: `2>&1` or `cmd > out` have no
|
||||
/// partner to match, so the scan already fails.
|
||||
fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool {
|
||||
if open != '<' {
|
||||
return true;
|
||||
@@ -535,10 +381,6 @@ fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool {
|
||||
e > s + 1 && !chars[s + 1].is_whitespace() && !chars[e - 1].is_whitespace()
|
||||
}
|
||||
|
||||
/// Select through a matching bracket: `click` on an opener scans forward,
|
||||
/// on a closer scans backward, nesting-aware. Inclusive char range covering
|
||||
/// both brackets, or `None` when the clicked char isn't a bracket, the match
|
||||
/// isn't on the logical line, or the span fails [`pair_is_plausible`].
|
||||
pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
let c = *chars.get(click)?;
|
||||
if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(o, _)| *o == c) {
|
||||
@@ -572,25 +414,13 @@ pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usiz
|
||||
None
|
||||
}
|
||||
|
||||
/// Patterns tried in specificity order after the URL detector: email,
|
||||
/// scientific-notation number, file path, dotted/hyphenated identifier.
|
||||
/// (URLs go through `search::url_span_at` first — it handles scheme
|
||||
/// detection, wrapper stripping and trailing-punctuation trimming better
|
||||
/// than a lone regex.)
|
||||
fn regexes() -> &'static [Regex] {
|
||||
static RE: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
[
|
||||
// Email address.
|
||||
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
|
||||
// Scientific notation (6.02e+23).
|
||||
r"\b[0-9]+(?:\.[0-9]+)?[eE][+-]?[0-9]+\b",
|
||||
// File path: at least two segments, or ~/.-anchored.
|
||||
r"[A-Za-z0-9._+@%~-]*(?:/[A-Za-z0-9._+@%~-]+)+/?",
|
||||
// Identifier chained with `.`/`-` (foo-bar.baz, 10.0.0.1).
|
||||
// ASCII classes only: the regex crate's `\w` matches Han
|
||||
// ideographs, which would swallow CJK text glued to a Latin
|
||||
// word and defeat the script narrowing.
|
||||
r"[0-9A-Za-z_]+(?:[.-][0-9A-Za-z_]+)*",
|
||||
]
|
||||
.iter()
|
||||
@@ -599,9 +429,6 @@ fn regexes() -> &'static [Regex] {
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a semantic object containing char index `click` in `text` that
|
||||
/// strictly extends the plain word selection the configured `separators`
|
||||
/// would produce. Inclusive char range, or `None` to keep the stock word.
|
||||
pub(super) fn smart_range(
|
||||
text: &str,
|
||||
chars: &[char],
|
||||
@@ -612,9 +439,6 @@ pub(super) fn smart_range(
|
||||
return None;
|
||||
}
|
||||
|
||||
// The plain word the double-click would select: the run of chars around
|
||||
// the click that are neither whitespace nor configured separators.
|
||||
// (Mirrors alacritty's semantic expansion over the same separator set.)
|
||||
let boundary = |c: char| c.is_whitespace() || separators.contains(c);
|
||||
let (mut pws, mut pwe) = (click, click);
|
||||
if !boundary(chars[click]) {
|
||||
@@ -625,13 +449,7 @@ pub(super) fn smart_range(
|
||||
pwe += 1;
|
||||
}
|
||||
}
|
||||
// CJK chars/punctuation glue onto Latin runs (`分支name,已` is one
|
||||
// separator-free run), so the word the user *means* is the same-script
|
||||
// sub-run around the click. Candidates are judged against that; if
|
||||
// nothing beats it, the narrowed run itself is the answer.
|
||||
let (ws, we) = narrow_to_script(chars, click, pws, pwe);
|
||||
// Applied only when the candidate strictly contains the (narrowed) word,
|
||||
// so smart select can grow the meant word but never shrink it.
|
||||
let extends = |s: usize, e: usize| s <= ws && e >= we && (s < ws || e > we);
|
||||
|
||||
if let Some((s, e, _url)) = super::search::url_span_at(text, click)
|
||||
@@ -640,7 +458,6 @@ pub(super) fn smart_range(
|
||||
return Some((s, e));
|
||||
}
|
||||
|
||||
// Regexes run over a bounded byte window around the click.
|
||||
let byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
|
||||
let w_start = click.saturating_sub(MATCH_WINDOW);
|
||||
let w_end = (click + MATCH_WINDOW).min(chars.len() - 1);
|
||||
@@ -662,15 +479,9 @@ pub(super) fn smart_range(
|
||||
return Some((s, e));
|
||||
}
|
||||
}
|
||||
// No pattern beat the meant word — but if script narrowing shrank the
|
||||
// raw run (Latin word glued to CJK text), that narrowed word *is* the
|
||||
// correction.
|
||||
((ws, we) != (pws, pwe)).then_some((ws, we))
|
||||
}
|
||||
|
||||
/// Shrink the inclusive run `[lo, hi]` to the chars sharing `click`'s script
|
||||
/// class (CJK vs not) — the sub-run a double-click on mixed-script text
|
||||
/// means. A no-op on single-script runs.
|
||||
pub(super) fn narrow_to_script(
|
||||
chars: &[char],
|
||||
click: usize,
|
||||
@@ -694,12 +505,8 @@ mod tests {
|
||||
use super::*;
|
||||
use alacritty_terminal::event::VoidListener;
|
||||
|
||||
/// alacritty's stock separator set, which is also the config default.
|
||||
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
|
||||
|
||||
/// In production the jieba table builds lazily off-thread and the racing
|
||||
/// click settles for the whole run; tests want it ready up front. No-op on
|
||||
/// macOS, where CFStringTokenizer needs no warm-up.
|
||||
fn ensure_segmenter() {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = JIEBA.get_or_init(jieba_rs::Jieba::new);
|
||||
@@ -710,16 +517,6 @@ mod tests {
|
||||
smart_range(text, &chars, click, SEPS)
|
||||
}
|
||||
|
||||
// ---- Grid-level tests ----
|
||||
//
|
||||
// The functions above operate on a plain `&str`; everything below drives a
|
||||
// real `Term` through the VT parser instead, because the grid is where the
|
||||
// index arithmetic actually gets hard: wide CJK glyphs occupy two cells
|
||||
// (the second a spacer), soft-wrapped rows have to be stitched back into
|
||||
// one logical line, and OSC 8 runs can straddle both.
|
||||
|
||||
/// A `cols`×`rows` terminal with `input` fed through the VT parser, so the
|
||||
/// grid holds exactly what a PTY would have produced.
|
||||
fn term_with(cols: usize, rows: usize, input: &str) -> Term<VoidListener> {
|
||||
let config = alacritty_terminal::term::Config {
|
||||
semantic_escape_chars: SEPS.to_string(),
|
||||
@@ -736,23 +533,17 @@ mod tests {
|
||||
term
|
||||
}
|
||||
|
||||
/// The text a double-click at `(line, col)` would select, or `None` when
|
||||
/// no smart candidate applies and the caller keeps the stock word.
|
||||
fn grid_select(term: &Term<VoidListener>, line: i32, col: usize) -> Option<String> {
|
||||
let r = grid_smart_range(term, Point::new(Line(line), Column(col)))?;
|
||||
Some(term.bounds_to_string(r.start, r.end))
|
||||
}
|
||||
|
||||
/// Column of the first occurrence of `needle` on row 0 — keeps the tests
|
||||
/// from hard-coding offsets that shift when the fixture text changes.
|
||||
fn col_of(row: &str, needle: &str) -> usize {
|
||||
row.find(needle).expect("needle in fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_hyperlink_selects_the_declared_extent_not_the_visible_word() {
|
||||
// The link text has a space in it: only the OSC 8 run knows where the
|
||||
// link really ends, which is the whole point of checking it first.
|
||||
let term = term_with(
|
||||
40,
|
||||
3,
|
||||
@@ -763,7 +554,6 @@ mod tests {
|
||||
grid_select(&term, 0, col_of(line, "here")).as_deref(),
|
||||
Some("click here"),
|
||||
);
|
||||
// A cell outside the run must not pick the link up.
|
||||
assert_ne!(
|
||||
grid_select(&term, 0, col_of(line, "now")).as_deref(),
|
||||
Some("click here"),
|
||||
@@ -772,36 +562,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn osc8_hyperlink_follows_a_soft_wrap() {
|
||||
// 30 chars of link text in 20 columns: the run fills row 0 and spills
|
||||
// 10 cells onto row 1. Stopping at the row edge would truncate the
|
||||
// selection to the visible first half.
|
||||
let term = term_with(
|
||||
20,
|
||||
4,
|
||||
"\x1b]8;;https://e.com\x1b\\aaaaaaaaaabbbbbbbbbbcccccccccc\x1b]8;;\x1b\\",
|
||||
);
|
||||
let whole = "aaaaaaaaaabbbbbbbbbbcccccccccc";
|
||||
// Click on the wrapped remainder (row 1) — walks backwards over the wrap.
|
||||
assert_eq!(grid_select(&term, 1, 2).as_deref(), Some(whole));
|
||||
// ...and from the first row, walking forwards over it.
|
||||
assert_eq!(grid_select(&term, 0, 3).as_deref(), Some(whole));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_wrapped_url_is_stitched_back_into_one_selection() {
|
||||
// No OSC 8 here — the URL is recovered from the joined logical line,
|
||||
// so this exercises `logical_line_at`'s wrap walk rather than the
|
||||
// hyperlink path.
|
||||
let term = term_with(20, 4, "see https://example.com/deep/path here");
|
||||
let whole = "https://example.com/deep/path";
|
||||
// Row 0 holds "see https://example.", row 1 the "com/deep/path here"
|
||||
// remainder. Clicking the head joins forwards over the wrap...
|
||||
assert_eq!(
|
||||
grid_select(&term, 0, col_of("see https://example", "example")).as_deref(),
|
||||
Some(whole),
|
||||
);
|
||||
// ...and clicking the tail joins backwards, which is the direction a
|
||||
// click on a continuation row depends on entirely.
|
||||
assert_eq!(
|
||||
grid_select(&term, 1, col_of("com/deep/path here", "deep")).as_deref(),
|
||||
Some(whole),
|
||||
@@ -810,13 +588,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hard_wrapped_url_is_bridged_only_for_links() {
|
||||
// A printing program emitted a literal `\n` mid-URL: the head fills
|
||||
// row 0 exactly (20 chars, no WRAPLINE flag) and the tail lands on
|
||||
// row 1. Soft-wrap stitching can't see across this gap; the hard
|
||||
// bridge in link mode joins them, while smart-select stays put.
|
||||
let term = term_with(20, 4, "https://example.com/\r\ndeep/path/seg rest");
|
||||
// The break carries no WRAPLINE flag — this is a producer hard newline,
|
||||
// not a terminal soft wrap.
|
||||
assert!(
|
||||
!term.grid()[Line(0)][Column(19)]
|
||||
.flags
|
||||
@@ -824,8 +596,6 @@ mod tests {
|
||||
"fixture must be a hard newline, not a soft wrap"
|
||||
);
|
||||
|
||||
// Link mode (bridge_hard_wrap = true) recovers the whole URL spanning
|
||||
// both rows.
|
||||
let click = Point::new(Line(0), Column(3));
|
||||
let (text, _points, _idx) =
|
||||
logical_line_at(&term, click, true).expect("logical line under click");
|
||||
@@ -834,8 +604,6 @@ mod tests {
|
||||
crate::terminal::search::url_span_at(&text, idx + 2).expect("url span in bridged line");
|
||||
assert_eq!(url, "https://example.com/deep/path/seg");
|
||||
|
||||
// Smart-select mode (bridge_hard_wrap = false) must NOT glue the two
|
||||
// output lines together.
|
||||
let (text, _points, _idx) =
|
||||
logical_line_at(&term, click, false).expect("logical line under click");
|
||||
assert!(
|
||||
@@ -846,11 +614,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_hard_break_before_userinfo_is_never_bridged() {
|
||||
// Row 0 ends with a bare host that fills the row exactly, row 1 opens
|
||||
// with `@`. Bridging would resolve `https://good.com@evil.com/x`, whose
|
||||
// authority per RFC 3986 is `evil.com` — the underline would read
|
||||
// `good.com` while the click navigated elsewhere. The hard bridge must
|
||||
// refuse this one even though the row shape otherwise invites it.
|
||||
let term = term_with(20, 4, "go1 https://good.com\r\n@evil.com/x rest");
|
||||
assert!(
|
||||
!term.grid()[Line(0)][Column(19)]
|
||||
@@ -873,9 +636,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_soft_wrap_before_userinfo_still_stitches() {
|
||||
// The `@` guard is about the *ambiguity* of a hard newline. A soft wrap
|
||||
// is the terminal folding one logical line, so the continuation is
|
||||
// certain and a userinfo URL must still resolve whole.
|
||||
let term = term_with(20, 4, "see https://user1234@ex.com/z rest");
|
||||
assert!(
|
||||
term.grid()[Line(0)][Column(19)]
|
||||
@@ -894,12 +654,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn wide_glyph_and_its_spacer_resolve_to_the_same_word() {
|
||||
// Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER
|
||||
// and has no `c` of its own. Clicking either half must select the same
|
||||
// segmented word — an off-by-one in the spacer branch shows up here.
|
||||
ensure_segmenter();
|
||||
let term = term_with(40, 3, "run 北京欢迎你 done");
|
||||
// "run " is 4 cells, then 北 at col 4 (spacer at 5), 京 at 6 (spacer 7).
|
||||
let expected = grid_select(&term, 0, 4);
|
||||
assert_eq!(expected.as_deref(), Some("北京"), "click on 北");
|
||||
assert_eq!(
|
||||
@@ -921,12 +677,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn wide_glyph_wrapping_to_the_next_row_keeps_its_word_intact() {
|
||||
// An odd column count leaves one cell at the end of the row: the wide
|
||||
// char can't fit, so alacritty pads with LEADING_WIDE_CHAR_SPACER and
|
||||
// moves the glyph to the next row. The logical line must still join.
|
||||
ensure_segmenter();
|
||||
let term = term_with(9, 4, "abcdefgh北京欢迎你");
|
||||
// 北 is pushed to row 1 col 0 by the leading spacer at row 0 col 8.
|
||||
assert_eq!(grid_select(&term, 1, 0).as_deref(), Some("北京"));
|
||||
}
|
||||
|
||||
@@ -937,17 +689,11 @@ mod tests {
|
||||
assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none());
|
||||
}
|
||||
|
||||
/// A double-click dispatched with the previous frame's geometry can name a
|
||||
/// row the grid has since dropped. Indexing it walks off the storage, and
|
||||
/// the click arrives in a gpui `extern "C"` callback where that panic
|
||||
/// aborts instead of unwinding — so the row has to be refused first.
|
||||
#[test]
|
||||
fn click_outside_the_grid_rows_yields_no_range() {
|
||||
let term = term_with(10, 2, "hello");
|
||||
// Below the last row of a shrunken grid...
|
||||
assert!(grid_smart_range(&term, Point::new(Line(2), Column(0))).is_none());
|
||||
assert!(grid_smart_range(&term, Point::new(Line(9_000), Column(0))).is_none());
|
||||
// ...and above the top of a scrollback this short.
|
||||
assert!(grid_smart_range(&term, Point::new(Line(-1), Column(0))).is_none());
|
||||
}
|
||||
|
||||
@@ -959,8 +705,6 @@ mod tests {
|
||||
#[test]
|
||||
fn url_expands_past_scheme_colon() {
|
||||
let text = "fetch https://example.com/a/b?q=1 done";
|
||||
// Click inside "example" — the plain word starts after the `:`
|
||||
// separator; smart select recovers the whole URL.
|
||||
let click = text.find("example").unwrap();
|
||||
assert_eq!(
|
||||
selected(text, click).as_deref(),
|
||||
@@ -977,14 +721,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn email_only_fires_when_it_extends_the_word() {
|
||||
// With the default separators the plain word already covers the whole
|
||||
// address (`@` and `.` are word chars) — the candidate equals the word
|
||||
// and must be rejected, keeping the stock selection.
|
||||
let text = "author:dev@example.com pushed";
|
||||
let click = text.find("example").unwrap();
|
||||
assert_eq!(range(text, click), None);
|
||||
// With `@` configured as a separator, the email regex reassembles the
|
||||
// full address across it.
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let got = smart_range(text, &chars, click, ",@:() ");
|
||||
let (s, e) = got.expect("email should match");
|
||||
@@ -1001,8 +740,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn path_across_quote_boundary_stays_plain() {
|
||||
// The whole path is one plain word already (no separators inside);
|
||||
// candidates equal to the word are rejected → stock selection.
|
||||
let text = "cat /usr/local/bin/tool";
|
||||
let click = text.find("local").unwrap();
|
||||
assert_eq!(range(text, click), None);
|
||||
@@ -1010,10 +747,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn path_glued_to_colon_expands() {
|
||||
// `error:/tmp/x/y` — the word starts after `:`; the path regex
|
||||
// must not leak left past the colon but the URL/identifier ones
|
||||
// must not shrink it either. Path candidate is `/tmp/x/y`, equal
|
||||
// to the plain word → None. Click on `error` side: word `error`.
|
||||
let text = "error:/tmp/x/y";
|
||||
let click = text.find("tmp").unwrap();
|
||||
assert_eq!(range(text, click), None);
|
||||
@@ -1026,8 +759,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scientific_notation_with_custom_separators() {
|
||||
// With `.` and `+` configured as separators (finer-grained
|
||||
// boundaries), the sci-notation regex reassembles the number.
|
||||
let text = "n = 6.02e+23 mol";
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let click = text.find("02").unwrap();
|
||||
@@ -1039,8 +770,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identifier_with_custom_separators() {
|
||||
// Fine-grained separators split `foo-bar.baz`; the identifier
|
||||
// regex restores the full dotted chain.
|
||||
let text = "run foo-bar.baz now";
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let click = text.find("bar").unwrap();
|
||||
@@ -1052,8 +781,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn latin_word_glued_directly_to_han_narrows_without_punctuation() {
|
||||
// No separator or punctuation between the scripts at all — the
|
||||
// identifier regex must not reassemble the mixed run (`\w` would).
|
||||
let text = "已合并到main分支";
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let click = chars.iter().position(|&c| c == 'm').unwrap();
|
||||
@@ -1064,8 +791,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn latin_word_glued_to_cjk_narrows_to_the_latin_run() {
|
||||
// `,` and `已` are not separators, so the raw run is
|
||||
// `worktree-feat-smart-select,已`; the meant word is the Latin part.
|
||||
let text = "分支 worktree-feat-smart-select,已 rebase";
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let click = chars.iter().position(|&c| c == 'w').unwrap() + 10;
|
||||
@@ -1077,35 +802,26 @@ mod tests {
|
||||
#[test]
|
||||
fn symmetric_quotes_pair_by_parity() {
|
||||
let chars: Vec<char> = r#"echo 'a,b' "c d" x"#.chars().collect();
|
||||
// First ' opens (0 quotes before), second closes.
|
||||
assert_eq!(quote_range(&chars, 5), Some((5, 9)));
|
||||
assert_eq!(quote_range(&chars, 9), Some((5, 9)));
|
||||
// Double quotes pair independently of the single ones.
|
||||
assert_eq!(quote_range(&chars, 11), Some((11, 15)));
|
||||
assert_eq!(quote_range(&chars, 15), Some((11, 15)));
|
||||
// An unmatched opener finds nothing.
|
||||
let chars: Vec<char> = "say 'oops".chars().collect();
|
||||
assert_eq!(quote_range(&chars, 4), None);
|
||||
// Non-quote chars never match.
|
||||
assert_eq!(quote_range(&chars, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contraction_apostrophes_do_not_pair() {
|
||||
let chars: Vec<char> = "it's a test, isn't it".chars().collect();
|
||||
// Clicking either contraction falls through to the stock word.
|
||||
assert_eq!(quote_range(&chars, 2), None);
|
||||
assert_eq!(quote_range(&chars, 16), None);
|
||||
// And the whole line yields no smart candidate at all, so the
|
||||
// double-click keeps alacritty's `it's`.
|
||||
let text = "it's a test, isn't it";
|
||||
assert_eq!(range(text, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contractions_do_not_skew_a_real_quote() {
|
||||
// The apostrophes inside the quoted span must not flip the parity or
|
||||
// steal the match from the genuine delimiters.
|
||||
let chars: Vec<char> = "echo 'it isn't so' done".chars().collect();
|
||||
let open = 5;
|
||||
let close = chars.iter().rposition(|&c| c == '\'').unwrap();
|
||||
@@ -1115,8 +831,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn trailing_apostrophe_still_closes() {
|
||||
// `dogs'` — the apostrophe has a word char only on its left, so it is
|
||||
// a delimiter, not a contraction.
|
||||
let chars: Vec<char> = "the 'dogs' bark".chars().collect();
|
||||
assert_eq!(quote_range(&chars, 4), Some((4, 9)));
|
||||
assert_eq!(quote_range(&chars, 9), Some((4, 9)));
|
||||
@@ -1151,7 +865,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn angle_brackets_pair_only_when_they_hug_their_contents() {
|
||||
// Real delimiters: generics, tags, placeholders, bracketed addresses.
|
||||
for (text, want) in [
|
||||
("let v: Vec<String> = x", "<String>"),
|
||||
("<div class=\"row\">hi", "<div class=\"row\">"),
|
||||
@@ -1165,8 +878,6 @@ mod tests {
|
||||
let got: String = chars[s..=e].iter().collect();
|
||||
assert_eq!(got, want, "{text}");
|
||||
}
|
||||
// Comparison operators must not pair across half a line — a space just
|
||||
// inside either end is the tell.
|
||||
for text in [
|
||||
"if a < b then c > d",
|
||||
"awk '{ if ($1 > 100 && $2 < 5) print }'",
|
||||
@@ -1180,7 +891,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Redirections never had a partner to match in the first place.
|
||||
for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
@@ -1210,9 +920,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cjk_segmentation_survives_surrogate_pairs_before_the_click() {
|
||||
// The emoji is two UTF-16 units: a tokenizer offset table that counted
|
||||
// chars instead would shift every index after it. Both backends agree
|
||||
// on `世界`, so a skewed mapping shows up as a different token.
|
||||
ensure_segmenter();
|
||||
for text in ["你好世界", "🙂 你好世界", "🙂🙂🙂 你好世界"] {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
@@ -1234,17 +941,12 @@ mod tests {
|
||||
assert_eq!(sel, ",");
|
||||
}
|
||||
|
||||
/// Japanese must not be run through jieba's Chinese dictionary — it cuts
|
||||
/// kana into single characters, which is worse than leaving the run whole.
|
||||
/// macOS hands it to CFStringTokenizer, which segments it properly.
|
||||
#[test]
|
||||
fn japanese_is_not_shredded_into_single_kana() {
|
||||
ensure_segmenter();
|
||||
let text = "日本語の文章です";
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let click = chars.iter().position(|&c| c == 'で').unwrap();
|
||||
// macOS yields a real token, never a lone kana; elsewhere the run
|
||||
// comes back unsegmented and the caller selects all of it.
|
||||
if let Some((s, e)) = cjk_word_range(text, click) {
|
||||
let sel: String = chars[s..=e].iter().collect();
|
||||
assert_eq!(sel, "です");
|
||||
|
||||
+1
-103
@@ -1,55 +1,13 @@
|
||||
//! Tracks what the user types into a pane while the local line editor is
|
||||
//! *disengaged*, so the editor can adopt it on engage instead of stranding it.
|
||||
//!
|
||||
//! Two windows behave identically: a freshly spawned shell sourcing rc files
|
||||
//! (often a second or more before the first OSC 133), and the gap every
|
||||
//! submitted command opens between `133;C` and the next prompt. In both,
|
||||
//! `at_prompt` is false and keystrokes go raw to the PTY. The shell isn't
|
||||
//! reading them — the bytes queue in the kernel TTY buffer — and when zle
|
||||
//! (re)starts at the next prompt it consumes them as type-ahead: they appear
|
||||
//! on the *shell's* command line. At that same moment the editor engages with
|
||||
//! an empty buffer and swallows every key, so the strays can be neither
|
||||
//! edited nor deleted — and the editor overlay (transparent, anchored at the
|
||||
//! cursor) double-draws its own line over their echo.
|
||||
//!
|
||||
//! The fix: record a best-effort reconstruction of the gap typing here; when
|
||||
//! the editor engages, send one `^U` (kill-whole-line) to the PTY and seed
|
||||
//! the editor with the reconstruction. Ordering makes the `^U` safe with no
|
||||
//! timing assumptions: it is written *after* every stray byte, and the TTY
|
||||
//! queue is FIFO, so zle always consumes the strays first and then the `^U`
|
||||
//! that wipes them — wherever prompt boundaries fall. In the common case
|
||||
//! nothing was typed in the gap, `drain` returns `None`, and no byte is sent.
|
||||
//!
|
||||
//! A command that *reads* its stdin (a REPL, a password prompt) consumes gap
|
||||
//! bytes itself; they never reach zle. The `^U` still only lands in zle (the
|
||||
//! editor engages at a prompt, after the command exited), where killing an
|
||||
//! empty line is a no-op, and Enter-terminated input seeds nothing thanks to
|
||||
//! the submit-boundary rule — so the wipe stays safe there too. Full-screen
|
||||
//! TUI input (alt screen) is not reconstructable typing at all: it taints the
|
||||
//! record instead of recording.
|
||||
|
||||
/// Cap on the recorded reconstruction. Typing that overflows it (nobody types
|
||||
/// 4 KiB into a prompt gap — this is a paste or a stuck key) taints the
|
||||
/// record instead of silently truncating to a wrong line.
|
||||
const RECORD_CAP: usize = 4096;
|
||||
|
||||
/// Best-effort reconstruction of user input sent raw to the PTY while the
|
||||
/// line editor was disengaged. `tainted` means bytes we can't reconstruct
|
||||
/// (arrows, tab, control chords, multi-line pastes) went through: the wipe
|
||||
/// still happens, but nothing is seeded — a wrong guess in the editor is
|
||||
/// worse than an empty line.
|
||||
#[derive(Default)]
|
||||
pub struct Typeahead {
|
||||
text: String,
|
||||
tainted: bool,
|
||||
}
|
||||
|
||||
/// One raw PTY-bound user-input event, as far as reconstruction cares.
|
||||
pub enum RawInput<'a> {
|
||||
/// Committed printable text (the IME commit and paste paths).
|
||||
Text(&'a str),
|
||||
/// A non-text keystroke that produced PTY bytes. `key` is the GPUI key
|
||||
/// name; `plain` means no control/alt/platform modifier was held.
|
||||
Key { key: &'a str, plain: bool },
|
||||
}
|
||||
|
||||
@@ -58,11 +16,6 @@ impl Typeahead {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Fold one raw PTY-bound input event into the reconstruction. Call this
|
||||
/// wherever user input is written to the PTY while the line editor is
|
||||
/// disengaged — the record mirrors exactly what the shell will later
|
||||
/// consume as type-ahead. `alt_screen` input belongs to a full-screen TUI,
|
||||
/// not the shell's next line: it taints the record instead of recording.
|
||||
pub fn observe(&mut self, input: RawInput, alt_screen: bool) {
|
||||
if alt_screen {
|
||||
self.taint();
|
||||
@@ -82,17 +35,10 @@ impl Typeahead {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the reconstruction accumulated since the last drain, resetting the
|
||||
/// record so the next gap starts clean. `None` → nothing was typed, send
|
||||
/// nothing. `Some(seed)` → send `^U` to wipe the shell's line, then put
|
||||
/// `seed` (possibly empty, if tainted or everything was already submitted)
|
||||
/// into the editor.
|
||||
pub fn drain(&mut self) -> Option<String> {
|
||||
std::mem::take(self).flush()
|
||||
}
|
||||
|
||||
/// Record committed printable text (the IME path). Control characters mean
|
||||
/// this wasn't plain typing (e.g. a multi-line paste) — taint instead.
|
||||
fn record_text(&mut self, s: &str) {
|
||||
if s.chars().any(char::is_control) {
|
||||
self.tainted = true;
|
||||
@@ -105,9 +51,6 @@ impl Typeahead {
|
||||
self.text.push_str(s);
|
||||
}
|
||||
|
||||
/// Record Enter. `\r` marks a submit boundary: everything before it will
|
||||
/// have been accepted (and run) by zle, so only the tail after the *last*
|
||||
/// `\r` is still sitting on the line when we flush.
|
||||
fn record_enter(&mut self) {
|
||||
if self.text.len() + 1 > RECORD_CAP {
|
||||
self.tainted = true;
|
||||
@@ -116,24 +59,16 @@ impl Typeahead {
|
||||
self.text.push('\r');
|
||||
}
|
||||
|
||||
/// Record Backspace. Pops the last recorded char — except across a submit
|
||||
/// boundary (or on an empty record), where zle itself would have had
|
||||
/// nothing to erase, so the record must not shrink either.
|
||||
fn record_backspace(&mut self) {
|
||||
if !self.text.ends_with('\r') {
|
||||
self.text.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a byte sequence we can't reconstruct (arrows, tab, control
|
||||
/// chords…). The eventual wipe neutralizes whatever zle makes of it; we
|
||||
/// just stop pretending to know the line's content.
|
||||
fn taint(&mut self) {
|
||||
self.tainted = true;
|
||||
}
|
||||
|
||||
/// Consume the record into the seed decision — the by-value core of
|
||||
/// [`Typeahead::drain`], see there for the contract.
|
||||
fn flush(self) -> Option<String> {
|
||||
if self.text.is_empty() && !self.tainted {
|
||||
return None;
|
||||
@@ -141,7 +76,6 @@ impl Typeahead {
|
||||
if self.tainted {
|
||||
return Some(String::new());
|
||||
}
|
||||
// Only the tail after the last submit boundary is still on zle's line.
|
||||
let seed = self.text.rsplit('\r').next().unwrap_or("");
|
||||
Some(seed.to_string())
|
||||
}
|
||||
@@ -153,24 +87,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn drained_record_reconstructs_each_gap_independently() {
|
||||
// Mid-session, every submitted command opens a prompt→prompt gap where
|
||||
// typing goes raw to the PTY. The record must reset on `drain` so each
|
||||
// gap seeds only its own typing.
|
||||
let mut t = Typeahead::new();
|
||||
t.observe(RawInput::Text("cd getty"), false);
|
||||
assert_eq!(t.drain(), Some("cd getty".to_string()));
|
||||
// The idle prompt drains once per render — nothing typed since, so
|
||||
// nothing is wiped or seeded.
|
||||
assert_eq!(t.drain(), None);
|
||||
// The next gap starts clean, unpolluted by the drained one.
|
||||
t.observe(RawInput::Text("ls"), false);
|
||||
assert_eq!(t.drain(), Some("ls".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_keys_map_to_boundary_erase_or_taint() {
|
||||
// Plain Enter is a submit boundary (zle ran what precedes it); plain
|
||||
// Backspace erases the last recorded char, exactly like zle will.
|
||||
let mut t = Typeahead::new();
|
||||
t.observe(RawInput::Text("ls"), false);
|
||||
t.observe(
|
||||
@@ -190,8 +116,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(t.drain(), Some("git s".to_string()));
|
||||
|
||||
// Any other key that produced PTY bytes (arrows, tab, chords) makes
|
||||
// the line unknowable — wipe, seed nothing.
|
||||
let mut t = Typeahead::new();
|
||||
t.observe(RawInput::Text("ls"), false);
|
||||
t.observe(
|
||||
@@ -203,7 +127,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(t.drain(), Some(String::new()));
|
||||
|
||||
// A chorded Enter isn't accept-line; it must not fake a boundary.
|
||||
let mut t = Typeahead::new();
|
||||
t.observe(RawInput::Text("a"), false);
|
||||
t.observe(
|
||||
@@ -218,10 +141,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn alt_screen_input_taints_instead_of_seeding() {
|
||||
// Keys typed into a full-screen TUI (vim, less…) are that program's
|
||||
// input, not command typing — resurrecting them as an editor seed
|
||||
// would turn a habitual `q` into a pending command. They make the
|
||||
// line unknowable: wipe at the next prompt, seed nothing.
|
||||
let mut t = Typeahead::new();
|
||||
t.observe(RawInput::Text("q"), true);
|
||||
assert_eq!(t.drain(), Some(String::new()));
|
||||
@@ -229,8 +148,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn untouched_record_flushes_to_none() {
|
||||
// The overwhelmingly common case — nothing typed during startup — must
|
||||
// send nothing: no ^U, no seed, zero behavior change.
|
||||
assert_eq!(Typeahead::new().drain(), None);
|
||||
}
|
||||
|
||||
@@ -251,8 +168,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn backspace_on_empty_record_is_noop_but_still_flushes_nothing() {
|
||||
// zle would have nothing to erase either; the record stays empty and
|
||||
// the flush stays silent.
|
||||
let mut p = Typeahead::new();
|
||||
p.record_backspace();
|
||||
assert_eq!(p.drain(), None);
|
||||
@@ -260,9 +175,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn enter_marks_a_submit_boundary() {
|
||||
// "ls\r" was accepted and executed by zle at the first prompt; nothing
|
||||
// of it remains on the line. Seeding "ls" again would duplicate the
|
||||
// command — the seed must be only the tail after the last \r.
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text("ls");
|
||||
p.record_enter();
|
||||
@@ -275,16 +187,11 @@ mod tests {
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text("ls");
|
||||
p.record_enter();
|
||||
// ^U still goes out (an empty next line is wiped harmlessly; a partial
|
||||
// leak is cleaned), but the executed command is not resurrected.
|
||||
assert_eq!(p.drain(), Some(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_does_not_cross_a_submit_boundary() {
|
||||
// After "ls\r", zle's next line is empty: a Backspace typed then erases
|
||||
// nothing in the shell, so it must not eat our \r marker either —
|
||||
// otherwise the seed would become "ls" and duplicate the executed command.
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text("ls");
|
||||
p.record_enter();
|
||||
@@ -294,8 +201,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreconstructable_input_taints_wipe_without_seed() {
|
||||
// An arrow key (history recall!) makes the line's real content
|
||||
// unknowable. Wipe it, seed nothing.
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text("ls");
|
||||
p.taint();
|
||||
@@ -304,8 +209,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn control_chars_in_committed_text_taint() {
|
||||
// A multi-line paste reaches the raw path as one commit; its embedded
|
||||
// newlines already ran as commands zle-side. Don't guess.
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text("echo a\necho b");
|
||||
assert_eq!(p.drain(), Some(String::new()));
|
||||
@@ -318,14 +221,11 @@ mod tests {
|
||||
for _ in 0..5 {
|
||||
p.record_text(&chunk);
|
||||
}
|
||||
// 5000 > RECORD_CAP: a truncated seed would be a wrong line; taint.
|
||||
assert_eq!(p.drain(), Some(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_at_the_cap_still_reconstructs() {
|
||||
// Filling the record to exactly RECORD_CAP is not an overflow; the full
|
||||
// reconstruction survives. One more char would tip it into taint.
|
||||
let mut p = Typeahead::new();
|
||||
let full = "x".repeat(RECORD_CAP);
|
||||
p.record_text(&full);
|
||||
@@ -333,14 +233,12 @@ mod tests {
|
||||
|
||||
let mut p = Typeahead::new();
|
||||
p.record_text(&full);
|
||||
p.record_text("y"); // cap + 1 → taint (wipe, no seed)
|
||||
p.record_text("y");
|
||||
assert_eq!(p.drain(), Some(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taint_survives_later_clean_typing() {
|
||||
// Once the record is unknowable it stays unknowable — later reconstructable
|
||||
// keys must not "wash" the taint into a half-right seed.
|
||||
let mut p = Typeahead::new();
|
||||
p.taint();
|
||||
p.record_text("ls");
|
||||
|
||||
+36
-3122
File diff suppressed because it is too large
Load Diff
+9
-2417
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user