use super::term::TerminalState;
use crate::services::async_bridge::AsyncBridge;
use crate::services::authority::TerminalWrapper;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::borrow::Cow;
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;
pub use fresh_core::TerminalId;
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,
pid: Option<u32>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
master_fd: Option<i32>,
}
impl TerminalHandle {
pub fn write(&self, data: &[u8]) {
#[allow(clippy::let_underscore_must_use)]
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;
#[allow(clippy::let_underscore_must_use)]
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) {
#[allow(clippy::let_underscore_must_use)]
let _ = self.command_tx.send(TerminalCommand::Shutdown);
}
pub fn pid(&self) -> Option<u32> {
self.pid
}
pub fn foreground_process_name(&self) -> Option<String> {
#[cfg(target_os = "linux")]
{
let fd = self.master_fd?;
let pgid = unsafe { libc::tcgetpgrp(fd) };
if pgid <= 0 {
return None;
}
let comm = std::fs::read_to_string(format!("/proc/{pgid}/comm")).ok()?;
let name = comm.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(unix)]
pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
let Some(pid) = self.pid else {
return Ok(false);
};
let sig = match signal_name {
"SIGTERM" => libc::SIGTERM,
"SIGKILL" => libc::SIGKILL,
"SIGINT" => libc::SIGINT,
"SIGHUP" => libc::SIGHUP,
other => return Err(format!("unsupported signal: {}", other)),
};
let rc = unsafe { libc::kill(-(pid as i32), sig) };
if rc == 0 {
Ok(true)
} else {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
Ok(false)
} else {
Err(format!("kill(-{}, {}): {}", pid, signal_name, err))
}
}
}
#[cfg(windows)]
pub fn signal(&self, signal_name: &str) -> Result<bool, String> {
if signal_name == "SIGKILL" {
self.shutdown();
return Ok(true);
}
Ok(false)
}
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 {
window_id: fresh_core::WindowId,
terminals: HashMap<TerminalId, TerminalHandle>,
next_id: usize,
async_bridge: Option<AsyncBridge>,
}
impl TerminalManager {
pub fn new(window_id: fresh_core::WindowId) -> Self {
Self {
window_id,
terminals: HashMap::new(),
next_id: 0,
async_bridge: None,
}
}
pub fn window_id(&self) -> fresh_core::WindowId {
self.window_id
}
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)
}
#[allow(clippy::too_many_arguments)]
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>,
terminal_wrapper: crate::services::authority::TerminalWrapper,
env_delta: crate::services::env_provider::EnvDelta,
) -> Result<TerminalId, String> {
let id = TerminalId(self.next_id);
self.next_id += 1;
let handle = self.build_terminal(
id,
cols,
rows,
cwd,
log_path,
backing_path,
terminal_wrapper,
env_delta,
)?;
self.terminals.insert(id, handle);
tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
Ok(id)
}
#[allow(clippy::too_many_arguments)]
fn build_terminal(
&self,
id: TerminalId,
cols: u16,
rows: u16,
cwd: Option<std::path::PathBuf>,
log_path: Option<std::path::PathBuf>,
backing_path: Option<std::path::PathBuf>,
terminal_wrapper: TerminalWrapper,
env_delta: crate::services::env_provider::EnvDelta,
) -> Result<TerminalHandle, String> {
let pty_pair = open_pty(cols, rows)?;
let (cmd, shell) = build_shell_command(terminal_wrapper, cwd.as_deref(), &env_delta);
let child = pty_pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
tracing::debug!("Shell process spawned successfully");
let child_pid = child.process_id();
let child_killer = child.clone_killer();
let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
if let Some(p) = backing_path.as_ref() {
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 master_writer = pty_pair
.master
.take_writer()
.map_err(|e| format!("Failed to get PTY writer: {}", e))?;
let reader = pty_pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to get PTY reader: {}", e))?;
let log_writer = open_log_writer(log_path.as_deref());
let backing_writer = open_backing_writer(backing_path.as_deref());
let wt_id = fresh_core::WindowTerminalId::new(self.window_id, id);
let reader_loop = ReaderLoop {
reader,
state: state.clone(),
response_tx: command_tx.clone(),
backing_writer,
log_writer,
async_bridge: self.async_bridge.clone(),
wt_id,
terminal_id: id,
alive: alive.clone(),
};
thread::spawn(move || reader_loop.run());
spawn_wait_thread(child, self.async_bridge.clone(), wt_id, id);
let master_fd: Option<i32> = {
#[cfg(unix)]
{
pty_pair.master.as_raw_fd()
}
#[cfg(not(unix))]
{
None
}
};
spawn_writer_thread(command_rx, master_writer, pty_pair.master, child_killer);
Ok(TerminalHandle {
state,
command_tx,
alive,
cols,
rows,
cwd,
shell,
pid: child_pid,
master_fd,
})
}
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
}
}
fn open_pty(cols: u16, rows: u16) -> Result<portable_pty::PtyPair, String> {
native_pty_system()
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| {
#[cfg(windows)]
{
format!(
"Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
e
)
}
#[cfg(not(windows))]
{
format!("Failed to open PTY: {}", e)
}
})
}
fn build_shell_command(
terminal_wrapper: TerminalWrapper,
cwd: Option<&std::path::Path>,
env_delta: &crate::services::env_provider::EnvDelta,
) -> (CommandBuilder, String) {
let TerminalWrapper {
command: shell,
args: cmd_args,
manages_cwd: skip_cwd,
} = terminal_wrapper;
tracing::info!("Spawning terminal with shell: {}", shell);
let mut cmd = CommandBuilder::new(&shell);
for arg in &cmd_args {
cmd.arg(arg);
}
if !skip_cwd {
if let Some(dir) = cwd {
cmd.cwd(strip_verbatim_prefix(dir).as_ref());
}
}
for (k, v) in &env_delta.set {
cmd.env(k, v);
}
for k in &env_delta.unset {
cmd.env_remove(k);
}
cmd.env("TERM", "xterm-256color");
if !skip_cwd {
if let Some(session_id) = crate::server::local_control::local_session_id() {
cmd.env("FRESH_SESSION", session_id);
}
}
#[cfg(windows)]
{
if shell.to_lowercase().contains("cmd") {
cmd.env("PROMPT", "$P$G");
}
}
(cmd, shell)
}
fn open_log_writer(
log_path: Option<&std::path::Path>,
) -> Option<std::io::BufWriter<std::fs::File>> {
log_path
.and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
.ok()
})
.map(std::io::BufWriter::new)
}
fn open_backing_writer(
backing_path: Option<&std::path::Path>,
) -> Option<std::io::BufWriter<std::fs::File>> {
backing_path
.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)
}
fn spawn_wait_thread(
mut child: Box<dyn portable_pty::Child + Send + Sync>,
async_bridge: Option<AsyncBridge>,
wt_id: fresh_core::WindowTerminalId,
terminal_id: TerminalId,
) {
thread::spawn(move || {
let exit_code = match child.wait() {
Ok(status) => Some(status.exit_code() as i32),
Err(e) => {
tracing::warn!("child.wait() failed for {:?}: {}", terminal_id, e);
None
}
};
if let Some(bridge) = &async_bridge {
#[allow(clippy::let_underscore_must_use)]
let _ = bridge.sender().send(
crate::services::async_bridge::AsyncMessage::TerminalExited {
terminal: wt_id,
exit_code,
},
);
}
});
}
fn spawn_writer_thread(
command_rx: mpsc::Receiver<TerminalCommand>,
mut master: Box<dyn Write + Send>,
pty_master: Box<dyn portable_pty::MasterPty + Send>,
mut child_killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
) {
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;
}
#[allow(clippy::let_underscore_must_use)]
let _ = master.flush();
}
Ok(TerminalCommand::Resize { cols, rows }) => {
if let Err(e) = pty_master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
}) {
tracing::warn!("Failed to resize PTY: {}", e);
}
}
Ok(TerminalCommand::Shutdown) | Err(_) => {
break;
}
}
}
#[allow(clippy::let_underscore_must_use)]
let _ = child_killer.kill();
});
}
struct ReaderLoop {
reader: Box<dyn Read + Send>,
state: Arc<Mutex<TerminalState>>,
response_tx: mpsc::Sender<TerminalCommand>,
backing_writer: Option<std::io::BufWriter<std::fs::File>>,
log_writer: Option<std::io::BufWriter<std::fs::File>>,
async_bridge: Option<AsyncBridge>,
wt_id: fresh_core::WindowTerminalId,
terminal_id: TerminalId,
alive: Arc<AtomicBool>,
}
impl ReaderLoop {
fn run(mut self) {
tracing::debug!("Terminal {:?} reader thread started", self.terminal_id);
let mut buf = [0u8; 4096];
let mut total_bytes = 0usize;
loop {
match self.reader.read(&mut buf) {
Ok(0) => {
tracing::info!(
"Terminal {:?} EOF after {} total bytes",
self.terminal_id,
total_bytes
);
break;
}
Ok(n) => {
total_bytes += n;
tracing::trace!(
"Terminal {:?} received {} bytes (total: {})",
self.terminal_id,
n,
total_bytes
);
self.process_output(&buf[..n]);
self.append_raw_log(&buf[..n]);
self.notify_redraw();
}
Err(e) => {
tracing::error!("Terminal read error: {}", e);
break;
}
}
}
self.alive
.store(false, std::sync::atomic::Ordering::Relaxed);
if let Some(mut w) = self.log_writer.take() {
#[allow(clippy::let_underscore_must_use)]
let _ = w.flush();
}
if let Some(mut w) = self.backing_writer.take() {
#[allow(clippy::let_underscore_must_use)]
let _ = w.flush();
}
}
fn process_output(&mut self, bytes: &[u8]) {
let Ok(mut state) = self.state.lock() else {
return;
};
state.process_output(bytes);
for response in state.drain_pty_write_queue() {
tracing::debug!(
"Terminal {:?} sending PTY response: {:?}",
self.terminal_id,
response
);
#[allow(clippy::let_underscore_must_use)]
let _ = self
.response_tx
.send(TerminalCommand::Write(response.into_bytes()));
}
if let Some(writer) = self.backing_writer.as_mut() {
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());
}
#[allow(clippy::let_underscore_must_use)]
let _ = writer.flush();
}
}
Err(e) => {
tracing::warn!("Terminal backing file write error: {}", e);
self.backing_writer = None;
}
}
}
}
fn append_raw_log(&mut self, bytes: &[u8]) {
if let Some(w) = self.log_writer.as_mut() {
if let Err(e) = w.write_all(bytes) {
tracing::warn!("Terminal log write error: {}", e);
self.log_writer = None;
} else if let Err(e) = w.flush() {
tracing::warn!("Terminal log flush error: {}", e);
self.log_writer = None;
}
}
}
fn notify_redraw(&self) {
if let Some(bridge) = &self.async_bridge {
#[allow(clippy::let_underscore_must_use)]
let _ = bridge.sender().send(
crate::services::async_bridge::AsyncMessage::TerminalOutput {
terminal: self.wt_id,
},
);
}
}
}
impl Drop for TerminalManager {
fn drop(&mut self) {
self.shutdown_all();
}
}
pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
#[cfg(windows)]
{
use std::path::{Component, Prefix};
let mut components = path.components();
let prefix = match components.next() {
Some(Component::Prefix(p)) => p,
_ => return Cow::Borrowed(path),
};
let mut rebuilt = std::path::PathBuf::new();
match prefix.kind() {
Prefix::VerbatimDisk(drive) => {
rebuilt.push(format!("{}:\\", drive as char));
}
Prefix::VerbatimUNC(server, share) => {
rebuilt.push(format!(
r"\\{}\{}\",
server.to_string_lossy(),
share.to_string_lossy()
));
}
_ => return Cow::Borrowed(path),
}
for component in components {
if matches!(component, Component::RootDir) {
continue;
}
rebuilt.push(component.as_os_str());
}
Cow::Owned(rebuilt)
}
#[cfg(not(windows))]
{
Cow::Borrowed(path)
}
}
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)]
{
super::windows_shell::select_windows_shell()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_id_display() {
let id = TerminalId(42);
assert_eq!(format!("{}", id), "Terminal-42");
}
#[test]
fn terminal_ids_collide_across_windows_but_window_disambiguates() {
use fresh_core::{WindowId, WindowTerminalId};
let win_a = TerminalManager::new(WindowId(1));
let win_b = TerminalManager::new(WindowId(2));
assert_eq!(win_a.next_terminal_id(), win_b.next_terminal_id());
assert_eq!(win_a.next_terminal_id(), TerminalId(0));
assert_eq!(win_a.window_id(), WindowId(1));
assert_eq!(win_b.window_id(), WindowId(2));
let a0 = WindowTerminalId::new(win_a.window_id(), win_a.next_terminal_id());
let b0 = WindowTerminalId::new(win_b.window_id(), win_b.next_terminal_id());
assert_ne!(
a0, b0,
"same local terminal id in different windows must be distinct globally"
);
}
#[test]
fn test_detect_shell() {
let shell = detect_shell();
assert!(!shell.is_empty());
}
#[cfg(not(windows))]
#[test]
fn strip_verbatim_prefix_is_noop_on_unix() {
use std::path::Path;
let p = Path::new("/home/user/project");
assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
}
#[cfg(windows)]
#[test]
fn strip_verbatim_prefix_removes_verbatim_disk() {
use std::path::{Path, PathBuf};
let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
let stripped = strip_verbatim_prefix(&verbatim);
assert_eq!(
stripped.as_ref(),
Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
"verbatim disk prefix should be replaced with plain drive form"
);
}
#[cfg(windows)]
#[test]
fn strip_verbatim_prefix_removes_verbatim_unc() {
use std::path::{Path, PathBuf};
let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
let stripped = strip_verbatim_prefix(&verbatim);
assert_eq!(
stripped.as_ref(),
Path::new(r"\\server\share\dir\file"),
"verbatim UNC prefix should be replaced with plain UNC form"
);
}
#[cfg(windows)]
#[test]
fn strip_verbatim_prefix_passes_plain_paths_through() {
use std::path::{Path, PathBuf};
let plain = PathBuf::from(r"C:\Users\HP\project");
let result = strip_verbatim_prefix(&plain);
assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
}
}