diff --git a/Cargo.lock b/Cargo.lock index 1666d3b..b5f2040 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "rss" -version = "0.1.0" +version = "0.1.1" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7449a50..1696123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,5 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" chrono = "0.4" -nix = { version = "0.29", default-features = false, features = ["signal", "fs"] } +nix = { version = "0.29", default-features = false, features = ["signal", "fs", "term"] } libc = "0.2" diff --git a/src/attach.rs b/src/attach.rs new file mode 100644 index 0000000..34cf931 --- /dev/null +++ b/src/attach.rs @@ -0,0 +1,655 @@ +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::os::unix::net::UnixStream; +use std::sync::mpsc; +use std::time::Duration; + +use nix::libc; + +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; +const GRAY_BG: &str = "\x1b[48;5;236m"; +const REVERSE: &str = "\x1b[7m"; +const SHOW_CURSOR: &str = "\x1b[?25h"; + +struct TerminalGuard { + orig: nix::sys::termios::Termios, + stdin: std::io::Stdin, +} + +impl TerminalGuard { + fn new() -> Self { + let stdin = std::io::stdin(); + let orig = set_raw_mode(&stdin); + TerminalGuard { orig, stdin } + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = write!(std::io::stdout(), "{}\r\n", SHOW_CURSOR); + let _ = std::io::stdout().flush(); + let _ = nix::sys::termios::tcsetattr( + &self.stdin, + nix::sys::termios::SetArg::TCSANOW, + &self.orig, + ); + } +} + +fn set_raw_mode(stdin: &std::io::Stdin) -> nix::sys::termios::Termios { + let mut termios = nix::sys::termios::tcgetattr(stdin).expect("tcgetattr"); + let orig = termios.clone(); + + termios.local_flags.remove( + nix::sys::termios::LocalFlags::ECHO + | nix::sys::termios::LocalFlags::ICANON + | nix::sys::termios::LocalFlags::ISIG + | nix::sys::termios::LocalFlags::IEXTEN, + ); + termios.input_flags.remove( + nix::sys::termios::InputFlags::IXON + | nix::sys::termios::InputFlags::BRKINT + | nix::sys::termios::InputFlags::INPCK + | nix::sys::termios::InputFlags::ISTRIP + | nix::sys::termios::InputFlags::ICRNL, + ); + termios.output_flags.remove(nix::sys::termios::OutputFlags::OPOST); + termios.control_flags.insert(nix::sys::termios::ControlFlags::CS8); + + nix::sys::termios::tcsetattr(stdin, nix::sys::termios::SetArg::TCSANOW, &termios) + .expect("tcsetattr"); + + orig +} + +fn terminal_size() -> (usize, usize) { + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 { + (ws.ws_row as usize, ws.ws_col as usize) + } else { + (24, 80) + } + } +} + +fn safe_truncate(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + // Find the nearest char boundary at or before max + let mut idx = max; + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + &s[..idx] +} + +fn colorize(line: &str) -> String { + let use_color = + std::env::var("NO_COLOR").is_err() && nix::unistd::isatty(1).unwrap_or(false); + if !use_color { + return line.to_string(); + } + if line.contains("E: ") || line.starts_with("E:") { + format!("{}{}{}", RED, line, RESET) + } else { + line.to_string() + } +} + +fn follow_log(path: &str, tx: mpsc::Sender) { + let file = match std::fs::File::open(path) { + Ok(f) => f, + Err(_) => return, + }; + let mut reader = BufReader::new(file); + let _ = reader.seek(SeekFrom::End(0)); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + std::thread::sleep(Duration::from_millis(100)); + } + Ok(_) => { + let trimmed = line.trim_end_matches('\n').trim_end_matches('\r').to_string(); + if !trimmed.is_empty() && tx.send(trimmed).is_err() { + break; + } + } + Err(_) => break, + } + } +} + +fn adjust_scroll(input: &str, cursor: usize, scroll: &mut usize, cols: usize) { + let max_visible = cols.saturating_sub(2); + if max_visible == 0 { + return; + } + let threshold = (max_visible as f64 * 0.9) as usize; + + if cursor > *scroll + threshold { + *scroll = cursor - threshold; + } + if cursor < *scroll { + *scroll = cursor; + } + if *scroll + max_visible > input.len() { + *scroll = input.len().saturating_sub(max_visible); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_all( + stdout: &mut std::io::Stdout, + rows: usize, + cols: usize, + log_lines: &VecDeque, + input: &str, + cursor: usize, + scroll: usize, + name: &str, + started: bool, +) { + let mut out = String::new(); + + if !started { + out.push_str("\x1b[2J"); + } + + let header = format!(" Attached to {} - Ctrl+D detach | Ctrl+C quit ", name); + out.push_str("\x1b[1;1H"); + out.push_str(REVERSE); + out.push_str(&header); + out.push_str("\x1b[K"); + out.push_str(RESET); + + let max_log_rows = rows.saturating_sub(2); + for i in 0..max_log_rows { + out.push_str(&format!("\x1b[{};1H", i + 2)); + out.push_str("\x1b[2K"); + if let Some(line) = log_lines.get(i) { + out.push_str(safe_truncate(line, cols)); + } + } + + let prompt = "> "; + let max_visible = cols.saturating_sub(prompt.len()); + let visible_start = scroll; + let visible_end = (scroll + max_visible).min(input.len()); + let visible = if scroll < input.len() { + &input[visible_start..visible_end] + } else { + "" + }; + + out.push_str(&format!("\x1b[{};1H", rows)); + out.push_str(GRAY_BG); + out.push_str(prompt); + out.push_str(visible); + out.push_str("\x1b[K"); + out.push_str(RESET); + + let cursor_col = prompt.len() + cursor.saturating_sub(scroll); + out.push_str(&format!("\x1b[{};{}H", rows, cursor_col + 1)); + + let _ = stdout.write_all(out.as_bytes()); + let _ = stdout.flush(); +} + +enum InputAction { + Send, + Exit, + SignalChild, + HistoryPrev, + HistoryNext, + None, +} + +#[derive(PartialEq)] +enum EscState { + None, + Escaped, + Bracket, +} + +fn utf8_char_len(first: u8) -> Option { + if first & 0x80 == 0 { + Some(1) + } else if first & 0xE0 == 0xC0 { + Some(2) + } else if first & 0xF0 == 0xE0 { + Some(3) + } else if first & 0xF8 == 0xF0 { + Some(4) + } else { + None + } +} + +pub fn run_attach(name: &str, full: bool, stream: UnixStream) { + if !nix::unistd::isatty(0).unwrap_or(false) { + run_attach_simple(name, full, stream); + return; + } + + let _guard = TerminalGuard::new(); + let mut stdout = std::io::stdout(); + + let (stdin_tx, stdin_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = [0u8; 1]; + loop { + match std::io::stdin().read(&mut buf) { + Ok(0) => { + let _ = stdin_tx.send(None); + break; + } + Ok(_) => { + let _ = stdin_tx.send(Some(buf[0])); + } + Err(_) => break, + } + } + }); + + let (log_tx, log_rx) = mpsc::channel(); + let log_path = crate::state::log_path(name); + if log_path.exists() { + let path_str = log_path.to_string_lossy().to_string(); + std::thread::spawn(move || { + follow_log(&path_str, log_tx); + }); + } + + let mut stream = stream; + let mut input = String::new(); + let mut cursor = 0usize; + let mut scroll = 0usize; + let mut log_lines: VecDeque = VecDeque::new(); + let mut history: VecDeque = VecDeque::new(); + let mut history_idx: Option = None; + let mut started = false; + let mut utf8_buf: Vec = Vec::new(); + let mut utf8_expected: Option = None; + let mut esc_state = EscState::None; + let mut prev_rows = 0usize; + let mut prev_cols = 0usize; + + loop { + let (rows, cols) = terminal_size(); + if rows != prev_rows || cols != prev_cols { + prev_rows = rows; + prev_cols = cols; + started = false; + } + + let mut changed = false; + + loop { + match stdin_rx.try_recv() { + Ok(byte_opt) => { + changed = true; + match byte_opt { + None => { + let _ = writeln!(std::io::stdout(), "\r"); + return; + } + Some(b) => { + let action = { + if b == 0x1b { + esc_state = EscState::Escaped; + changed = true; + continue; + } + + match esc_state { + EscState::Escaped => { + esc_state = if b == b'[' { + EscState::Bracket + } else { + EscState::None + }; + if esc_state == EscState::Bracket { + InputAction::None + } else { + process_input_byte( + b, + &mut input, + &mut cursor, + &mut scroll, + cols, + &mut utf8_buf, + &mut utf8_expected, + ) + } + } + EscState::Bracket => { + esc_state = EscState::None; + process_escape( + b, + &mut input, + &mut cursor, + &mut scroll, + cols, + &mut history, + &mut history_idx, + ) + } + EscState::None => process_input_byte( + b, + &mut input, + &mut cursor, + &mut scroll, + cols, + &mut utf8_buf, + &mut utf8_expected, + ), + } + }; + + match action { + InputAction::Send => { + if !input.is_empty() { + let _ = writeln!(stream, "{}", input); + let _ = stream.flush(); + history.push_back(input.clone()); + if history.len() > 100 { + history.pop_front(); + } + history_idx = None; + } + input.clear(); + cursor = 0; + scroll = 0; + } + InputAction::Exit => { + let _ = writeln!(std::io::stdout(), "\r"); + return; + } + InputAction::SignalChild => { + if full { + let _ = + crate::ipc::send_cmd(&format!("signal {} INT", name)); + } + let _ = writeln!(std::io::stdout(), "\r"); + return; + } + InputAction::HistoryPrev => { + if history.is_empty() { + continue; + } + if history_idx.is_none() { + history_idx = Some(history.len() - 1); + } else if history_idx.unwrap() > 0 { + history_idx = Some(history_idx.unwrap() - 1); + } + if let Some(idx) = history_idx { + input = history[idx].clone(); + cursor = input.len(); + scroll = input.len().saturating_sub(cols.saturating_sub(2)); + } + adjust_scroll(&input, cursor, &mut scroll, cols); + } + InputAction::HistoryNext => { + if let Some(idx) = history_idx { + if idx + 1 < history.len() { + history_idx = Some(idx + 1); + input = history[idx + 1].clone(); + cursor = input.len(); + } else { + history_idx = None; + input.clear(); + cursor = 0; + } + adjust_scroll(&input, cursor, &mut scroll, cols); + } + } + InputAction::None => {} + } + } + } + } + Err(mpsc::TryRecvError::Disconnected) => { + let _ = writeln!(std::io::stdout(), "\r"); + return; + } + Err(mpsc::TryRecvError::Empty) => break, + } + } + + while let Ok(line) = log_rx.try_recv() { + changed = true; + let max_log_area = rows.saturating_sub(2); + if log_lines.len() >= max_log_area { + log_lines.pop_front(); + } + log_lines.push_back(colorize(&line)); + } + + if changed || !started { + draw_all( + &mut stdout, + rows, + cols, + &log_lines, + &input, + cursor, + scroll, + name, + started, + ); + started = true; + } + + std::thread::sleep(Duration::from_millis(16)); + } +} + +fn process_input_byte( + b: u8, + input: &mut String, + cursor: &mut usize, + scroll: &mut usize, + cols: usize, + utf8_buf: &mut Vec, + utf8_expected: &mut Option, +) -> InputAction { + match b { + 0x0d | 0x0a => return InputAction::Send, + 0x03 => return InputAction::SignalChild, + 0x04 => return InputAction::Exit, + 0x10 => return InputAction::HistoryPrev, + 0x0e => return InputAction::HistoryNext, + 0x08 | 0x7f => { + if *cursor > 0 { + *cursor -= 1; + input.remove(*cursor); + } + adjust_scroll(input, *cursor, scroll, cols); + return InputAction::None; + } + 0x01 => { + *cursor = 0; + *scroll = 0; + return InputAction::None; + } + 0x05 => { + *cursor = input.len(); + adjust_scroll(input, *cursor, scroll, cols); + return InputAction::None; + } + 0x15 => { + input.clear(); + *cursor = 0; + *scroll = 0; + return InputAction::None; + } + 0x0b => { + input.truncate(*cursor); + return InputAction::None; + } + _ => {} + } + + if b & 0x80 != 0 { + if utf8_buf.is_empty() { + if let Some(len) = utf8_char_len(b) { + utf8_buf.push(b); + *utf8_expected = Some(len); + } + } else if b & 0xC0 == 0x80 { + utf8_buf.push(b); + if Some(utf8_buf.len()) == *utf8_expected { + if let Ok(s) = std::str::from_utf8(&utf8_buf) { + for c in s.chars() { + let n = c.len_utf8(); + input.insert(*cursor, c); + *cursor += n; + } + adjust_scroll(input, *cursor, scroll, cols); + } + utf8_buf.clear(); + *utf8_expected = None; + } + } else { + utf8_buf.clear(); + *utf8_expected = None; + } + } else if b >= 0x20 { + input.insert(*cursor, b as char); + *cursor += 1; + adjust_scroll(input, *cursor, scroll, cols); + } + + InputAction::None +} + +fn process_escape( + b: u8, + input: &mut String, + cursor: &mut usize, + scroll: &mut usize, + cols: usize, + history: &mut VecDeque, + history_idx: &mut Option, +) -> InputAction { + match b { + b'A' => { + // Up arrow - history prev + if history.is_empty() { + return InputAction::None; + } + if history_idx.is_none() { + *history_idx = Some(history.len() - 1); + } else if history_idx.unwrap() > 0 { + *history_idx = Some(history_idx.unwrap() - 1); + } + if let Some(idx) = *history_idx { + *input = history[idx].clone(); + *cursor = input.len(); + } + adjust_scroll(input, *cursor, scroll, cols); + InputAction::None + } + b'B' => { + // Down arrow - history next + if let Some(idx) = *history_idx { + if idx + 1 < history.len() { + *history_idx = Some(idx + 1); + *input = history[idx + 1].clone(); + *cursor = input.len(); + } else { + *history_idx = None; + input.clear(); + *cursor = 0; + } + adjust_scroll(input, *cursor, scroll, cols); + } + InputAction::None + } + b'C' => { + // Right arrow + if *cursor < input.len() { + *cursor += 1; + } + adjust_scroll(input, *cursor, scroll, cols); + InputAction::None + } + b'D' => { + // Left arrow + *cursor = cursor.saturating_sub(1); + adjust_scroll(input, *cursor, scroll, cols); + InputAction::None + } + b'H' => { + // Home + *cursor = 0; + *scroll = 0; + InputAction::None + } + b'F' => { + // End + *cursor = input.len(); + adjust_scroll(input, *cursor, scroll, cols); + InputAction::None + } + _ => InputAction::None, + } +} + +fn run_attach_simple(name: &str, full: bool, stream: UnixStream) { + use std::sync::atomic::{AtomicBool, Ordering}; + static ATTACH_INTERRUPT: AtomicBool = AtomicBool::new(false); + + extern "C" fn handle_attach_int(_: i32) { + ATTACH_INTERRUPT.store(true, Ordering::Relaxed); + } + + let sig_action = nix::sys::signal::SigAction::new( + nix::sys::signal::SigHandler::Handler(handle_attach_int), + nix::sys::signal::SaFlags::empty(), + nix::sys::signal::SigSet::empty(), + ); + unsafe { + let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sig_action); + } + + let log_path = crate::state::log_path(name); + if log_path.exists() { + let log_path_str = log_path.to_string_lossy().to_string(); + std::thread::spawn(move || { + let _ = crate::log::show_logs(&log_path_str, Some(0), true); + }); + } + + let stdin = std::io::stdin(); + loop { + if ATTACH_INTERRUPT.load(Ordering::Relaxed) { + if full { + let _ = crate::ipc::send_cmd(&format!("signal {} INT", name)); + } + break; + } + let mut line = String::new(); + match stdin.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim_end_matches('\n').trim_end_matches('\r'); + let bytes = trimmed.as_bytes(); + if bytes.len() >= 2 && bytes[0] == 0x10 && (bytes[1] == b'd' || bytes[1] == b'D') { + break; + } + if !trimmed.is_empty() { + let _ = writeln!(&stream, "{}", trimmed); + let _ = (&stream).flush(); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } +} diff --git a/src/main.rs b/src/main.rs index 3a7951b..40c67b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod attach; mod cli; mod config; mod daemon; @@ -8,8 +9,6 @@ mod supervisor; use clap::Parser; use cli::{Cli, Commands}; -use std::io::Write; -use std::sync::atomic::{AtomicBool, Ordering}; fn main() { @@ -201,22 +200,7 @@ fn cmd_send(name: &str, message: &[String]) { } } -static ATTACH_INTERRUPT: AtomicBool = AtomicBool::new(false); - -extern "C" fn handle_attach_int(_: i32) { - ATTACH_INTERRUPT.store(true, Ordering::Relaxed); -} - fn cmd_attach(name: &str, full: bool) { - let sig_action = nix::sys::signal::SigAction::new( - nix::sys::signal::SigHandler::Handler(handle_attach_int), - nix::sys::signal::SaFlags::empty(), - nix::sys::signal::SigSet::empty(), - ); - unsafe { - let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sig_action); - } - let stream = match ipc::send_cmd_get_stream(&format!("stdin {}", name)) { Ok(s) => s, Err(e) => { @@ -225,43 +209,5 @@ fn cmd_attach(name: &str, full: bool) { } }; - // Tail the log file in a background thread - let log_path = crate::state::log_path(name); - if log_path.exists() { - let log_path_str = log_path.to_string_lossy().to_string(); - std::thread::spawn(move || { - let _ = crate::log::show_logs(&log_path_str, Some(0), true); - }); - } - - // Main loop: read stdin line by line, send to daemon - let stdin = std::io::stdin(); - loop { - if ATTACH_INTERRUPT.load(Ordering::Relaxed) { - if full { - let _ = ipc::send_cmd(&format!("signal {} INT", name)); - } - break; - } - let mut line = String::new(); - match stdin.read_line(&mut line) { - Ok(0) => break, - Ok(_) => { - let trimmed = line.trim_end_matches('\n').trim_end_matches('\r'); - let bytes = trimmed.as_bytes(); - if bytes.len() >= 2 && bytes[0] == 0x10 && (bytes[1] == b'd' || bytes[1] == b'D') - { - break; - } - if !trimmed.is_empty() { - let _ = writeln!(&stream, "{}", trimmed); - let _ = (&stream).flush(); - } - } - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { - continue; - } - Err(_) => break, - } - } + attach::run_attach(name, full, stream); }