T36: programs report their status through OSC 7501, revision 0.4, checked as the spec checks them; each terminal keeps their records by id, replaced whole, cleared with those under them, 256 at most; a new prompt drops working, blocked and idle, a full reset all; the support query is answered with every state and kind, ahead of a DA1 sent after it; the pty's tests move to their own file
CI / platforms (windows-latest) (push) Successful in 1m31s
CI / platforms (ubuntu-24.04) (push) Successful in 1m46s
CI / macos (push) Successful in 11m59s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 00:46:07 -04:00
1 parent 99db56bab1
commit 42b272ece6
12 files changed
+755 -250

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ mod pointer;
mod prompts;
mod pty;
pub mod shell;
mod status;
mod tap;
#[cfg(unix)]
mod unix;
+6 -220
View File
@@ -11,7 +11,7 @@ use alacritty_terminal::{
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::{marks::Mark, mouse::MouseMode, tap::Tapped};
use super::{marks::Mark, mouse::MouseMode, status::Status, tap::Tapped};
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty.
@@ -21,6 +21,10 @@ pub enum PtyEvent {
Folder(PathBuf),
Mouse(MouseMode),
Mark(Mark),
/// A program's status, OSC 7501.
Status(Status),
/// A full reset, `ESC c`.
Reset,
}
/// Hands the grid's events to the session; `pid` names the program in logs.
@@ -246,225 +250,7 @@ pub fn fit(columns: usize, lines: usize, cell: (u16, u16)) -> WindowSize {
}
#[cfg(all(test, unix))]
mod tests {
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Line},
};
use futures::StreamExt;
use super::*;
struct Ran {
folders: Vec<PathBuf>,
marks: Vec<Mark>,
text: String,
/// The first line's cells that carry a prompt's link.
prompt: Vec<usize>,
success: bool,
pid: u32,
columns: usize,
}
fn run(script: &str, size: (usize, usize)) -> Ran {
crate::testlog::init();
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
env: vec![("ELY".into(), "ok".into())],
reports: false,
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, marks, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut success) = (Vec::new(), Vec::new(), false);
while let Some(event) = events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Mark(mark) => marks.push(mark),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, marks, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
let prompt = (0..24).filter(|&column| {
row[Column(column)]
.hyperlink()
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
});
Ran {
folders,
marks,
text: text.trim_end().to_string(),
prompt: prompt.collect(),
success,
pid: pty.pid,
columns: term.columns(),
}
}
#[test]
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
assert!(ran.folders.is_empty());
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
assert!(ran.success);
}
#[test]
fn osc_7_reaches_the_session_as_a_folder() {
let ran = run(
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
(40, 4),
);
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
assert_eq!(ran.text, "x");
}
#[test]
fn osc_133_reaches_the_session_as_marks() {
let ran = run(
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
(40, 4),
);
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
assert_eq!(ran.marks, marks);
assert_eq!(ran.text, ">x");
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
}
#[test]
fn a_long_escape_of_controls_keeps_the_pty_reading() {
let ran = run(
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
(40, 4),
);
assert_eq!(ran.text, "READY");
}
#[test]
fn the_foreground_is_the_program_s_own_group() {
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
};
let (pty, _events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while pty.foreground().ok() != Some(pty.pid as i32) {
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pty.foreground()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
let ran = run("stty size", (9000, 8));
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
}
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
fn close_after_ready(script: &str, reaped: &str) {
crate::testlog::init();
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
};
let (pty, mut events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
futures::executor::block_on(async {
while let Some(event) = events.next().await {
let term = pty.term.lock();
let text: String = (0..5)
.map(|column| term.grid()[Line(0)][Column(column)].c)
.collect();
drop(term);
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
break;
}
}
});
let pid = pty.pid;
drop(pty);
let hung_up = format!("pty {pid}: hung up, {reaped}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !crate::testlog::logged(&hung_up) {
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
assert!(!alive, "the program was reaped");
}
#[test]
fn closing_a_session_hangs_up_its_program() {
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
}
#[test]
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
}
#[test]
fn a_missing_folder_stops_the_start() {
let launch = Launch {
program: None,
cwd: "/nonexistent/ely".into(),
env: Vec::new(),
reports: false,
};
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
.err()
.expect("a missing folder is an error");
assert!(error.to_string().contains("/nonexistent/ely"));
}
#[test]
fn a_folder_that_cannot_be_entered_stops_the_start() {
use std::os::unix::fs::PermissionsExt;
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
std::fs::create_dir_all(&folder).expect("a temp folder");
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
cwd: folder.clone(),
env: Vec::new(),
reports: false,
};
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
std::fs::remove_dir(&folder).expect("the temp folder goes");
let error = started.err().expect("a folder it cannot enter is an error");
let error = error.to_string();
assert!(error.contains("Permission denied"), "{error}");
assert!(error.contains(&folder.display().to_string()), "{error}");
}
#[test]
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
let size = fit(70_000, 24, (8, 16));
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
let size = fit(0, 0, (8, 16));
assert_eq!((size.num_cols, size.num_lines), (2, 1));
}
}
mod tests;
#[cfg(test)]
mod scrollback {
+247
View File
@@ -0,0 +1,247 @@
use alacritty_terminal::{
grid::Dimensions,
index::{Column, Line},
};
use futures::StreamExt;
use super::*;
use crate::term::status::{Record, State};
struct Ran {
folders: Vec<PathBuf>,
marks: Vec<Mark>,
/// What programs said of their status, a full reset as none.
statuses: Vec<Option<Status>>,
text: String,
/// The first line's cells that carry a prompt's link.
prompt: Vec<usize>,
success: bool,
pid: u32,
columns: usize,
}
fn run(script: &str, size: (usize, usize)) -> Ran {
crate::testlog::init();
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
env: vec![("ELY".into(), "ok".into())],
reports: false,
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, marks, statuses, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut success) =
(Vec::new(), Vec::new(), Vec::new(), false);
while let Some(event) = events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Mark(mark) => marks.push(mark),
PtyEvent::Status(status) => statuses.push(Some(status)),
PtyEvent::Reset => statuses.push(None),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, marks, statuses, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
let prompt = (0..24).filter(|&column| {
row[Column(column)]
.hyperlink()
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
});
Ran {
folders,
marks,
statuses,
text: text.trim_end().to_string(),
prompt: prompt.collect(),
success,
pid: pty.pid,
columns: term.columns(),
}
}
#[test]
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
assert!(ran.folders.is_empty());
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
assert!(ran.success);
}
#[test]
fn osc_7_reaches_the_session_as_a_folder() {
let ran = run(
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
(40, 4),
);
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
assert_eq!(ran.text, "x");
}
#[test]
fn osc_133_reaches_the_session_as_marks() {
let ran = run(
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
(40, 4),
);
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
assert_eq!(ran.marks, marks);
assert_eq!(ran.text, ">x");
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
}
#[test]
fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
let ran = run(
"printf '\\033]7501;state=working:progress=40\\033\\\\\\033]7501;?\\007\\033cx'",
(40, 4),
);
let working = Record {
state: State::Working,
kind: None,
progress: Some(40),
app: None,
title: None,
msg: None,
};
let said = [
Some(Status::Report(String::new(), working)),
Some(Status::Query),
None,
];
assert_eq!(ran.statuses, said);
assert_eq!(ran.text, "x");
}
#[test]
fn a_long_escape_of_controls_keeps_the_pty_reading() {
let ran = run(
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
(40, 4),
);
assert_eq!(ran.text, "READY");
}
#[test]
fn the_foreground_is_the_program_s_own_group() {
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
};
let (pty, _events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while pty.foreground().ok() != Some(pty.pid as i32) {
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pty.foreground()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
let ran = run("stty size", (9000, 8));
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
}
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
fn close_after_ready(script: &str, reaped: &str) {
crate::testlog::init();
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
env: Vec::new(),
reports: false,
};
let (pty, mut events) =
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
futures::executor::block_on(async {
while let Some(event) = events.next().await {
let term = pty.term.lock();
let text: String = (0..5)
.map(|column| term.grid()[Line(0)][Column(column)].c)
.collect();
drop(term);
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
break;
}
}
});
let pid = pty.pid;
drop(pty);
let hung_up = format!("pty {pid}: hung up, {reaped}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !crate::testlog::logged(&hung_up) {
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
assert!(!alive, "the program was reaped");
}
#[test]
fn closing_a_session_hangs_up_its_program() {
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
}
#[test]
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
}
#[test]
fn a_missing_folder_stops_the_start() {
let launch = Launch {
program: None,
cwd: "/nonexistent/ely".into(),
env: Vec::new(),
reports: false,
};
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
.err()
.expect("a missing folder is an error");
assert!(error.to_string().contains("/nonexistent/ely"));
}
#[test]
fn a_folder_that_cannot_be_entered_stops_the_start() {
use std::os::unix::fs::PermissionsExt;
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
std::fs::create_dir_all(&folder).expect("a temp folder");
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
cwd: folder.clone(),
env: Vec::new(),
reports: false,
};
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
std::fs::remove_dir(&folder).expect("the temp folder goes");
let error = started.err().expect("a folder it cannot enter is an error");
let error = error.to_string();
assert!(error.contains("Permission denied"), "{error}");
assert!(error.contains(&folder.display().to_string()), "{error}");
}
#[test]
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
let size = fit(70_000, 24, (8, 16));
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
let size = fit(0, 0, (8, 16));
assert_eq!((size.num_cols, size.num_lines), (2, 1));
}
+242
View File
@@ -0,0 +1,242 @@
use base64::{
Engine,
alphabet::STANDARD,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
/// What a program says it is doing, OSC 7501.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Idle,
Working,
Done,
Blocked,
Error,
}
/// What a blocked program waits for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Permission,
Question,
Auth,
}
/// A record, as the last report for its id left it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub state: State,
pub kind: Option<Kind>,
pub progress: Option<u8>,
pub app: Option<String>,
pub title: Option<String>,
pub msg: Option<String>,
}
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Report(String, Record),
/// Drops the record at the id and those under it; with no id, all.
Clear(Option<String>),
/// Asks which states and kinds the terminal takes.
Query,
}
/// The answer to the query: every state and kind this terminal takes.
pub const REPLY: &str =
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
const SEQUENCE: usize = 4096;
const KEY: usize = 16;
const APP: usize = 32;
const ID: usize = 128;
const SEGMENT: usize = 32;
const LEVELS: usize = 8;
/// Records a terminal keeps; past them, the one updated longest ago goes.
pub const RECORDS: usize = 256;
/// Standard base64, its padding optional.
const BASE64: GeneralPurpose = GeneralPurpose::new(
&STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
/// The sequence in an OSC 7501 body past `7501;`; none for one the spec drops, logged with why.
pub fn read(body: &[u8]) -> Option<Status> {
parse(body)
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
.ok()
}
/// A value's bytes, as the spec allows them.
fn valued(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
}
/// An id's or an app's bytes.
fn named(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
}
fn parse(body: &[u8]) -> Result<Status, String> {
// ESC ] 7501 ; and an ST of two bytes frame the body.
if body.len() + 9 > SEQUENCE {
return Err(format!("over {SEQUENCE} bytes"));
}
if body.trim_ascii() == b"?" {
return Ok(Status::Query);
}
let mut pairs = Vec::new();
for pair in body.split(|&byte| byte == b':') {
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
continue;
};
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
if key.is_empty()
|| !key.iter().all(u8::is_ascii_lowercase)
|| !value.iter().all(|&byte| valued(byte))
{
continue;
}
if key.len() > KEY {
return Err(format!("a key over {KEY} bytes"));
}
let ascii = |bytes| String::from_utf8(Vec::from(bytes)).expect("ASCII");
pairs.push((ascii(key), ascii(value)));
}
// The last of a key wins.
let get = |key: &str| {
pairs
.iter()
.rev()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
};
let id = get("id").map(id).transpose()?;
let state = match get("state").ok_or("no state")? {
"idle" => State::Idle,
"working" => State::Working,
"done" => State::Done,
"blocked" => State::Blocked,
"error" => State::Error,
"clear" => return Ok(Status::Clear(id)),
other => return Err(format!("state {other} is not the spec's")),
};
let kind = match (state, get("kind")) {
(State::Blocked, Some("permission")) => Some(Kind::Permission),
(State::Blocked, Some("question")) => Some(Kind::Question),
(State::Blocked, Some("auth")) => Some(Kind::Auth),
_ => None,
};
let progress = match state {
State::Working | State::Blocked => get("progress")
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
.and_then(|value| value.parse::<u8>().ok())
.filter(|&value| value <= 100),
_ => None,
};
let app = match get("app") {
Some(app) if app.len() > APP => return Err(format!("an app over {APP} bytes")),
Some(app) if !app.is_empty() && app.bytes().all(named) => Some(app.to_string()),
_ => None,
};
let title = get("title")
.map(|value| text(value, "title", 256, 192))
.transpose()?;
let msg = get("msg")
.map(|value| text(value, "msg", 2732, 2048))
.transpose()?;
let record = Record {
state,
kind,
progress,
app,
title,
msg,
};
Ok(Status::Report(id.unwrap_or_default(), record))
}
/// An id that keeps to the spec's grammar and limits.
fn id(value: &str) -> Result<String, String> {
let segments: Vec<&str> = value.split('/').collect();
let fits = value.len() <= ID
&& segments.len() <= LEVELS
&& segments
.iter()
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
if fits {
Ok(value.to_string())
} else {
Err(format!("id {value} is not the spec's"))
}
}
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
if value.len() > encoded {
return Err(format!("a {key} over {encoded} bytes"));
}
let bytes = BASE64
.decode(value)
.map_err(|error| format!("{key} is no base64: {error}"))?;
if bytes.len() > decoded {
return Err(format!("a {key} over {decoded} bytes decoded"));
}
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
if text.chars().any(char::is_control) {
return Err(format!("{key} holds a control character"));
}
Ok(text)
}
/// The records one terminal keeps, by id, each with when it was last reported.
#[derive(Default)]
pub struct Records {
list: Vec<(String, Record, u64)>,
clock: u64,
}
impl Records {
/// Replaces the record at `id` whole.
pub fn report(&mut self, id: String, record: Record) {
self.clock += 1;
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
*entry = (id, record, self.clock);
return;
}
if self.list.len() == RECORDS {
let oldest = (0..self.list.len())
.min_by_key(|&at| self.list[at].2)
.expect("records");
let (gone, ..) = self.list.remove(oldest);
log::debug!("status: record {gone:?} goes, the one updated longest ago");
}
self.list.push((id, record, self.clock));
}
/// Drops the record at `id` and every one under it; with none, all.
pub fn clear(&mut self, id: Option<&str>) {
match id {
None => self.list.clear(),
Some(id) => {
let under = format!("{id}/");
self.list
.retain(|(at, ..)| at != id && !at.starts_with(&under));
}
}
}
/// A new prompt: working, blocked and idle records go, done and error stay.
pub fn prompt(&mut self) {
self.list
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
}
}
#[cfg(test)]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
use super::*;
fn encoded(text: &[u8]) -> String {
BASE64.encode(text)
}
fn record(state: State, app: Option<&str>) -> Record {
Record {
state,
kind: None,
progress: None,
app: app.map(str::to_string),
title: None,
msg: None,
}
}
fn reported(body: &str) -> Record {
match read(body.as_bytes()) {
Some(Status::Report(_, record)) => record,
other => panic!("{body}: {other:?}"),
}
}
#[test]
fn the_spec_s_examples_read_as_it_means_them() {
let body = b"state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/";
let blocked = Record {
kind: Some(Kind::Permission),
msg: Some("Apply 3 to add, 1 to change, 0 to destroy?".into()),
..record(State::Blocked, Some("terraform"))
};
assert_eq!(read(body), Some(Status::Report(String::new(), blocked)));
let body = b"state=working:id=us-east:title=VVMgRWFzdA==:progress=40:msg=UHVzaGluZyBpbWFnZQ==";
let working = Record {
progress: Some(40),
title: Some("US East".into()),
msg: Some("Pushing image".into()),
..record(State::Working, None)
};
assert_eq!(read(body), Some(Status::Report("us-east".into(), working)));
assert_eq!(
read(b"state=clear:id=us-east"),
Some(Status::Clear(Some("us-east".into())))
);
assert_eq!(read(b"state=clear"), Some(Status::Clear(None)));
assert_eq!(read(b"?"), Some(Status::Query));
assert_eq!(
reported("state=done:msg=VXBncmFkZWQgMTIgcGFja2FnZXM")
.msg
.as_deref(),
Some("Upgraded 12 packages"),
"padding is optional"
);
}
#[test]
fn a_malformed_pair_is_skipped_the_last_of_a_key_wins_and_unknown_keys_pass() {
let record = reported(" junk : =x : state = idle :Key=v:app=a b:color=red:state=done:app=ok ");
assert_eq!(record, self::record(State::Done, Some("ok")));
}
#[test]
fn a_sequence_that_breaks_a_rule_drops_whole() {
let long = |key: &str, length: usize| format!("state=done:{key}={}", "a".repeat(length));
let drops = [
"app=x".to_string(),
"state=sleeping".into(),
"state=done:id=".into(),
"state=done:id=a//b".into(),
"state=done:id=a/b/c/d/e/f/g/h/i".into(),
format!("state=done:id={}", "a".repeat(33)),
format!("state=done:{}=1", "k".repeat(17)),
long("app", 33),
long("title", 257),
long("msg", 2733),
"state=done:msg=A".into(),
format!("state=done:msg={}", encoded(b"a\nb")),
format!("state=done:msg={}", encoded("a\u{85}b".as_bytes())),
format!("state=done:msg={}", encoded(&[0xff])),
format!("state=done:title={}", encoded(&[b'a'; 193])),
format!("state=done:x={}", "a".repeat(4096 - 9 - 12)),
];
for body in drops {
assert_eq!(read(body.as_bytes()), None, "{body}");
}
assert!(read(format!("state=done:x={}", "a".repeat(4096 - 9 - 14)).as_bytes()).is_some());
}
#[test]
fn kind_progress_and_app_count_only_where_the_spec_has_them() {
let done = reported("state=done:kind=auth:progress=5");
assert_eq!((done.kind, done.progress), (None, None));
let blocked = reported("state=blocked:kind=nope:progress=101");
assert_eq!((blocked.kind, blocked.progress), (None, None));
assert_eq!(reported("state=working:progress=007").progress, Some(7));
assert_eq!(reported("state=working:progress=+5").progress, None);
assert_eq!(reported("state=blocked:kind=auth").kind, Some(Kind::Auth));
assert_eq!(
reported("state=working:app=a.b+c_d-e").app.as_deref(),
Some("a.b+c_d-e")
);
assert_eq!(reported("state=working:app=a,b").app, None);
}
#[test]
fn records_replace_and_clear_as_the_spec_says() {
let mut records = Records::default();
records.report(String::new(), record(State::Working, Some("deploy")));
records.report("us-east".into(), record(State::Working, None));
records.report("us-east/push".into(), record(State::Blocked, None));
records.report("us-easter".into(), record(State::Done, Some("other")));
records.report("us-east".into(), record(State::Done, None));
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
assert_eq!(ids, ["", "us-east", "us-east/push", "us-easter"]);
let east = records
.list
.iter()
.find(|(id, ..)| id == "us-east")
.map(|(_, record, _)| record.clone());
assert_eq!(
east,
Some(record(State::Done, None)),
"a report replaces its record whole"
);
records.clear(Some("us-east"));
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
assert_eq!(
ids,
["", "us-easter"],
"clear takes the record and those under it"
);
records.clear(None);
assert!(records.list.is_empty());
}
#[test]
fn a_new_prompt_leaves_done_and_error() {
let mut records = Records::default();
for (id, state) in [
("a", State::Idle),
("b", State::Working),
("c", State::Done),
("d", State::Blocked),
("e", State::Error),
] {
records.report(id.into(), record(state, None));
}
records.prompt();
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
assert_eq!(ids, ["c", "e"]);
}
#[test]
fn past_the_cap_the_record_updated_longest_ago_goes() {
let mut records = Records::default();
for at in 0..RECORDS {
records.report(format!("r{at}"), record(State::Working, None));
}
records.report("r0".into(), record(State::Done, None));
records.report("new".into(), record(State::Working, None));
assert_eq!(records.list.len(), RECORDS);
assert!(
records.list.iter().all(|(id, ..)| id != "r1"),
"r1 was updated longest ago"
);
assert!(records.list.iter().any(|(id, ..)| id == "r0"));
}
+49 -25
View File
@@ -12,6 +12,7 @@ use super::{
mouse::MouseMode,
prompts::{self, Prompts},
pty::PtyEvent,
status,
};
/// The longest OSC body kept; longer ones are dropped whole.
@@ -55,27 +56,8 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
let count = self.inner.reader().read(&mut buf[..take])?;
let read = &buf[..count];
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
self.scanner.feed(read, &mut |body| {
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
log::debug!("pty {pid}: {mark:?}");
if events.unbounded_send(PtyEvent::Mark(mark)).is_err() {
log::debug!("pty {pid}: a mark after its session closed");
}
return;
}
let Some(folder) = body.strip_prefix(b"7;").and_then(folder) else {
return;
};
if last.as_ref() == Some(&folder) {
log::debug!("pty {pid}: folder {} again", folder.display());
} else {
log::info!("pty {pid}: folder {}", folder.display());
*last = Some(folder.clone());
}
if events.unbounded_send(PtyEvent::Folder(folder)).is_err() {
log::debug!("pty {pid}: a folder report after its session closed");
}
});
self.scanner
.feed(read, &mut |found| report(found, events, pid, last));
if let Some(mode) = self.mouse.feed(read) {
log::info!("pty {pid}: mouse {mode:?}");
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
@@ -88,6 +70,38 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
}
/// Sends the session what the scanner found: a full reset, a mark, a program's status or a folder.
fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut Option<PathBuf>) {
let event = match found {
Found::Reset => {
log::debug!("pty {pid}: a full reset");
PtyEvent::Reset
}
Found::Osc(body) => {
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
log::debug!("pty {pid}: {mark:?}");
PtyEvent::Mark(mark)
} else if let Some(status) = body.strip_prefix(b"7501;").and_then(status::read) {
log::debug!("pty {pid}: {status:?}");
PtyEvent::Status(status)
} else if let Some(folder) = body.strip_prefix(b"7;").and_then(folder) {
if last.as_ref() == Some(&folder) {
log::debug!("pty {pid}: folder {} again", folder.display());
} else {
log::info!("pty {pid}: folder {}", folder.display());
*last = Some(folder.clone());
}
PtyEvent::Folder(folder)
} else {
return;
}
}
};
if events.unbounded_send(event).is_err() {
log::debug!("pty {pid}: an event after its session closed");
}
}
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
type Reader = Self;
type Writer = P::Writer;
@@ -138,7 +152,13 @@ enum State {
Osc,
}
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB.
/// What the scanner finds: an OSC's body, or a full reset.
pub(crate) enum Found<'a> {
Osc(&'a [u8]),
Reset,
}
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, and each full reset.
#[derive(Default)]
pub(crate) struct Osc {
state: State,
@@ -147,7 +167,7 @@ pub(crate) struct Osc {
}
impl Osc {
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(Found)) {
let mut rest = bytes;
while !rest.is_empty() {
if self.state == State::Ground {
@@ -168,6 +188,10 @@ impl Osc {
self.overflow = false;
State::Osc
}
(State::Escape, b'c') => {
found(Found::Reset);
State::Ground
}
// vte runs controls in an escape and stays there.
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f) => State::Escape,
(State::Escape, _) => State::Ground,
@@ -194,11 +218,11 @@ impl Osc {
}
}
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
fn finish(&mut self, found: &mut impl FnMut(Found)) {
if self.overflow {
log::warn!("pty: an OSC over {MOST} bytes dropped");
} else {
found(&self.body);
found(Found::Osc(&self.body));
}
self.body.clear();
}
+19 -3
View File
@@ -1,16 +1,32 @@
use super::*;
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
fn scan(chunks: &[&[u8]]) -> Vec<String> {
let mut scanner = Osc::default();
let mut found = Vec::new();
for chunk in chunks {
scanner.feed(chunk, &mut |body| {
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
scanner.feed(chunk, &mut |thing| {
found.push(match thing {
Found::Osc(body) => String::from_utf8(body.to_vec()).expect("utf-8"),
Found::Reset => "RESET".into(),
})
});
}
found
}
#[test]
fn finds_a_full_reset_between_bodies() {
assert_eq!(
scan(&[b"\x1b]0;a\x07\x1bc\x1b", b"c\x1b]0;b\x1b\\"]),
["0;a", "RESET", "RESET", "0;b"]
);
assert!(
scan(&[b"\x1b[c\x1b]0;c"]).is_empty(),
"DA1 and an unfinished body"
);
}
#[test]
fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
@@ -30,7 +46,7 @@ fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
#[test]
fn controls_vte_skips_stay_out_of_a_body() {
assert_eq!(scan(&[b"\x1b\x00]1\x0033;\x1fA\x07"]), ["133;A"]);
assert!(scan(&[b"\x1b[31m\x1bc"]).is_empty());
assert_eq!(scan(&[b"\x1b[31m\x1bc"]), ["RESET"]);
}
#[test]
+17
View File
@@ -27,6 +27,7 @@ use super::{
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
status::{self, Records, Status},
zoom,
};
use crate::{
@@ -71,6 +72,8 @@ pub struct TerminalView {
pub(super) size: (usize, usize),
/// How many folders the program has reported.
pub(super) reports: u64,
/// What programs say they are doing, OSC 7501.
pub(super) records: Records,
cell: (u16, u16),
/// Focused in an active window, as the program last heard.
focused: bool,
@@ -118,6 +121,7 @@ impl TerminalView {
finder: None,
size,
reports: 0,
records: Records::default(),
cell: (8, 16),
focused: false,
blinking: false,
@@ -222,7 +226,11 @@ impl TerminalView {
PtyEvent::Term(event) => self.term_event(event, cx),
PtyEvent::Mouse(mode) => self.mouse = mode,
PtyEvent::Mark(Mark::Done(status)) => cx.emit(TerminalEvent::Finished(status)),
// A new prompt ends what was working, waiting or idle; done and error stay.
PtyEvent::Mark(Mark::Prompt) => self.records.prompt(),
PtyEvent::Mark(_) => {}
PtyEvent::Status(status) => self.status(status),
PtyEvent::Reset => self.records.clear(None),
}
}
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
@@ -232,6 +240,15 @@ impl TerminalView {
cx.notify();
}
/// Keeps a program's report, drops what it clears, and answers its query.
fn status(&mut self, status: Status) {
match status {
Status::Report(id, record) => self.records.report(id, record),
Status::Clear(id) => self.records.clear(id.as_deref()),
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
}
}
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
match event {
Event::Wakeup => self.shown = true,