use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use anyhow::Result;
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use crate::event::AppEvent;
use crate::ids::PaneId;
use crate::terminal::vt::alacritty::AlacrittyEngine;
use crate::terminal::vt::VtEngine;
#[derive(Clone, Copy, Default)]
pub struct MouseModes {
pub report: bool,
pub drag: bool,
pub motion: bool,
pub sgr: bool,
}
pub struct Pane {
pub engine: Arc<Mutex<dyn VtEngine>>,
master: Box<dyn MasterPty + Send>,
input_tx: Sender<Vec<u8>>,
pub cwd: PathBuf,
pub command: String,
pub child_pid: Option<u32>,
data_pending: Arc<std::sync::atomic::AtomicBool>,
child_exited: Arc<std::sync::atomic::AtomicBool>,
size: (u16, u16),
}
impl Drop for Pane {
fn drop(&mut self) {
if self.child_exited.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
let Some(pid) = self.child_pid else { return };
#[cfg(unix)]
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGHUP);
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
}
impl Pane {
#[allow(clippy::too_many_arguments)]
pub fn spawn(
id: PaneId,
cols: u16,
rows: u16,
cwd: PathBuf,
app_tx: Sender<AppEvent>,
initial: Option<&str>,
shell: &str,
scrollback: usize,
) -> Result<Pane> {
let cmd = CommandBuilder::new(shell);
Self::build(
id,
cols,
rows,
cwd,
app_tx,
initial,
cmd,
basename(shell),
&[],
scrollback,
)
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_shell_with(
id: PaneId,
cols: u16,
rows: u16,
cwd: PathBuf,
app_tx: Sender<AppEvent>,
initial: Option<&str>,
shell: &str,
argv: &[String],
scrollback: usize,
) -> Result<Pane> {
let Some((program, args)) = argv.split_first() else {
return Err(anyhow::anyhow!("empty shell command"));
};
let mut cmd = CommandBuilder::new(program);
for a in args {
cmd.arg(a);
}
Self::build(
id,
cols,
rows,
cwd,
app_tx,
initial,
cmd,
basename(shell),
&[],
scrollback,
)
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_command(
id: PaneId,
cols: u16,
rows: u16,
cwd: PathBuf,
app_tx: Sender<AppEvent>,
argv: &[String],
env: &[(String, String)],
scrollback: usize,
) -> Result<Pane> {
let Some((program, args)) = argv.split_first() else {
return Err(anyhow::anyhow!("empty module command"));
};
let mut cmd = CommandBuilder::new(program);
for a in args {
cmd.arg(a);
}
Self::build(
id,
cols,
rows,
cwd,
app_tx,
None,
cmd,
basename(program),
env,
scrollback,
)
}
#[allow(clippy::too_many_arguments)]
fn build(
id: PaneId,
cols: u16,
rows: u16,
cwd: PathBuf,
app_tx: Sender<AppEvent>,
initial: Option<&str>,
mut cmd: CommandBuilder,
command: String,
extra_env: &[(String, String)],
scrollback: usize,
) -> Result<Pane> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: rows.max(1),
cols: cols.max(1),
pixel_width: 0,
pixel_height: 0,
})?;
cmd.cwd(&cwd);
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
cmd.env("BOHAY_ENV", "1");
cmd.env("BOHAY_PANE_ID", id.0.to_string());
if let Some(sock) = crate::ipc::api::socket_path_env() {
cmd.env("BOHAY_SOCKET_PATH", sock);
}
let child = pair.slave.spawn_command(cmd)?;
let child_pid = child.process_id();
drop(pair.slave);
let (input_tx, input_rx) = mpsc::channel::<Vec<u8>>();
let engine: Arc<Mutex<dyn VtEngine>> = Arc::new(Mutex::new(AlacrittyEngine::new(
cols,
rows,
input_tx.clone(),
scrollback,
)));
if let Some(screen) = initial {
if let Ok(mut e) = engine.lock() {
e.advance(screen.as_bytes());
}
}
let mut writer = pair.master.take_writer()?;
thread::spawn(move || {
while let Ok(bytes) = input_rx.recv() {
if writer.write_all(&bytes).is_err() {
break;
}
let _ = writer.flush();
}
});
let reader = pair.master.try_clone_reader()?;
let eng = engine.clone();
let tx = app_tx.clone();
let data_pending = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pending = data_pending.clone();
thread::spawn(move || read_loop(id, reader, eng, tx, pending));
let child_exited = Arc::new(std::sync::atomic::AtomicBool::new(false));
let exited = child_exited.clone();
thread::spawn(move || {
let mut child = child;
let _ = child.wait();
exited.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = app_tx.send(AppEvent::PtyExit(id));
});
Ok(Pane {
engine,
child_pid,
master: pair.master,
input_tx,
cwd,
command,
data_pending,
child_exited,
size: (cols, rows),
})
}
pub fn take_data_pending(&self) -> bool {
self.data_pending
.swap(false, std::sync::atomic::Ordering::AcqRel)
}
pub fn send(&self, bytes: &[u8]) {
let _ = self.input_tx.send(bytes.to_vec());
}
pub fn set_scrollback(&self, lines: usize) {
if let Ok(mut e) = self.engine.lock() {
e.set_scrollback(lines);
}
}
pub fn scroll(&self, delta: i32) {
if let Ok(mut e) = self.engine.lock() {
e.scroll(delta);
}
}
pub fn scroll_to_top(&self) {
if let Ok(mut e) = self.engine.lock() {
e.scroll_to_top();
}
}
pub fn scroll_to_bottom(&self) {
if let Ok(mut e) = self.engine.lock() {
e.scroll_to_bottom();
}
}
pub fn scroll_state(&self) -> (usize, usize) {
self.engine
.lock()
.map(|e| (e.scroll_offset(), e.history_len()))
.unwrap_or((0, 0))
}
pub fn alt_screen(&self) -> bool {
self.engine.lock().map(|e| e.alt_screen()).unwrap_or(false)
}
pub fn mouse_mode(&self) -> MouseModes {
self.engine
.lock()
.map(|e| MouseModes {
report: e.mouse_report(),
drag: e.mouse_drag(),
motion: e.mouse_motion(),
sgr: e.sgr_mouse(),
})
.unwrap_or_default()
}
pub fn send_paste(&self, text: &str) {
let bracketed = self
.engine
.lock()
.map(|e| e.bracketed_paste())
.unwrap_or(false);
self.send(&wrap_paste(text, bracketed));
}
pub fn resize(&mut self, cols: u16, rows: u16) {
if cols == 0 || rows == 0 || (cols, rows) == self.size {
return;
}
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
if let Ok(mut e) = self.engine.lock() {
e.resize(cols, rows);
}
self.size = (cols, rows);
}
}
fn wrap_paste(text: &str, bracketed: bool) -> Vec<u8> {
if !bracketed {
return text.as_bytes().to_vec();
}
let mut out = Vec::with_capacity(text.len() + 12);
out.extend_from_slice(b"\x1b[200~");
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(b"\x1b[201~");
out
}
fn basename(s: &str) -> String {
std::path::Path::new(s)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(s)
.to_string()
}
fn read_loop(
id: PaneId,
mut reader: Box<dyn Read + Send>,
engine: Arc<Mutex<dyn VtEngine>>,
tx: Sender<AppEvent>,
data_pending: Arc<std::sync::atomic::AtomicBool>,
) {
use std::sync::atomic::Ordering;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => {
let _ = tx.send(AppEvent::PtyExit(id));
break;
}
Ok(n) => {
if let Ok(mut e) = engine.lock() {
e.advance(&buf[..n]);
}
if !data_pending.swap(true, Ordering::AcqRel)
&& tx.send(AppEvent::PtyData(id)).is_err()
{
break;
}
}
}
}
}
#[cfg(all(test, unix))]
mod reap_tests {
use super::*;
use crate::ids::PaneId;
fn alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
fn wait_gone(pid: u32) -> bool {
for _ in 0..40 {
if !alive(pid) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
false
}
fn spawn_sh() -> Pane {
let (tx, rx) = mpsc::channel();
std::mem::forget(rx); Pane::spawn(
PaneId::alloc(),
80,
24,
std::env::temp_dir(),
tx,
None,
"/bin/sh",
500,
)
.expect("spawn")
}
#[test]
fn dropping_a_pane_reaps_its_child() {
let pane = spawn_sh();
let pid = pane.child_pid.expect("pid");
assert!(alive(pid), "child runs while the pane is open");
drop(pane);
assert!(wait_gone(pid), "LEAK: child {pid} survived the pane");
}
#[test]
fn closing_one_pane_leaves_the_others_running() {
let keep = spawn_sh();
let kept_pid = keep.child_pid.expect("pid");
let doomed = spawn_sh();
let doomed_pid = doomed.child_pid.expect("pid");
drop(doomed);
assert!(wait_gone(doomed_pid), "the closed pane's child exited");
assert!(
alive(kept_pid),
"the surviving pane's child is untouched by its neighbour closing"
);
drop(keep);
}
}
#[cfg(test)]
mod tests {
use super::wrap_paste;
#[test]
fn paste_is_bracketed_only_when_the_child_asked() {
let path = "/Users/riz/shot.png";
assert_eq!(
wrap_paste(path, true),
format!("\x1b[200~{path}\x1b[201~").into_bytes(),
"wrapped when the child enabled bracketed paste"
);
assert_eq!(
wrap_paste(path, false),
path.as_bytes(),
"sent bare when it did not, so a plain shell is unaffected"
);
}
}