use super::term::TerminalState;
use crate::services::async_bridge::AsyncBridge;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::AtomicBool;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TerminalId(pub usize);
impl std::fmt::Display for TerminalId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Terminal-{}", self.0)
}
}
enum TerminalCommand {
Write(Vec<u8>),
Resize { cols: u16, rows: u16 },
Shutdown,
}
pub struct TerminalHandle {
pub state: Arc<Mutex<TerminalState>>,
command_tx: mpsc::Sender<TerminalCommand>,
alive: Arc<std::sync::atomic::AtomicBool>,
cols: u16,
rows: u16,
cwd: Option<std::path::PathBuf>,
shell: String,
}
impl TerminalHandle {
pub fn write(&self, data: &[u8]) {
let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
}
pub fn resize(&mut self, cols: u16, rows: u16) {
if cols != self.cols || rows != self.rows {
self.cols = cols;
self.rows = rows;
let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
if let Ok(mut state) = self.state.lock() {
state.resize(cols, rows);
}
}
}
pub fn is_alive(&self) -> bool {
self.alive.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn shutdown(&self) {
let _ = self.command_tx.send(TerminalCommand::Shutdown);
}
pub fn size(&self) -> (u16, u16) {
(self.cols, self.rows)
}
pub fn cwd(&self) -> Option<std::path::PathBuf> {
self.cwd.clone()
}
pub fn shell(&self) -> &str {
&self.shell
}
}
pub struct TerminalManager {
terminals: HashMap<TerminalId, TerminalHandle>,
next_id: usize,
async_bridge: Option<AsyncBridge>,
}
impl TerminalManager {
pub fn new() -> Self {
Self {
terminals: HashMap::new(),
next_id: 0,
async_bridge: None,
}
}
pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
self.async_bridge = Some(bridge);
}
pub fn next_terminal_id(&self) -> TerminalId {
TerminalId(self.next_id)
}
pub fn spawn(
&mut self,
cols: u16,
rows: u16,
cwd: Option<std::path::PathBuf>,
log_path: Option<std::path::PathBuf>,
backing_path: Option<std::path::PathBuf>,
) -> Result<TerminalId, String> {
let id = TerminalId(self.next_id);
self.next_id += 1;
let handle_result: Result<TerminalHandle, String> = (|| {
let pty_system = native_pty_system();
let pty_pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {}", e))?;
let shell = detect_shell();
tracing::info!("Spawning terminal with shell: {}", shell);
let mut cmd = CommandBuilder::new(&shell);
if let Some(ref dir) = cwd {
cmd.cwd(dir);
}
let mut child = pty_pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {}", e))?;
let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
if let Some(ref p) = backing_path {
if let Ok(metadata) = std::fs::metadata(p) {
if metadata.len() > 0 {
if let Ok(mut s) = state.lock() {
s.set_backing_file_history_end(metadata.len());
}
}
}
}
let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
let alive = Arc::new(AtomicBool::new(true));
let alive_clone = alive.clone();
let mut master = pty_pair
.master
.take_writer()
.map_err(|e| format!("Failed to get PTY writer: {}", e))?;
let mut reader = pty_pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to get PTY reader: {}", e))?;
let state_clone = state.clone();
let async_bridge = self.async_bridge.clone();
let mut log_writer = log_path
.as_ref()
.and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.ok()
})
.map(std::io::BufWriter::new);
let mut backing_writer = backing_path
.as_ref()
.and_then(|p| {
let existing_has_content =
p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
if existing_has_content {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.ok()
} else {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(p)
.ok()
}
})
.map(std::io::BufWriter::new);
let terminal_id = id;
thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => {
tracing::info!("Terminal {:?} EOF", terminal_id);
break;
}
Ok(n) => {
if let Ok(mut state) = state_clone.lock() {
state.process_output(&buf[..n]);
if let Some(ref mut writer) = backing_writer {
match state.flush_new_scrollback(writer) {
Ok(lines_written) => {
if lines_written > 0 {
if let Ok(pos) = writer.get_ref().metadata() {
state.set_backing_file_history_end(pos.len());
}
let _ = writer.flush();
}
}
Err(e) => {
tracing::warn!(
"Terminal backing file write error: {}",
e
);
backing_writer = None;
}
}
}
}
if let Some(w) = log_writer.as_mut() {
if let Err(e) = w.write_all(&buf[..n]) {
tracing::warn!("Terminal log write error: {}", e);
log_writer = None; } else if let Err(e) = w.flush() {
tracing::warn!("Terminal log flush error: {}", e);
log_writer = None;
}
}
if let Some(ref bridge) = async_bridge {
let _ = bridge.sender().send(
crate::services::async_bridge::AsyncMessage::TerminalOutput {
terminal_id,
},
);
}
}
Err(e) => {
tracing::error!("Terminal read error: {}", e);
break;
}
}
}
alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
if let Some(mut w) = log_writer {
let _ = w.flush();
}
if let Some(mut w) = backing_writer {
let _ = w.flush();
}
if let Some(ref bridge) = async_bridge {
let _ = bridge.sender().send(
crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
);
}
});
let pty_size_ref = pty_pair.master;
thread::spawn(move || {
loop {
match command_rx.recv() {
Ok(TerminalCommand::Write(data)) => {
if let Err(e) = master.write_all(&data) {
tracing::error!("Terminal write error: {}", e);
break;
}
let _ = master.flush();
}
Ok(TerminalCommand::Resize { cols, rows }) => {
if let Err(e) = pty_size_ref.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
}) {
tracing::warn!("Failed to resize PTY: {}", e);
}
}
Ok(TerminalCommand::Shutdown) | Err(_) => {
break;
}
}
}
let _ = child.kill();
let _ = child.wait();
});
Ok(TerminalHandle {
state,
command_tx,
alive,
cols,
rows,
cwd: cwd.clone(),
shell,
})
})();
let handle = handle_result.map_err(|err| {
err
})?;
self.terminals.insert(id, handle);
tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
Ok(id)
}
pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
self.terminals.get(&id)
}
pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
self.terminals.get_mut(&id)
}
pub fn close(&mut self, id: TerminalId) -> bool {
if let Some(handle) = self.terminals.remove(&id) {
handle.shutdown();
true
} else {
false
}
}
pub fn terminal_ids(&self) -> Vec<TerminalId> {
self.terminals.keys().copied().collect()
}
pub fn count(&self) -> usize {
self.terminals.len()
}
pub fn shutdown_all(&mut self) {
for (_, handle) in self.terminals.drain() {
handle.shutdown();
}
}
pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
let dead: Vec<TerminalId> = self
.terminals
.iter()
.filter(|(_, h)| !h.is_alive())
.map(|(id, _)| *id)
.collect();
for id in &dead {
self.terminals.remove(id);
}
dead
}
}
impl Default for TerminalManager {
fn default() -> Self {
Self::new()
}
}
impl Drop for TerminalManager {
fn drop(&mut self) {
self.shutdown_all();
}
}
pub fn detect_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.is_empty() {
return shell;
}
}
#[cfg(unix)]
{
"/bin/sh".to_string()
}
#[cfg(windows)]
{
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_id_display() {
let id = TerminalId(42);
assert_eq!(format!("{}", id), "Terminal-42");
}
#[test]
fn test_detect_shell() {
let shell = detect_shell();
assert!(!shell.is_empty());
}
}