use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub const SCROLLBACK_LINES: usize = 1000;
const MIN_ROWS: u16 = 3;
const MIN_COLS: u16 = 10;
pub struct TerminalPanelState {
pub session: Option<TerminalSession>,
pub visible: bool,
pub height: u16,
}
impl Default for TerminalPanelState {
fn default() -> Self {
Self { session: None, visible: false, height: 12 }
}
}
pub struct TerminalSession {
parser: Arc<Mutex<vt100::Parser>>,
master: Box<dyn portable_pty::MasterPty + Send>,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
exited: Arc<AtomicBool>,
generation: Arc<AtomicU64>,
exit_status: Option<portable_pty::ExitStatus>,
size: (u16, u16),
pub repo_label: String,
}
impl TerminalSession {
pub fn spawn(
cwd: &std::path::Path,
repo_label: String,
rows: u16,
cols: u16,
) -> Result<Self, String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut cmd = portable_pty::CommandBuilder::new(&shell);
cmd.cwd(cwd);
cmd.env("GITWIG", "1");
cmd.env("GITWIG_SHELL", "1");
cmd.env("TERM", "xterm-256color");
Self::spawn_command(cmd, repo_label, rows, cols)
}
pub fn spawn_command(
cmd: portable_pty::CommandBuilder,
repo_label: String,
rows: u16,
cols: u16,
) -> Result<Self, String> {
let rows = rows.max(MIN_ROWS);
let cols = cols.max(MIN_COLS);
let pty_system = portable_pty::native_pty_system();
let pair = pty_system
.openpty(portable_pty::PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("Could not open PTY: {}", e))?;
let child =
pair.slave.spawn_command(cmd).map_err(|e| format!("Could not start shell: {}", e))?;
drop(pair.slave);
let writer =
pair.master.take_writer().map_err(|e| format!("Could not open PTY writer: {}", e))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Could not open PTY reader: {}", e))?;
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_LINES)));
let exited = Arc::new(AtomicBool::new(false));
let generation = Arc::new(AtomicU64::new(0));
spawn_reader(reader, Arc::clone(&parser), Arc::clone(&exited), Arc::clone(&generation));
Ok(Self {
parser,
master: pair.master,
writer,
child,
exited,
generation,
exit_status: None,
size: (rows, cols),
repo_label,
})
}
pub fn lock_parser(&self) -> MutexGuard<'_, vt100::Parser> {
self.parser.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(MIN_ROWS);
let cols = cols.max(MIN_COLS);
if (rows, cols) == self.size {
return;
}
self.size = (rows, cols);
let _ = self.master.resize(portable_pty::PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
self.lock_parser().screen_mut().set_size(rows, cols);
self.bump_generation();
}
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::Relaxed)
}
fn bump_generation(&self) {
self.generation.fetch_add(1, Ordering::Relaxed);
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
}
impl TerminalSession {
pub fn poll_exit(&mut self) -> bool {
if !self.exited() || self.exit_status.is_some() {
return false;
}
match self.child.try_wait() {
Ok(Some(status)) => {
self.exit_status = Some(status);
true
}
Ok(None) => false,
Err(_) => {
self.exit_status = Some(portable_pty::ExitStatus::with_exit_code(1));
true
}
}
}
pub fn exit_code(&self) -> Option<u32> {
self.exit_status.as_ref().map(portable_pty::ExitStatus::exit_code)
}
pub fn send_bytes(&mut self, bytes: &[u8]) {
if self.exited() {
return;
}
use std::io::Write;
if self.writer.write_all(bytes).and_then(|_| self.writer.flush()).is_err() {
self.exited.store(true, Ordering::Relaxed);
}
}
pub fn send_key(&mut self, key: KeyEvent) {
let app_cursor = self.lock_parser().screen().application_cursor();
if let Some(bytes) = encode_key(key, app_cursor) {
self.lock_parser().screen_mut().set_scrollback(0);
self.bump_generation();
self.send_bytes(&bytes);
}
}
pub fn send_paste(&mut self, text: &str) {
let sanitized = text.replace("\x1b[201~", "");
let bracketed = self.lock_parser().screen().bracketed_paste();
self.lock_parser().screen_mut().set_scrollback(0);
self.bump_generation();
if bracketed {
self.send_bytes(b"\x1b[200~");
let payload = sanitized.into_bytes();
self.send_bytes(&payload);
self.send_bytes(b"\x1b[201~");
} else {
self.send_bytes(sanitized.as_bytes());
}
}
pub fn scroll_by(&mut self, delta: isize) {
let mut guard = self.lock_parser();
let screen = guard.screen_mut();
let current = screen.scrollback();
let next = if delta >= 0 {
current.saturating_add(delta as usize)
} else {
current.saturating_sub(delta.unsigned_abs())
};
screen.set_scrollback(next);
drop(guard);
self.bump_generation();
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
if !self.exited() && self.exit_status.is_none() {
let _ = self.child.kill();
}
let _ = self.child.wait();
}
}
fn spawn_reader(
mut reader: Box<dyn std::io::Read + Send>,
parser: Arc<Mutex<vt100::Parser>>,
exited: Arc<AtomicBool>,
generation: Arc<AtomicU64>,
) {
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
parser.lock().unwrap_or_else(PoisonError::into_inner).process(&buf[..n]);
generation.fetch_add(1, Ordering::Relaxed);
}
}
}
exited.store(true, Ordering::Relaxed);
generation.fetch_add(1, Ordering::Relaxed);
});
}
pub fn encode_key(key: KeyEvent, application_cursor: bool) -> Option<Vec<u8>> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let mod_param = 1 + u8::from(shift) + 2 * u8::from(alt) + 4 * u8::from(ctrl);
let csi_modified = |c: u8| format!("\x1b[1;{}{}", mod_param, c as char).into_bytes();
let tilde = |n: u8| {
if mod_param > 1 {
format!("\x1b[{};{}~", n, mod_param).into_bytes()
} else {
format!("\x1b[{}~", n).into_bytes()
}
};
let arrow = |c: u8| {
if mod_param > 1 {
csi_modified(c)
} else if application_cursor {
vec![0x1b, b'O', c]
} else {
vec![0x1b, b'[', c]
}
};
let bytes = match key.code {
KeyCode::Char(c) => {
let mut out = Vec::new();
if alt {
out.push(0x1b);
}
if ctrl {
out.push(ctrl_byte(c)?);
} else {
let mut utf8 = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes());
}
out
}
KeyCode::Enter => {
if alt {
vec![0x1b, b'\r']
} else {
vec![b'\r']
}
}
KeyCode::Tab => vec![b'\t'],
KeyCode::BackTab => b"\x1b[Z".to_vec(),
KeyCode::Backspace => {
let base = if ctrl { 0x08 } else { 0x7f };
if alt { vec![0x1b, base] } else { vec![base] }
}
KeyCode::Esc => vec![0x1b],
KeyCode::Up => arrow(b'A'),
KeyCode::Down => arrow(b'B'),
KeyCode::Right => arrow(b'C'),
KeyCode::Left => arrow(b'D'),
KeyCode::Home => {
if mod_param > 1 {
csi_modified(b'H')
} else if application_cursor {
b"\x1bOH".to_vec()
} else {
b"\x1b[H".to_vec()
}
}
KeyCode::End => {
if mod_param > 1 {
csi_modified(b'F')
} else if application_cursor {
b"\x1bOF".to_vec()
} else {
b"\x1b[F".to_vec()
}
}
KeyCode::Insert => tilde(2),
KeyCode::Delete => tilde(3),
KeyCode::PageUp => tilde(5),
KeyCode::PageDown => tilde(6),
KeyCode::F(n @ 1..=4) => vec![0x1b, b'O', b'P' + (n - 1)],
KeyCode::F(n @ 5..=12) => {
let code = match n {
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
_ => 24,
};
tilde(code)
}
_ => return None,
};
Some(bytes)
}
fn ctrl_byte(c: char) -> Option<u8> {
match c {
'a'..='z' => Some(c as u8 & 0x1f),
'A'..='Z' => Some(c.to_ascii_lowercase() as u8 & 0x1f),
' ' | '@' => Some(0x00),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' => Some(0x1f),
'?' => Some(0x7f),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn enc(code: KeyCode) -> Vec<u8> {
encode_key(key(code), false).expect("mapped key")
}
#[test]
fn encodes_plain_chars_and_controls() {
assert_eq!(enc(KeyCode::Char('a')), b"a");
assert_eq!(enc(KeyCode::Char('é')), "é".as_bytes());
assert_eq!(enc(KeyCode::Enter), b"\r");
assert_eq!(enc(KeyCode::Tab), b"\t");
assert_eq!(enc(KeyCode::BackTab), b"\x1b[Z");
assert_eq!(enc(KeyCode::Backspace), &[0x7f]);
assert_eq!(enc(KeyCode::Esc), &[0x1b]);
}
#[test]
fn encodes_ctrl_and_alt_chars() {
let c = |ch| encode_key(key_mod(KeyCode::Char(ch), KeyModifiers::CONTROL), false);
assert_eq!(c('c'), Some(vec![0x03]));
assert_eq!(c('d'), Some(vec![0x04]));
assert_eq!(c('z'), Some(vec![0x1a]));
assert_eq!(c(' '), Some(vec![0x00]));
assert_eq!(c('?'), Some(vec![0x7f]));
assert_eq!(
encode_key(key_mod(KeyCode::Char('x'), KeyModifiers::ALT), false),
Some(b"\x1bx".to_vec())
);
assert_eq!(
encode_key(
key_mod(KeyCode::Char('c'), KeyModifiers::ALT | KeyModifiers::CONTROL),
false
),
Some(vec![0x1b, 0x03])
);
}
#[test]
fn encodes_arrows_and_navigation() {
assert_eq!(enc(KeyCode::Up), b"\x1b[A");
assert_eq!(enc(KeyCode::Left), b"\x1b[D");
assert_eq!(encode_key(key(KeyCode::Up), true), Some(b"\x1bOA".to_vec()));
assert_eq!(encode_key(key(KeyCode::Home), true), Some(b"\x1bOH".to_vec()));
assert_eq!(enc(KeyCode::Home), b"\x1b[H");
assert_eq!(enc(KeyCode::End), b"\x1b[F");
assert_eq!(enc(KeyCode::PageUp), b"\x1b[5~");
assert_eq!(enc(KeyCode::PageDown), b"\x1b[6~");
assert_eq!(enc(KeyCode::Insert), b"\x1b[2~");
assert_eq!(enc(KeyCode::Delete), b"\x1b[3~");
}
#[test]
fn encodes_modified_arrows_with_xterm_parameter() {
assert_eq!(
encode_key(key_mod(KeyCode::Up, KeyModifiers::SHIFT), false),
Some(b"\x1b[1;2A".to_vec())
);
assert_eq!(
encode_key(key_mod(KeyCode::Right, KeyModifiers::CONTROL), false),
Some(b"\x1b[1;5C".to_vec())
);
assert_eq!(
encode_key(
key_mod(
KeyCode::Left,
KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::CONTROL
),
false
),
Some(b"\x1b[1;8D".to_vec())
);
assert_eq!(
encode_key(key_mod(KeyCode::Delete, KeyModifiers::SHIFT), false),
Some(b"\x1b[3;2~".to_vec())
);
}
#[test]
fn encodes_function_keys() {
assert_eq!(enc(KeyCode::F(1)), b"\x1bOP");
assert_eq!(enc(KeyCode::F(4)), b"\x1bOS");
assert_eq!(enc(KeyCode::F(5)), b"\x1b[15~");
assert_eq!(enc(KeyCode::F(12)), b"\x1b[24~");
}
#[test]
fn unmapped_keys_are_swallowed() {
assert_eq!(encode_key(key(KeyCode::CapsLock), false), None);
assert_eq!(encode_key(key(KeyCode::F(20)), false), None);
}
#[cfg(unix)]
#[test]
fn pty_output_bumps_generation_and_quiet_shell_holds_it() {
let mut cmd = portable_pty::CommandBuilder::new("/bin/sh");
cmd.args(["-c", "printf gitwig_gen_test; sleep 5"]);
let mut session = TerminalSession::spawn_command(cmd, "test".to_string(), 10, 60)
.expect("spawn PTY session");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
let contents = session.lock_parser().screen().contents();
if contents.contains("gitwig_gen_test") {
break;
}
assert!(std::time::Instant::now() < deadline, "output never arrived: {contents:?}");
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(session.generation() > 0, "reader output did not bump the generation");
std::thread::sleep(std::time::Duration::from_millis(250));
let settled = session.generation();
std::thread::sleep(std::time::Duration::from_millis(300));
assert_eq!(session.generation(), settled, "generation moved with no PTY output");
session.scroll_by(1);
assert!(session.generation() > settled, "scroll_by did not bump the generation");
}
#[cfg(unix)]
#[test]
fn pty_session_runs_command_and_exits() {
let mut cmd = portable_pty::CommandBuilder::new("/bin/sh");
cmd.args(["-c", "printf gitwig_pty_hello"]);
let mut session = TerminalSession::spawn_command(cmd, "test".to_string(), 10, 60)
.expect("spawn PTY session");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
let contents = session.lock_parser().screen().contents();
if contents.contains("gitwig_pty_hello") {
break;
}
assert!(std::time::Instant::now() < deadline, "output never arrived: {contents:?}");
std::thread::sleep(std::time::Duration::from_millis(25));
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
session.poll_exit();
if session.exited() {
break;
}
assert!(std::time::Instant::now() < deadline, "shell never exited");
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
}