T04: pty through alacritty's event loop, OSC 7 tap on the reader
This commit is contained in:
7 files changed
+528
No files matched your search
@@ -19,6 +19,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
|
||||
## Rules
|
||||
|
||||
Generated
+4
@@ -1748,13 +1748,17 @@ dependencies = [
|
||||
name = "elyterminal"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"dirs 7.0.0",
|
||||
"ely-gpui-component",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"gpui-pre",
|
||||
"gpui-pre-platform",
|
||||
"log",
|
||||
"polling",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
|
||||
@@ -9,13 +9,17 @@ repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal = { version = "0.26", default-features = false }
|
||||
anyhow = "1"
|
||||
dirs = "7"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
|
||||
env_logger = "0.11"
|
||||
futures = "0.3"
|
||||
gethostname = "1.1.0"
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
log = "0.4"
|
||||
polling = "3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod settings;
|
||||
pub mod term;
|
||||
pub mod themes;
|
||||
@@ -0,0 +1,4 @@
|
||||
mod pty;
|
||||
mod tap;
|
||||
|
||||
pub use pty::{Launch, Listener, Pty, PtyEvent, spawn, window_size};
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener, Notify, OnResize, WindowSize},
|
||||
event_loop::{EventLoop, Msg, Notifier},
|
||||
sync::FairMutex,
|
||||
term::{Config, Term, test::TermSize},
|
||||
tty::{self, Options, Shell},
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::tap::Tapped;
|
||||
|
||||
/// What a session hears from its pty: the grid's events and the folder the shell reports.
|
||||
#[derive(Debug)]
|
||||
pub enum PtyEvent {
|
||||
Term(Event),
|
||||
Folder(PathBuf),
|
||||
}
|
||||
|
||||
/// Hands the grid's events to the session.
|
||||
#[derive(Clone)]
|
||||
pub struct Listener(UnboundedSender<PtyEvent>);
|
||||
|
||||
impl EventListener for Listener {
|
||||
fn send_event(&self, event: Event) {
|
||||
if self.0.unbounded_send(PtyEvent::Term(event)).is_err() {
|
||||
log::debug!("pty: an event after its session closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A program, the folder it starts in, and variables over the app's own.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Launch {
|
||||
/// None runs the login shell.
|
||||
pub program: Option<(String, Vec<String>)>,
|
||||
pub cwd: PathBuf,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A running program on a pseudo-terminal and the grid it draws into.
|
||||
pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub events: UnboundedReceiver<PtyEvent>,
|
||||
notifier: Notifier,
|
||||
}
|
||||
|
||||
/// Starts `launch` on a pty of `size` cells, each `cell` pixels, keeping `scrollback` lines.
|
||||
pub fn spawn(
|
||||
launch: &Launch,
|
||||
size: TermSize,
|
||||
cell: (u16, u16),
|
||||
scrollback: usize,
|
||||
) -> anyhow::Result<Pty> {
|
||||
anyhow::ensure!(
|
||||
launch.cwd.is_dir(),
|
||||
"pty: cannot start in {}: no such folder",
|
||||
launch.cwd.display()
|
||||
);
|
||||
let (sender, events) = unbounded();
|
||||
let listener = Listener(sender.clone());
|
||||
let config = Config {
|
||||
scrolling_history: scrollback,
|
||||
kitty_keyboard: true,
|
||||
..Config::default()
|
||||
};
|
||||
let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone())));
|
||||
let mut env = HashMap::from([
|
||||
("TERM".to_string(), "xterm-256color".to_string()),
|
||||
("COLORTERM".to_string(), "truecolor".to_string()),
|
||||
]);
|
||||
if std::env::var_os("LANG").is_none() {
|
||||
env.insert("LANG".into(), "en_US.UTF-8".into());
|
||||
}
|
||||
env.extend(launch.env.iter().cloned());
|
||||
let options = Options {
|
||||
shell: launch
|
||||
.program
|
||||
.clone()
|
||||
.map(|(program, args)| Shell::new(program, args)),
|
||||
working_directory: Some(launch.cwd.clone()),
|
||||
drain_on_exit: true,
|
||||
env,
|
||||
#[cfg(windows)]
|
||||
escape_args: true,
|
||||
};
|
||||
let pty = tty::new(&options, window_size(&size, cell), 0)?;
|
||||
log::info!(
|
||||
"pty: started {:?} in {}, child {}",
|
||||
launch.program,
|
||||
launch.cwd.display(),
|
||||
child_id(&pty)
|
||||
);
|
||||
let event_loop = EventLoop::new(
|
||||
term.clone(),
|
||||
listener,
|
||||
Tapped::new(pty, sender),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
let notifier = Notifier(event_loop.channel());
|
||||
event_loop.spawn();
|
||||
Ok(Pty {
|
||||
term,
|
||||
events,
|
||||
notifier,
|
||||
})
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
/// Sends bytes to the program, as typing does.
|
||||
pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
|
||||
self.notifier.notify(bytes);
|
||||
}
|
||||
|
||||
/// Fits the grid and tells the program its new size.
|
||||
pub fn resize(&mut self, size: TermSize, cell: (u16, u16)) {
|
||||
self.notifier.on_resize(window_size(&size, cell));
|
||||
self.term.lock().resize(size);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
if self.notifier.0.send(Msg::Shutdown).is_err() {
|
||||
log::debug!("pty: the event loop had already stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn child_id(pty: &tty::Pty) -> String {
|
||||
pty.child().id().to_string()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn child_id(pty: &tty::Pty) -> String {
|
||||
pty.child_watcher()
|
||||
.pid()
|
||||
.map_or_else(|| "without an id".into(), |pid| pid.to_string())
|
||||
}
|
||||
|
||||
/// A pty's size; counts past what the kernel holds are cut and logged.
|
||||
pub fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize {
|
||||
let cells = |count: usize, side: u16| {
|
||||
let most = (i16::MAX as u16).min(u16::MAX / side.max(1));
|
||||
let kept = u16::try_from(count).unwrap_or(u16::MAX).min(most);
|
||||
if usize::from(kept) < count {
|
||||
log::error!("pty: {count} cells of {side} px pass what a pty holds; {kept} kept");
|
||||
}
|
||||
kept
|
||||
};
|
||||
WindowSize {
|
||||
num_lines: cells(size.screen_lines, cell.1),
|
||||
num_cols: cells(size.columns, cell.0),
|
||||
cell_width: cell.0,
|
||||
cell_height: cell.1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use alacritty_terminal::index::{Column, Line};
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn run(script: &str) -> (Vec<PathBuf>, String, bool) {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
};
|
||||
let mut pty = spawn(&launch, TermSize::new(40, 4), (8, 16), 100).expect("sh starts");
|
||||
let (folders, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut success) = (Vec::new(), false);
|
||||
while let Some(event) = pty.events.next().await {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..20).map(|column| row[Column(column)].c).collect();
|
||||
(folders, text.trim_end().to_string(), success)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_prints_into_the_grid_with_its_variables() {
|
||||
let (folders, text, success) = run("printf 'ely %s' \"$ELY\"");
|
||||
assert!(folders.is_empty());
|
||||
assert_eq!(text, "ely ok");
|
||||
assert!(success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_reaches_the_session_as_a_folder() {
|
||||
let (folders, text, _) = run(
|
||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||
);
|
||||
assert_eq!(folders, [PathBuf::from("/tmp/a b")]);
|
||||
assert_eq!(text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
program: None,
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
};
|
||||
let error = spawn(&launch, TermSize::new(20, 4), (8, 16), 100)
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_cells_than_a_pty_holds_cap_at_its_most() {
|
||||
let size = window_size(&TermSize::new(70_000, 24), (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||
}
|
||||
}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
use std::{io, path::PathBuf, sync::Arc};
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{OnResize, WindowSize},
|
||||
tty::{ChildEvent, EventedPty, EventedReadWrite},
|
||||
};
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use polling::{Event, PollMode, Poller};
|
||||
|
||||
use super::pty::PtyEvent;
|
||||
|
||||
/// The longest OSC body kept; longer ones are dropped whole.
|
||||
const MOST: usize = 4096;
|
||||
|
||||
/// A pty whose reader hands each byte to an OSC 7 scanner before the parser sees it.
|
||||
pub(crate) struct Tapped<P> {
|
||||
pub inner: P,
|
||||
scanner: Osc7,
|
||||
events: UnboundedSender<PtyEvent>,
|
||||
}
|
||||
|
||||
impl<P> Tapped<P> {
|
||||
pub fn new(inner: P, events: UnboundedSender<PtyEvent>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
scanner: Osc7::default(),
|
||||
events,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let count = self.inner.reader().read(buf)?;
|
||||
let events = &self.events;
|
||||
self.scanner.feed(&buf[..count], &mut |body| {
|
||||
if let Some(folder) = folder(body)
|
||||
&& events.unbounded_send(PtyEvent::Folder(folder)).is_err()
|
||||
{
|
||||
log::debug!("pty: a folder report after its session closed");
|
||||
}
|
||||
});
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
|
||||
type Reader = Self;
|
||||
type Writer = P::Writer;
|
||||
|
||||
unsafe fn register(
|
||||
&mut self,
|
||||
poll: &Arc<Poller>,
|
||||
event: Event,
|
||||
mode: PollMode,
|
||||
) -> io::Result<()> {
|
||||
unsafe { self.inner.register(poll, event, mode) }
|
||||
}
|
||||
|
||||
fn reregister(&mut self, poll: &Arc<Poller>, event: Event, mode: PollMode) -> io::Result<()> {
|
||||
self.inner.reregister(poll, event, mode)
|
||||
}
|
||||
|
||||
fn deregister(&mut self, poll: &Arc<Poller>) -> io::Result<()> {
|
||||
self.inner.deregister(poll)
|
||||
}
|
||||
|
||||
fn reader(&mut self) -> &mut Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> &mut P::Writer {
|
||||
self.inner.writer()
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: EventedPty> EventedPty for Tapped<P> {
|
||||
fn next_child_event(&mut self) -> Option<ChildEvent> {
|
||||
self.inner.next_child_event()
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: OnResize> OnResize for Tapped<P> {
|
||||
fn on_resize(&mut self, size: WindowSize) {
|
||||
self.inner.on_resize(size);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum State {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
Osc,
|
||||
OscEscape,
|
||||
}
|
||||
|
||||
/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Osc7 {
|
||||
state: State,
|
||||
body: Vec<u8>,
|
||||
overflow: bool,
|
||||
}
|
||||
|
||||
impl Osc7 {
|
||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == State::Ground {
|
||||
match rest.iter().position(|&byte| byte == 0x1b) {
|
||||
Some(at) => {
|
||||
self.state = State::Escape;
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let byte = rest[0];
|
||||
rest = &rest[1..];
|
||||
self.state = match (self.state, byte) {
|
||||
(State::Escape, b']') => {
|
||||
self.body.clear();
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
(State::Escape, 0x1b) => State::Escape,
|
||||
(State::Escape, _) => State::Ground,
|
||||
(State::Osc, 0x07) | (State::OscEscape, b'\\') => {
|
||||
self.finish(found);
|
||||
State::Ground
|
||||
}
|
||||
(State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground,
|
||||
(State::Osc, 0x1b) => State::OscEscape,
|
||||
(State::OscEscape, b']') => {
|
||||
self.body.clear();
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
(State::OscEscape, 0x1b) => State::Escape,
|
||||
(State::OscEscape, _) => State::Ground,
|
||||
(State::Osc, byte) => {
|
||||
if self.body.len() < MOST {
|
||||
self.body.push(byte);
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
State::Osc
|
||||
}
|
||||
(State::Ground, _) => unreachable!("ground scans by position"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
|
||||
if self.overflow {
|
||||
log::warn!("pty: an OSC over {MOST} bytes dropped");
|
||||
} else if let Some(body) = self.body.strip_prefix(b"7;") {
|
||||
found(body);
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
|
||||
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
|
||||
let Some(rest) = body.strip_prefix(b"file://") else {
|
||||
log::warn!(
|
||||
"pty: OSC 7 without file:// ({})",
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let slash = rest.iter().position(|&byte| byte == b'/')?;
|
||||
let host = String::from_utf8_lossy(&rest[..slash]);
|
||||
let local = gethostname::gethostname();
|
||||
if !(host.is_empty()
|
||||
|| host.eq_ignore_ascii_case("localhost")
|
||||
|| local
|
||||
.to_str()
|
||||
.is_some_and(|name| host.eq_ignore_ascii_case(name)))
|
||||
{
|
||||
log::debug!("pty: OSC 7 from host {host}, not this one");
|
||||
return None;
|
||||
}
|
||||
let path = match decode(&rest[slash..]) {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
log::warn!("pty: OSC 7 path is not UTF-8 after decoding");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let drive = path.as_bytes().get(1..3).is_some_and(|two| two[1] == b':')
|
||||
&& path.as_bytes()[1].is_ascii_alphabetic();
|
||||
Some(PathBuf::from(if cfg!(windows) && drive {
|
||||
&path[1..]
|
||||
} else {
|
||||
&path[..]
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<String> {
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
let hex = bytes
|
||||
.get(at + 1..at + 3)
|
||||
.and_then(|two| std::str::from_utf8(two).ok())
|
||||
.and_then(|two| u8::from_str_radix(two, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(value)) => {
|
||||
out.push(value);
|
||||
at += 3;
|
||||
}
|
||||
(byte, _) => {
|
||||
out.push(byte);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
let mut scanner = Osc7::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"))
|
||||
});
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
|
||||
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_and_cancels_pass() {
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_body_is_dropped() {
|
||||
let mut long = b"\x1b]7;".to_vec();
|
||||
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
||||
long.push(0x07);
|
||||
assert!(scan(&[&long]).is_empty());
|
||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_uri_gives_its_decoded_folder() {
|
||||
assert_eq!(
|
||||
folder(b"file:///tmp/ely%20dir"),
|
||||
Some(PathBuf::from("/tmp/ely dir"))
|
||||
);
|
||||
assert_eq!(folder(b"file://localhost/x"), Some(PathBuf::from("/x")));
|
||||
let host = gethostname::gethostname().into_string().expect("host");
|
||||
let named = format!("file://{host}/y");
|
||||
assert_eq!(folder(named.as_bytes()), Some(PathBuf::from("/y")));
|
||||
assert_eq!(
|
||||
folder("file:///%E4%B8%AD".as_bytes()),
|
||||
Some(PathBuf::from("/中"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_hosts_and_bad_uris_give_none() {
|
||||
assert_eq!(folder(b"file://elsewhere.invalid/tmp"), None);
|
||||
assert_eq!(folder(b"http://x/y"), None);
|
||||
assert_eq!(folder(b"file:///%FF"), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user