use std::collections::HashMap; use std::io::{Read, Write}; #[cfg(target_os = "windows")] use std::path::Path; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use tauri::Emitter; use super::error::TerminalError; use super::types::{TerminalEvent, TerminalInfo}; struct TerminalInstance { write_tx: mpsc::Sender>, master: Box, _child: Box, title: String, owner_window_label: String, } pub struct TerminalManager { terminals: Arc>>, } fn resolve_shell() -> String { #[cfg(target_os = "windows")] { if let Ok(shell) = std::env::var("SHELL") { let trimmed = shell.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } if let Ok(comspec) = std::env::var("COMSPEC") { let trimmed = comspec.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } "cmd.exe".to_string() } #[cfg(not(target_os = "windows"))] { std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()) } } #[cfg(target_os = "windows")] #[derive(Debug, Clone, Copy)] enum WindowsShellFlavor { Cmd, PowerShell, Posix, } #[cfg(target_os = "windows")] fn detect_windows_shell_flavor(shell: &str) -> WindowsShellFlavor { let shell_name = Path::new(shell) .file_name() .and_then(|value| value.to_str()) .unwrap_or(shell) .to_ascii_lowercase(); if shell_name.contains("pwsh") || shell_name.contains("powershell") { WindowsShellFlavor::PowerShell } else if shell_name.contains("bash") || shell_name.contains("zsh") || shell_name.contains("fish") || shell_name.ends_with("sh.exe") { WindowsShellFlavor::Posix } else { WindowsShellFlavor::Cmd } } fn configure_shell_command(cmd: &mut CommandBuilder, shell: &str, initial_command: Option<&str>) { #[cfg(target_os = "windows")] { match detect_windows_shell_flavor(shell) { WindowsShellFlavor::Cmd => { if let Some(command) = initial_command { cmd.env("CODEG_CMD", command); cmd.args(["/D", "/S", "/C", "%CODEG_CMD%"]); } } WindowsShellFlavor::PowerShell => { if let Some(command) = initial_command { cmd.env("CODEG_CMD", command); cmd.args([ "-NoLogo", "-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; Invoke-Expression $env:CODEG_CMD", ]); } else { cmd.args(["-NoLogo", "-NoProfile"]); } } WindowsShellFlavor::Posix => { cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); cmd.env("TERM_PROGRAM", "codeg"); if let Some(command) = initial_command { cmd.env("CODEG_CMD", command); cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]); } else { cmd.args(["-l", "-i"]); } } } } #[cfg(not(target_os = "windows"))] { let _ = shell; // GUI app environments often miss TERM; force a sane terminal type so // readline/zle can redraw lines correctly (history navigation, etc.). cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); cmd.env("TERM_PROGRAM", "codeg"); if let Some(command) = initial_command { // Run command and let this PTY session exit when it completes. cmd.env("CODEG_CMD", command); cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]); } else { cmd.args(["-l", "-i"]); } } } impl TerminalManager { pub fn new() -> Self { Self { terminals: Arc::new(Mutex::new(HashMap::new())), } } pub fn spawn( &self, working_dir: String, owner_window_label: String, app_handle: tauri::AppHandle, initial_command: Option, ) -> Result { let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }) .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; let shell = resolve_shell(); let mut cmd = CommandBuilder::new(&shell); configure_shell_command(&mut cmd, &shell, initial_command.as_deref()); cmd.cwd(&working_dir); let child = pair .slave .spawn_command(cmd) .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; drop(pair.slave); let writer = pair .master .take_writer() .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; let reader = pair .master .try_clone_reader() .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; let terminal_id = uuid::Uuid::new_v4().to_string(); let (write_tx, write_rx) = mpsc::channel::>(); let instance = TerminalInstance { write_tx, master: pair.master, _child: child, title: "Terminal".to_string(), owner_window_label, }; self.terminals .lock() .unwrap() .insert(terminal_id.clone(), instance); // Named writer thread let id_for_writer = terminal_id.clone(); std::thread::Builder::new() .name(format!("pty-writer-{}", &terminal_id[..8])) .spawn(move || { write_loop(writer, write_rx); }) .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; // Named reader thread — emits per-terminal events let id_for_reader = terminal_id.clone(); let terminals_ref = self.terminals.clone(); std::thread::Builder::new() .name(format!("pty-reader-{}", &id_for_writer[..8])) .spawn(move || { read_loop(reader, id_for_reader, &app_handle, &terminals_ref); }) .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; Ok(terminal_id) } pub fn write(&self, terminal_id: &str, data: &[u8]) -> Result<(), TerminalError> { let terminals = self.terminals.lock().unwrap(); let instance = terminals .get(terminal_id) .ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?; instance .write_tx .send(data.to_vec()) .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; Ok(()) } pub fn resize(&self, terminal_id: &str, cols: u16, rows: u16) -> Result<(), TerminalError> { let terminals = self.terminals.lock().unwrap(); let instance = terminals .get(terminal_id) .ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?; instance .master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; Ok(()) } pub fn kill(&self, terminal_id: &str) -> Result<(), TerminalError> { let mut instance = self .terminals .lock() .unwrap() .remove(terminal_id) .ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?; terminate_terminal(&mut instance); Ok(()) } pub fn list_with_exit_check(&self, app_handle: Option<&tauri::AppHandle>) -> Vec { let mut terminals = self.terminals.lock().unwrap(); let mut exited_terminal_ids: Vec = Vec::new(); // Windows ConPTY may not always surface EOF promptly; reconcile exited // child processes here so frontend running-state can recover reliably. for (id, instance) in terminals.iter_mut() { match instance._child.try_wait() { Ok(Some(_)) => exited_terminal_ids.push(id.clone()), Ok(None) => {} Err(err) => { eprintln!( "[TERM] failed to query child status for terminal {}: {}", id, err ); exited_terminal_ids.push(id.clone()); } } } for terminal_id in &exited_terminal_ids { terminals.remove(terminal_id); } let infos = terminals .iter() .map(|(id, inst)| TerminalInfo { id: id.clone(), title: inst.title.clone(), }) .collect(); drop(terminals); if let Some(handle) = app_handle { for terminal_id in exited_terminal_ids { emit_terminal_exit_event(handle, &terminal_id); } } infos } pub fn kill_by_owner_window(&self, owner_window_label: &str) -> usize { let mut instances = { let mut terminals = self.terminals.lock().unwrap(); let ids: Vec = terminals .iter() .filter_map(|(id, instance)| { if instance.owner_window_label == owner_window_label { Some(id.clone()) } else { None } }) .collect(); let mut removed = Vec::with_capacity(ids.len()); for id in ids { if let Some(instance) = terminals.remove(&id) { removed.push(instance); } } removed }; let killed = instances.len(); for instance in &mut instances { terminate_terminal(instance); } killed } pub fn kill_all(&self) -> usize { let mut instances: Vec = { let mut terminals = self.terminals.lock().unwrap(); terminals.drain().map(|(_, inst)| inst).collect() }; let killed = instances.len(); for instance in &mut instances { terminate_terminal(instance); } eprintln!("[TERM] kill_all killed_terminals={}", killed); killed } } fn terminate_terminal(instance: &mut TerminalInstance) { let _ = instance._child.kill(); let _ = instance._child.wait(); } fn write_loop(mut writer: Box, rx: mpsc::Receiver>) { while let Ok(data) = rx.recv() { if writer.write_all(&data).is_err() { break; } while let Ok(more) = rx.try_recv() { if writer.write_all(&more).is_err() { return; } } if writer.flush().is_err() { break; } } } fn read_loop( mut reader: Box, terminal_id: String, app_handle: &tauri::AppHandle, terminals: &Arc>>, ) { let output_event = format!("terminal://output/{}", terminal_id); let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { let data = String::from_utf8_lossy(&buf[..n]).to_string(); let event = TerminalEvent { terminal_id: terminal_id.clone(), data, }; let _ = app_handle.emit(&output_event, &event); } Err(_) => break, } } // Terminal exited — remove from map terminals.lock().unwrap().remove(&terminal_id); emit_terminal_exit_event(app_handle, &terminal_id); } fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) { let exit_event = format!("terminal://exit/{}", terminal_id); let event = TerminalEvent { terminal_id: terminal_id.to_string(), data: String::new(), }; let _ = app_handle.emit(&exit_event, &event); }