use std::io::Read;
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect, Size},
style::{Modifier as RatModifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget, Wrap},
};
use tui_term::widget::PseudoTerminal;
use super::{Component, EventResult, RenderableComponent};
use crate::theme::UiColors;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionAction {
Close,
}
pub struct ExecutionState {
pub command_display: String,
pub parser: Arc<RwLock<vt100::Parser>>,
pub pty_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
pub pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
pub exited: Arc<AtomicBool>,
pub exit_status: Arc<Mutex<Option<String>>>,
}
pub struct ExecutionComponent {
state: ExecutionState,
}
impl ExecutionComponent {
pub fn new(state: ExecutionState) -> Self {
Self { state }
}
pub fn spawn(
command_display: String,
parts: &[String],
terminal_size: Size,
) -> color_eyre::Result<Self> {
if parts.is_empty() {
return Err(color_eyre::eyre::eyre!("No command to execute"));
}
let mut cmd = CommandBuilder::new(&parts[0]);
for arg in &parts[1..] {
cmd.arg(arg);
}
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
let pty_size = Self::pty_size_for_terminal(terminal_size);
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(pty_size)
.map_err(|e| color_eyre::eyre::eyre!("Failed to open PTY: {}", e))?;
let parser = Arc::new(RwLock::new(vt100::Parser::new(
pty_size.rows,
pty_size.cols,
0,
)));
let exited = Arc::new(AtomicBool::new(false));
let exit_status: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let child_result = pair.slave.spawn_command(cmd);
drop(pair.slave);
let mut child = child_result.map_err(|e| {
color_eyre::eyre::eyre!("Failed to spawn command '{}': {}", parts[0], e)
})?;
{
let exited = exited.clone();
let exit_status = exit_status.clone();
std::thread::spawn(move || {
match child.wait() {
Ok(status) => {
if let Ok(mut s) = exit_status.lock() {
*s = Some(format!("{}", status));
}
}
Err(e) => {
if let Ok(mut s) = exit_status.lock() {
*s = Some(format!("error: {}", e));
}
}
}
exited.store(true, Ordering::Relaxed);
});
}
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| color_eyre::eyre::eyre!("Failed to clone PTY reader: {}", e))?;
{
let parser = parser.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(size) => {
if let Ok(mut p) = parser.write() {
p.process(&buf[..size]);
}
}
Err(_) => break,
}
}
});
}
let writer = pair
.master
.take_writer()
.map_err(|e| color_eyre::eyre::eyre!("Failed to take PTY writer: {}", e))?;
let pty_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>> =
Arc::new(Mutex::new(Some(writer)));
let pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>> =
Arc::new(Mutex::new(Some(pair.master)));
{
let exited = exited.clone();
let pty_writer = pty_writer.clone();
let pty_master = pty_master.clone();
std::thread::spawn(move || {
while !exited.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(50));
}
std::thread::sleep(Duration::from_millis(100));
if let Ok(mut w) = pty_writer.lock() {
*w = None;
}
if let Ok(mut m) = pty_master.lock() {
*m = None;
}
});
}
Ok(Self::new(ExecutionState {
command_display,
parser,
pty_writer,
pty_master,
exited,
exit_status,
}))
}
pub fn resize_to_terminal(&self, terminal_size: Size) {
let pty_size = Self::pty_size_for_terminal(terminal_size);
self.resize_pty(pty_size.rows, pty_size.cols);
}
fn pty_size_for_terminal(terminal_size: Size) -> PtySize {
PtySize {
rows: terminal_size.height.saturating_sub(4).max(4),
cols: terminal_size.width.max(20),
pixel_width: 0,
pixel_height: 0,
}
}
pub fn exited(&self) -> bool {
self.state.exited.load(Ordering::Relaxed)
}
pub fn exit_status(&self) -> Option<String> {
self.state
.exit_status
.lock()
.ok()
.and_then(|s| s.clone())
}
pub fn write_to_pty(&self, data: &[u8]) {
if let Ok(mut writer_guard) = self.state.pty_writer.lock() {
if let Some(ref mut writer) = *writer_guard {
let _ = writer.write_all(data);
let _ = writer.flush();
}
}
}
pub fn resize_pty(&self, rows: u16, cols: u16) {
if let Ok(mut master_guard) = self.state.pty_master.lock() {
if let Some(ref mut master) = *master_guard {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
if let Ok(mut parser_guard) = self.state.parser.write() {
parser_guard.screen_mut().set_size(rows, cols);
}
}
fn forward_key_to_pty(&self, key: KeyEvent) {
let bytes: Option<Vec<u8>> = match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'c' => return self.write_to_pty(b"\x03"),
'd' => return self.write_to_pty(b"\x04"),
_ => {}
}
}
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
Some(s.as_bytes().to_vec())
}
KeyCode::Enter => Some(b"\r".to_vec()),
KeyCode::Backspace => Some(b"\x7f".to_vec()),
KeyCode::Tab => Some(b"\t".to_vec()),
KeyCode::Esc => Some(b"\x1b".to_vec()),
KeyCode::Up => Some(b"\x1b[A".to_vec()),
KeyCode::Down => Some(b"\x1b[B".to_vec()),
KeyCode::Right => Some(b"\x1b[C".to_vec()),
KeyCode::Left => Some(b"\x1b[D".to_vec()),
KeyCode::Home => Some(b"\x1b[H".to_vec()),
KeyCode::End => Some(b"\x1b[F".to_vec()),
KeyCode::Delete => Some(b"\x1b[3~".to_vec()),
_ => None,
};
if let Some(data) = bytes {
self.write_to_pty(&data);
}
}
}
impl Component for ExecutionComponent {
type Action = ExecutionAction;
fn handle_key(&mut self, key: KeyEvent) -> EventResult<ExecutionAction> {
if self.exited() {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
EventResult::Action(ExecutionAction::Close)
}
_ => EventResult::Consumed,
}
} else {
self.forward_key_to_pty(key);
EventResult::Consumed
}
}
fn handle_mouse(&mut self, _event: MouseEvent, _area: Rect) -> EventResult<ExecutionAction> {
EventResult::NotHandled
}
}
impl RenderableComponent for ExecutionComponent {
fn render(&mut self, area: Rect, buf: &mut Buffer, colors: &UiColors) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(4), Constraint::Length(1), ])
.split(area);
let cmd_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors.active_border))
.title(" Command ")
.title_style(Style::default().fg(colors.active_border).bold());
let cmd_spans = vec![
Span::styled("$ ", Style::default().fg(colors.command)),
Span::styled(
self.state.command_display.clone(),
Style::default()
.fg(colors.preview_cmd)
.add_modifier(RatModifier::BOLD),
),
];
let cmd_paragraph = Paragraph::new(Line::from(cmd_spans))
.block(cmd_block)
.wrap(Wrap { trim: false });
Widget::render(cmd_paragraph, outer[0], buf);
let term_block = Block::default().borders(Borders::NONE);
if let Ok(parser) = self.state.parser.read() {
let pseudo_term = PseudoTerminal::new(parser.screen())
.block(term_block)
.style(Style::default().fg(colors.preview_cmd).bg(colors.bg));
Widget::render(pseudo_term, outer[1], buf);
} else {
let fallback = Paragraph::new("(terminal output unavailable)")
.block(term_block)
.style(Style::default().fg(colors.help));
Widget::render(fallback, outer[1], buf);
}
let exited = self.exited();
let status_text = if exited {
let exit_code = self.exit_status().unwrap_or_default();
format!(
" Exited ({}) — press Esc/⏎/q to close ",
if exit_code.is_empty() {
"unknown".to_string()
} else {
exit_code
}
)
} else {
" Running… (input is forwarded to the process) ".to_string()
};
let status_style = if exited {
Style::default()
.fg(colors.help)
.add_modifier(RatModifier::BOLD | RatModifier::REVERSED)
} else {
Style::default()
.fg(colors.command)
.add_modifier(RatModifier::BOLD | RatModifier::REVERSED)
};
let status = Paragraph::new(status_text).style(status_style);
Widget::render(status, outer[2], buf);
}
}