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
This commit is contained in:
12 files changed
+755
-250
No files matched your search
@@ -15,6 +15,7 @@ mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
pub mod shell;
|
||||
mod status;
|
||||
mod tap;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
|
||||
+6
-220
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user